From 86edc09e4c6b05704b376e35b9288516e46151b4 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Fri, 28 Aug 2026 15:09:02 +0200 Subject: [PATCH 01/42] feat(walkthrough): show where flows live, without making anyone build one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openregister ships a flows surface (the `flows` index at /flows, in the main menu) and its tour never mentioned it, so the automation was discoverable only to someone who already knew it was there. Found by widening gate 70's flows-page predicate, which matched only `type: "flows"` — a type exactly one fleet app declares. openregister ships its surface as an ordinary `type: "index"` page, so the gate reported NOT APPLICABLE: it read as covered while covering nothing. The step is `optional` with `allowManualNext`: showing where flows are edited must not become "author an automation before you may finish the tour". It targets `flows` — the ROUTE, lowercase, as the menu entry declares it. `data-cn-route` carries `item.route`, and that is what CnWalkthrough.resolveTarget() looks up. The menu entry's ID here is `Flows` with a capital F; targeting that would look right in review and resolve to nothing at runtime, and an optional step whose target is absent is SKIPPED silently. (The neighbouring `open-tables` step has exactly that shape — it targets the menu id `Tables` while advancing on route `tables` — which is worth a separate look.) All 36 required locales are translated, not just en/nl: this repo enforces full parity, and `test:l10n` fails on any missing or empty value. Verified: gate-70 0 findings, gate-96 rc=0, l10n parity OK across 36 locales, check:l10n-js rc=0. --- l10n/be.js | 5 ++++- l10n/be.json | 5 ++++- l10n/bg.js | 5 ++++- l10n/bg.json | 5 ++++- l10n/bs.js | 5 ++++- l10n/bs.json | 5 ++++- l10n/ca.js | 5 ++++- l10n/ca.json | 5 ++++- l10n/cs.js | 5 ++++- l10n/cs.json | 5 ++++- l10n/da.js | 5 ++++- l10n/da.json | 5 ++++- l10n/de.js | 5 ++++- l10n/de.json | 5 ++++- l10n/el.js | 5 ++++- l10n/el.json | 5 ++++- l10n/en.js | 5 ++++- l10n/en.json | 5 ++++- l10n/es.js | 5 ++++- l10n/es.json | 5 ++++- l10n/et.js | 5 ++++- l10n/et.json | 5 ++++- l10n/fi.js | 5 ++++- l10n/fi.json | 5 ++++- l10n/fr.js | 5 ++++- l10n/fr.json | 5 ++++- l10n/ga.js | 5 ++++- l10n/ga.json | 5 ++++- l10n/hr.js | 5 ++++- l10n/hr.json | 5 ++++- l10n/hu.js | 5 ++++- l10n/hu.json | 5 ++++- l10n/is.js | 5 ++++- l10n/is.json | 5 ++++- l10n/it.js | 5 ++++- l10n/it.json | 5 ++++- l10n/lb.js | 5 ++++- l10n/lb.json | 5 ++++- l10n/lt.js | 5 ++++- l10n/lt.json | 5 ++++- l10n/lv.js | 5 ++++- l10n/lv.json | 5 ++++- l10n/mk.js | 5 ++++- l10n/mk.json | 5 ++++- l10n/mt.js | 5 ++++- l10n/mt.json | 5 ++++- l10n/nb.js | 5 ++++- l10n/nb.json | 5 ++++- l10n/nl.js | 5 ++++- l10n/nl.json | 5 ++++- l10n/pl.js | 5 ++++- l10n/pl.json | 5 ++++- l10n/pt.js | 5 ++++- l10n/pt.json | 5 ++++- l10n/rm.js | 5 ++++- l10n/rm.json | 5 ++++- l10n/ro.js | 5 ++++- l10n/ro.json | 5 ++++- l10n/ru.js | 5 ++++- l10n/ru.json | 5 ++++- l10n/sk.js | 5 ++++- l10n/sk.json | 5 ++++- l10n/sl.js | 5 ++++- l10n/sl.json | 5 ++++- l10n/sq.js | 5 ++++- l10n/sq.json | 5 ++++- l10n/sr.js | 5 ++++- l10n/sr.json | 5 ++++- l10n/sv.js | 5 ++++- l10n/sv.json | 5 ++++- l10n/tr.js | 5 ++++- l10n/tr.json | 5 ++++- l10n/uk.js | 5 ++++- l10n/uk.json | 5 ++++- src/manifest.json | 20 +++++++++++++++++++- 75 files changed, 315 insertions(+), 75 deletions(-) diff --git a/l10n/be.js b/l10n/be.js index 101ea5022d..f9446ee61f 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дэскрыптары рэестраў", "ships v{shipped}": "пастаўляе v{shipped}", "State": "Стан", - "v{installed} → ships v{shipped}": "v{installed} → пастаўляе v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → пастаўляе v{shipped}", + "Where the automation lives": "Дзе жыве аўтаматызацыя", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Патокі — гэта тое, што адбываецца без націскаў: аб'ект, які пазначаецца пры захаванні, праграма, якую апавяшчаюць пры змене запісу. Тут вы іх чытаеце і рэдагуеце. Зараз нічога будаваць не трэба.", + "Open Flows in the menu": "Адкрыйце Патокі ў меню" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/be.json b/l10n/be.json index cd634403d7..2249335f0f 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Дэскрыптары рэестраў", "ships v{shipped}": "пастаўляе v{shipped}", "State": "Стан", - "v{installed} → ships v{shipped}": "v{installed} → пастаўляе v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → пастаўляе v{shipped}", + "Where the automation lives": "Дзе жыве аўтаматызацыя", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Патокі — гэта тое, што адбываецца без націскаў: аб'ект, які пазначаецца пры захаванні, праграма, якую апавяшчаюць пры змене запісу. Тут вы іх чытаеце і рэдагуеце. Зараз нічога будаваць не трэба.", + "Open Flows in the menu": "Адкрыйце Патокі ў меню" }, "plurals": { "{count} email": [ diff --git a/l10n/bg.js b/l10n/bg.js index 5b504e14d1..253e738992 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "доставя v{shipped}", "State": "Състояние", - "v{installed} → ships v{shipped}": "v{installed} → доставя v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → доставя v{shipped}", + "Where the automation lives": "Къде живее автоматизацията", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоците са това, което се случва без някой да кликне: обект, подпечатан при запис, приложение, уведомено при промяна на запис. Тук ги четете и редактирате. Сега няма какво да се изгражда.", + "Open Flows in the menu": "Отворете Потоци в менюто" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index 2188f8ae81..549d72f8f1 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "доставя v{shipped}", "State": "Състояние", - "v{installed} → ships v{shipped}": "v{installed} → доставя v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → доставя v{shipped}", + "Where the automation lives": "Къде живее автоматизацията", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоците са това, което се случва без някой да кликне: обект, подпечатан при запис, приложение, уведомено при промяна на запис. Тук ги четете и редактирате. Сега няма какво да се изгражда.", + "Open Flows in the menu": "Отворете Потоци в менюто" }, "plurals": { "{count} email": [ diff --git a/l10n/bs.js b/l10n/bs.js index 86ad833f49..c297cf9ba1 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}", + "Where the automation lives": "Gdje živi automatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi su ono što se dešava bez klika: objekat označen pri spremanju, aplikacija obaviještena kad se zapis promijeni. Ovdje ih čitate i uređujete. Sada nema šta graditi.", + "Open Flows in the menu": "Otvorite Tokove u meniju" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bs.json b/l10n/bs.json index f11d9fae11..db6a51f081 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}", + "Where the automation lives": "Gdje živi automatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi su ono što se dešava bez klika: objekat označen pri spremanju, aplikacija obaviještena kad se zapis promijeni. Ovdje ih čitate i uređujete. Sada nema šta graditi.", + "Open Flows in the menu": "Otvorite Tokove u meniju" }, "plurals": { "{count} email": [ diff --git a/l10n/ca.js b/l10n/ca.js index 5ad9273830..105dd38237 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descriptors de registre", "ships v{shipped}": "lliura v{shipped}", "State": "Estat", - "v{installed} → ships v{shipped}": "v{installed} → lliura v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → lliura v{shipped}", + "Where the automation lives": "On viu l'automatització", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Els fluxos són el que passa sense que ningú faci clic: un objecte segellat en desar, una aplicació avisada quan canvia un registre. Aquí els llegiu i els editeu. Ara no cal construir res.", + "Open Flows in the menu": "Obriu Fluxos al menú" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index ee4dfa7362..4ae6391f31 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Descriptors de registre", "ships v{shipped}": "lliura v{shipped}", "State": "Estat", - "v{installed} → ships v{shipped}": "v{installed} → lliura v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → lliura v{shipped}", + "Where the automation lives": "On viu l'automatització", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Els fluxos són el que passa sense que ningú faci clic: un objecte segellat en desar, una aplicació avisada quan canvia un registre. Aquí els llegiu i els editeu. Ara no cal construir res.", + "Open Flows in the menu": "Obriu Fluxos al menú" }, "plurals": { "{count} email": [ diff --git a/l10n/cs.js b/l10n/cs.js index 44a8e0cf5b..0e4918cfe6 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptory registrů", "ships v{shipped}": "dodává v{shipped}", "State": "Stav", - "v{installed} → ships v{shipped}": "v{installed} → dodává v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dodává v{shipped}", + "Where the automation lives": "Kde žije automatizace", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Toky jsou to, co se děje bez kliknutí: objekt označený při uložení, aplikace informovaná při změně záznamu. Zde je čtete a upravujete. Nyní není třeba nic stavět.", + "Open Flows in the menu": "Otevřete Toky v nabídce" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/cs.json b/l10n/cs.json index 289437f526..44fe01e1f2 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Deskriptory registrů", "ships v{shipped}": "dodává v{shipped}", "State": "Stav", - "v{installed} → ships v{shipped}": "v{installed} → dodává v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dodává v{shipped}", + "Where the automation lives": "Kde žije automatizace", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Toky jsou to, co se děje bez kliknutí: objekt označený při uložení, aplikace informovaná při změně záznamu. Zde je čtete a upravujete. Nyní není třeba nic stavět.", + "Open Flows in the menu": "Otevřete Toky v nabídce" }, "plurals": { "{count} email": [ diff --git a/l10n/da.js b/l10n/da.js index 82950a9158..95798bc262 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Register-deskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}", + "Where the automation lives": "Hvor automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows er det, der sker uden at nogen klikker: et objekt der stemples ved gem, en app der får besked når en post ændres. Her læser og redigerer du dem. Der er intet at bygge nu.", + "Open Flows in the menu": "Åbn Flows i menuen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 433654fbfc..8fafd65f26 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Register-deskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}", + "Where the automation lives": "Hvor automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows er det, der sker uden at nogen klikker: et objekt der stemples ved gem, en app der får besked når en post ændres. Her læser og redigerer du dem. Der er intet at bygge nu.", + "Open Flows in the menu": "Åbn Flows i menuen" }, "plurals": { "{count} email": [ diff --git a/l10n/de.js b/l10n/de.js index 0e646f1490..b1b51f1ad8 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liefert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → liefert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → liefert v{shipped}", + "Where the automation lives": "Wo die Automatisierung lebt", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows sind das, was ohne Klick passiert: ein Objekt, das beim Speichern gestempelt wird, eine nachgelagerte App, die bei einer Änderung benachrichtigt wird. Hier lesen und bearbeiten Sie sie. Jetzt ist nichts zu bauen.", + "Open Flows in the menu": "Flows im Menü öffnen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index 419b49424d..169f6302c4 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liefert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → liefert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → liefert v{shipped}", + "Where the automation lives": "Wo die Automatisierung lebt", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows sind das, was ohne Klick passiert: ein Objekt, das beim Speichern gestempelt wird, eine nachgelagerte App, die bei einer Änderung benachrichtigt wird. Hier lesen und bearbeiten Sie sie. Jetzt ist nichts zu bauen.", + "Open Flows in the menu": "Flows im Menü öffnen" }, "plurals": { "{count} email": [ diff --git a/l10n/el.js b/l10n/el.js index 02e9fc8a30..c0cf1623aa 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Περιγραφείς μητρώων", "ships v{shipped}": "παρέχει v{shipped}", "State": "Κατάσταση", - "v{installed} → ships v{shipped}": "v{installed} → παρέχει v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → παρέχει v{shipped}", + "Where the automation lives": "Πού ζει ο αυτοματισμός", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Οι ροές είναι ό,τι συμβαίνει χωρίς να κάνει κανείς κλικ: ένα αντικείμενο που σφραγίζεται κατά την αποθήκευση, μια εφαρμογή που ειδοποιείται όταν αλλάζει μια εγγραφή. Εδώ τις διαβάζετε και τις επεξεργάζεστε. Δεν χρειάζεται να φτιάξετε τίποτα τώρα.", + "Open Flows in the menu": "Ανοίξτε τις Ροές στο μενού" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index 8401d62cef..4d3b111e42 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Περιγραφείς μητρώων", "ships v{shipped}": "παρέχει v{shipped}", "State": "Κατάσταση", - "v{installed} → ships v{shipped}": "v{installed} → παρέχει v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → παρέχει v{shipped}", + "Where the automation lives": "Πού ζει ο αυτοματισμός", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Οι ροές είναι ό,τι συμβαίνει χωρίς να κάνει κανείς κλικ: ένα αντικείμενο που σφραγίζεται κατά την αποθήκευση, μια εφαρμογή που ειδοποιείται όταν αλλάζει μια εγγραφή. Εδώ τις διαβάζετε και τις επεξεργάζεστε. Δεν χρειάζεται να φτιάξετε τίποτα τώρα.", + "Open Flows in the menu": "Ανοίξτε τις Ροές στο μενού" }, "plurals": { "{count} email": [ diff --git a/l10n/en.js b/l10n/en.js index 51d34b4113..2c8ac399c7 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Register descriptors", "ships v{shipped}": "ships v{shipped}", "State": "State", - "v{installed} → ships v{shipped}": "v{installed} → ships v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → ships v{shipped}", + "Where the automation lives": "Where the automation lives", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.", + "Open Flows in the menu": "Open Flows in the menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 62c4766138..47da3aa4c2 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Register descriptors", "ships v{shipped}": "ships v{shipped}", "State": "State", - "v{installed} → ships v{shipped}": "v{installed} → ships v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → ships v{shipped}", + "Where the automation lives": "Where the automation lives", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.", + "Open Flows in the menu": "Open Flows in the menu" }, "plurals": { "{count} email": [ diff --git a/l10n/es.js b/l10n/es.js index 023da9c390..9d76caec84 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descriptores de registro", "ships v{shipped}": "entrega v{shipped}", "State": "Estado", - "v{installed} → ships v{shipped}": "v{installed} → entrega v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → entrega v{shipped}", + "Where the automation lives": "Dónde vive la automatización", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Los flujos son lo que ocurre sin que nadie haga clic: un objeto que se sella al guardar, una aplicación avisada cuando cambia un registro. Aquí los lees y los editas. No hay nada que construir ahora.", + "Open Flows in the menu": "Abrir Flujos en el menú" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index 913fd89106..189f4b657f 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Descriptores de registro", "ships v{shipped}": "entrega v{shipped}", "State": "Estado", - "v{installed} → ships v{shipped}": "v{installed} → entrega v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → entrega v{shipped}", + "Where the automation lives": "Dónde vive la automatización", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Los flujos son lo que ocurre sin que nadie haga clic: un objeto que se sella al guardar, una aplicación avisada cuando cambia un registro. Aquí los lees y los editas. No hay nada que construir ahora.", + "Open Flows in the menu": "Abrir Flujos en el menú" }, "plurals": { "{count} email": [ diff --git a/l10n/et.js b/l10n/et.js index eca19e74dd..6615dfcff6 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Registrikirjeldajad", "ships v{shipped}": "tarnib v{shipped}", "State": "Olek", - "v{installed} → ships v{shipped}": "v{installed} → tarnib v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → tarnib v{shipped}", + "Where the automation lives": "Kus automatiseerimine elab", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Vood on see, mis juhtub ilma klõpsamata: salvestamisel tembeldatud objekt, rakendus, keda teavitatakse kirje muutumisel. Siin loete ja muudate neid. Praegu pole midagi ehitada.", + "Open Flows in the menu": "Avage Vood menüüs" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index 2ce108ddb3..1f6b9153f3 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Registrikirjeldajad", "ships v{shipped}": "tarnib v{shipped}", "State": "Olek", - "v{installed} → ships v{shipped}": "v{installed} → tarnib v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → tarnib v{shipped}", + "Where the automation lives": "Kus automatiseerimine elab", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Vood on see, mis juhtub ilma klõpsamata: salvestamisel tembeldatud objekt, rakendus, keda teavitatakse kirje muutumisel. Siin loete ja muudate neid. Praegu pole midagi ehitada.", + "Open Flows in the menu": "Avage Vood menüüs" }, "plurals": { "{count} email": [ diff --git a/l10n/fi.js b/l10n/fi.js index 1b59565f75..ec37866c2f 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Rekisterikuvaukset", "ships v{shipped}": "toimittaa v{shipped}", "State": "Tila", - "v{installed} → ships v{shipped}": "v{installed} → toimittaa v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → toimittaa v{shipped}", + "Where the automation lives": "Missä automaatio asuu", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Vuot ovat sitä, mitä tapahtuu ilman että kukaan klikkaa: tallennettaessa leimattava objekti, sovellus jolle kerrotaan kun tietue muuttuu. Täällä luet ja muokkaat niitä. Nyt ei tarvitse rakentaa mitään.", + "Open Flows in the menu": "Avaa Vuot valikosta" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 0109b932ca..e196c948da 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Rekisterikuvaukset", "ships v{shipped}": "toimittaa v{shipped}", "State": "Tila", - "v{installed} → ships v{shipped}": "v{installed} → toimittaa v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → toimittaa v{shipped}", + "Where the automation lives": "Missä automaatio asuu", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Vuot ovat sitä, mitä tapahtuu ilman että kukaan klikkaa: tallennettaessa leimattava objekti, sovellus jolle kerrotaan kun tietue muuttuu. Täällä luet ja muokkaat niitä. Nyt ei tarvitse rakentaa mitään.", + "Open Flows in the menu": "Avaa Vuot valikosta" }, "plurals": { "{count} email": [ diff --git a/l10n/fr.js b/l10n/fr.js index ac1815b378..90f9b07e52 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descripteurs de registre", "ships v{shipped}": "fournit v{shipped}", "State": "État", - "v{installed} → ships v{shipped}": "v{installed} → fournit v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fournit v{shipped}", + "Where the automation lives": "Où vit l'automatisation", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Les flux sont ce qui se produit sans que personne ne clique : un objet horodaté à l'enregistrement, une application avertie quand un enregistrement change. C'est ici que vous les consultez et les modifiez. Rien à construire maintenant.", + "Open Flows in the menu": "Ouvrir Flux dans le menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 1c5605fb84..204880c27a 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Descripteurs de registre", "ships v{shipped}": "fournit v{shipped}", "State": "État", - "v{installed} → ships v{shipped}": "v{installed} → fournit v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fournit v{shipped}", + "Where the automation lives": "Où vit l'automatisation", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Les flux sont ce qui se produit sans que personne ne clique : un objet horodaté à l'enregistrement, une application avertie quand un enregistrement change. C'est ici que vous les consultez et les modifiez. Rien à construire maintenant.", + "Open Flows in the menu": "Ouvrir Flux dans le menu" }, "plurals": { "{count} email": [ diff --git a/l10n/ga.js b/l10n/ga.js index 285a5308fd..11f2a4fcd7 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Tuairisceoirí clár", "ships v{shipped}": "soláthraíonn sé v{shipped}", "State": "Staid", - "v{installed} → ships v{shipped}": "v{installed} → soláthraíonn sé v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → soláthraíonn sé v{shipped}", + "Where the automation lives": "Áit a mhaireann an uathoibriú", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Is éard atá i sruthanna ná an rud a tharlaíonn gan aon chliceáil: réad a stampáiltear ar shábháil, aip a chuirtear ar an eolas nuair a athraíonn taifead. Is anseo a léann tú agus a chuireann tú in eagar iad. Níl aon rud le tógáil anois.", + "Open Flows in the menu": "Oscail Sruthanna sa roghchlár" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ga.json b/l10n/ga.json index f428a5bcc9..00f205c5f0 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -2728,7 +2728,10 @@ "Register descriptors": "Tuairisceoirí clár", "ships v{shipped}": "soláthraíonn sé v{shipped}", "State": "Staid", - "v{installed} → ships v{shipped}": "v{installed} → soláthraíonn sé v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → soláthraíonn sé v{shipped}", + "Where the automation lives": "Áit a mhaireann an uathoibriú", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Is éard atá i sruthanna ná an rud a tharlaíonn gan aon chliceáil: réad a stampáiltear ar shábháil, aip a chuirtear ar an eolas nuair a athraíonn taifead. Is anseo a léann tú agus a chuireann tú in eagar iad. Níl aon rud le tógáil anois.", + "Open Flows in the menu": "Oscail Sruthanna sa roghchlár" }, "plurals": { "{count} email": [ diff --git a/l10n/hr.js b/l10n/hr.js index 2edd9d4b36..5506f0a3f1 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}", + "Where the automation lives": "Gdje živi automatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi su ono što se događa bez klika: objekt označen pri spremanju, aplikacija obaviještena kad se zapis promijeni. Ovdje ih čitate i uređujete. Sada nema što graditi.", + "Open Flows in the menu": "Otvorite Tokove u izborniku" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hr.json b/l10n/hr.json index ccc993586a..2b78d7f999 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Deskriptori registara", "ships v{shipped}": "isporučuje v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → isporučuje v{shipped}", + "Where the automation lives": "Gdje živi automatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi su ono što se događa bez klika: objekt označen pri spremanju, aplikacija obaviještena kad se zapis promijeni. Ovdje ih čitate i uređujete. Sada nema što graditi.", + "Open Flows in the menu": "Otvorite Tokove u izborniku" }, "plurals": { "{count} email": [ diff --git a/l10n/hu.js b/l10n/hu.js index 724b4da19e..b3d495b1f3 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Regiszterleírók", "ships v{shipped}": "szállítja: v{shipped}", "State": "Állapot", - "v{installed} → ships v{shipped}": "v{installed} → szállítja: v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → szállítja: v{shipped}", + "Where the automation lives": "Ahol az automatizálás lakik", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "A folyamatok azok, amik kattintás nélkül történnek: mentéskor bélyegzett objektum, értesített alkalmazás rekordváltozáskor. Itt olvashatja és szerkesztheti őket. Most nincs mit építeni.", + "Open Flows in the menu": "Nyissa meg a Folyamatokat a menüben" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index f63ed369f6..e32644ec99 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Regiszterleírók", "ships v{shipped}": "szállítja: v{shipped}", "State": "Állapot", - "v{installed} → ships v{shipped}": "v{installed} → szállítja: v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → szállítja: v{shipped}", + "Where the automation lives": "Ahol az automatizálás lakik", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "A folyamatok azok, amik kattintás nélkül történnek: mentéskor bélyegzett objektum, értesített alkalmazás rekordváltozáskor. Itt olvashatja és szerkesztheti őket. Most nincs mit építeni.", + "Open Flows in the menu": "Nyissa meg a Folyamatokat a menüben" }, "plurals": { "{count} email": [ diff --git a/l10n/is.js b/l10n/is.js index b40de7f229..b2bf11c62f 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Skrárlýsingar", "ships v{shipped}": "skilar v{shipped}", "State": "Staða", - "v{installed} → ships v{shipped}": "v{installed} → skilar v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → skilar v{shipped}", + "Where the automation lives": "Þar sem sjálfvirknin býr", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flæði er það sem gerist án þess að nokkur smelli: hlutur sem er stimplaður við vistun, forrit sem fær boð þegar færsla breytist. Hér lestu og breytir þeim. Ekkert að byggja núna.", + "Open Flows in the menu": "Opnaðu Flæði í valmyndinni" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index c69f121df8..078cb1eea3 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Skrárlýsingar", "ships v{shipped}": "skilar v{shipped}", "State": "Staða", - "v{installed} → ships v{shipped}": "v{installed} → skilar v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → skilar v{shipped}", + "Where the automation lives": "Þar sem sjálfvirknin býr", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flæði er það sem gerist án þess að nokkur smelli: hlutur sem er stimplaður við vistun, forrit sem fær boð þegar færsla breytist. Hér lestu og breytir þeim. Ekkert að byggja núna.", + "Open Flows in the menu": "Opnaðu Flæði í valmyndinni" }, "plurals": { "{count} email": [ diff --git a/l10n/it.js b/l10n/it.js index 5afbf81639..ec5349acd4 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descrittori di registro", "ships v{shipped}": "fornisce v{shipped}", "State": "Stato", - "v{installed} → ships v{shipped}": "v{installed} → fornisce v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fornisce v{shipped}", + "Where the automation lives": "Dove vive l'automazione", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "I flussi sono ciò che accade senza che nessuno clicchi: un oggetto marcato al salvataggio, un'app avvisata quando un record cambia. Qui li leggi e li modifichi. Nulla da costruire ora.", + "Open Flows in the menu": "Apri Flussi nel menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index 66033dbf6f..493cc1b5f1 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Descrittori di registro", "ships v{shipped}": "fornisce v{shipped}", "State": "Stato", - "v{installed} → ships v{shipped}": "v{installed} → fornisce v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fornisce v{shipped}", + "Where the automation lives": "Dove vive l'automazione", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "I flussi sono ciò che accade senza che nessuno clicchi: un oggetto marcato al salvataggio, un'app avvisata quando un record cambia. Qui li leggi e li modifichi. Nulla da costruire ora.", + "Open Flows in the menu": "Apri Flussi nel menu" }, "plurals": { "{count} email": [ diff --git a/l10n/lb.js b/l10n/lb.js index f73a5e25c8..32a4c3ae65 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liwwert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → liwwert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → liwwert v{shipped}", + "Where the automation lives": "Wou d'Automatisatioun wunnt", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows sinn dat, wat ouni Klick geschitt: en Objet dee beim Späichere gestempelt gëtt, eng App déi Bescheed kritt wann e Record ännert. Hei liest an ännert Dir se. Elo ass näischt ze bauen.", + "Open Flows in the menu": "Flows am Menü opmaachen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 1a6bd20172..264c1e3e52 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Register-Deskriptoren", "ships v{shipped}": "liwwert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → liwwert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → liwwert v{shipped}", + "Where the automation lives": "Wou d'Automatisatioun wunnt", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows sinn dat, wat ouni Klick geschitt: en Objet dee beim Späichere gestempelt gëtt, eng App déi Bescheed kritt wann e Record ännert. Hei liest an ännert Dir se. Elo ass näischt ze bauen.", + "Open Flows in the menu": "Flows am Menü opmaachen" }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/lt.js b/l10n/lt.js index 9bc8fa95c6..40b00165a6 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Registrų deskriptoriai", "ships v{shipped}": "pateikia v{shipped}", "State": "Būsena", - "v{installed} → ships v{shipped}": "v{installed} → pateikia v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → pateikia v{shipped}", + "Where the automation lives": "Kur gyvena automatizavimas", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Srautai yra tai, kas vyksta niekam nespaudžiant: objektas, pažymimas išsaugant, programa, informuojama pasikeitus įrašui. Čia juos skaitote ir redaguojate. Dabar nieko kurti nereikia.", + "Open Flows in the menu": "Atidarykite Srautus meniu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lt.json b/l10n/lt.json index fb28b63fda..e6fb842aa9 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Registrų deskriptoriai", "ships v{shipped}": "pateikia v{shipped}", "State": "Būsena", - "v{installed} → ships v{shipped}": "v{installed} → pateikia v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → pateikia v{shipped}", + "Where the automation lives": "Kur gyvena automatizavimas", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Srautai yra tai, kas vyksta niekam nespaudžiant: objektas, pažymimas išsaugant, programa, informuojama pasikeitus įrašui. Čia juos skaitote ir redaguojate. Dabar nieko kurti nereikia.", + "Open Flows in the menu": "Atidarykite Srautus meniu" }, "plurals": { "{count} email": [ diff --git a/l10n/lv.js b/l10n/lv.js index bbf7946dee..8782c216ad 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Reģistru deskriptori", "ships v{shipped}": "piegādā v{shipped}", "State": "Stāvoklis", - "v{installed} → ships v{shipped}": "v{installed} → piegādā v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → piegādā v{shipped}", + "Where the automation lives": "Kur dzīvo automatizācija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Plūsmas ir tas, kas notiek bez klikšķa: objekts, kas tiek zīmogots saglabājot, lietotne, kurai paziņo, kad ieraksts mainās. Šeit tās lasāt un rediģējat. Tagad nav jāveido nekas.", + "Open Flows in the menu": "Atveriet Plūsmas izvēlnē" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lv.json b/l10n/lv.json index a674ca83b2..9357c9f69a 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Reģistru deskriptori", "ships v{shipped}": "piegādā v{shipped}", "State": "Stāvoklis", - "v{installed} → ships v{shipped}": "v{installed} → piegādā v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → piegādā v{shipped}", + "Where the automation lives": "Kur dzīvo automatizācija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Plūsmas ir tas, kas notiek bez klikšķa: objekts, kas tiek zīmogots saglabājot, lietotne, kurai paziņo, kad ieraksts mainās. Šeit tās lasāt un rediģējat. Tagad nav jāveido nekas.", + "Open Flows in the menu": "Atveriet Plūsmas izvēlnē" }, "plurals": { "{count} email": [ diff --git a/l10n/mk.js b/l10n/mk.js index 1ad7661465..9bd8f7d1ef 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "испорачува v{shipped}", "State": "Состојба", - "v{installed} → ships v{shipped}": "v{installed} → испорачува v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → испорачува v{shipped}", + "Where the automation lives": "Каде живее автоматизацијата", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Текови се тоа што се случува без некој да кликне: објект означен при зачувување, апликација известена кога запис ќе се промени. Тука ги читате и уредувате. Сега нема што да се гради.", + "Open Flows in the menu": "Отворете Текови во менито" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 651c153a81..823b75f189 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Дескриптори на регистри", "ships v{shipped}": "испорачува v{shipped}", "State": "Состојба", - "v{installed} → ships v{shipped}": "v{installed} → испорачува v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → испорачува v{shipped}", + "Where the automation lives": "Каде живее автоматизацијата", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Текови се тоа што се случува без некој да кликне: објект означен при зачувување, апликација известена кога запис ќе се промени. Тука ги читате и уредувате. Сега нема што да се гради.", + "Open Flows in the menu": "Отворете Текови во менито" }, "plurals": { "{count} email": [ diff --git a/l10n/mt.js b/l10n/mt.js index 5d8e9d1ae2..50519b5ff5 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskritturi tar-reġistri", "ships v{shipped}": "iwassal v{shipped}", "State": "Stat", - "v{installed} → ships v{shipped}": "v{installed} → iwassal v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → iwassal v{shipped}", + "Where the automation lives": "Fejn tgħix l-awtomazzjoni", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Il-flussi huma dak li jiġri mingħajr ma xi ħadd jikklikkja: oġġett ittimbrat mal-iffrankar, applikazzjoni mgħarrfa meta jinbidel rekord. Hawn taqrahom u teditjahom. M'hemm xejn x'tibni issa.", + "Open Flows in the menu": "Iftaħ Flussi fil-menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/mt.json b/l10n/mt.json index f2994daec5..81a088ac1b 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -2727,7 +2727,10 @@ "Register descriptors": "Deskritturi tar-reġistri", "ships v{shipped}": "iwassal v{shipped}", "State": "Stat", - "v{installed} → ships v{shipped}": "v{installed} → iwassal v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → iwassal v{shipped}", + "Where the automation lives": "Fejn tgħix l-awtomazzjoni", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Il-flussi huma dak li jiġri mingħajr ma xi ħadd jikklikkja: oġġett ittimbrat mal-iffrankar, applikazzjoni mgħarrfa meta jinbidel rekord. Hawn taqrahom u teditjahom. M'hemm xejn x'tibni issa.", + "Open Flows in the menu": "Iftaħ Flussi fil-menu" }, "plurals": { "{count} email": [ diff --git a/l10n/nb.js b/l10n/nb.js index da08211319..b297307db0 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}", + "Where the automation lives": "Der automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flyter er det som skjer uten at noen klikker: et objekt som stemples ved lagring, en app som varsles når en post endres. Her leser og redigerer du dem. Ingenting å bygge nå.", + "Open Flows in the menu": "Åpne Flyter i menyen" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index d1bc95d31d..9c14da2f58 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "leverer v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → leverer v{shipped}", + "Where the automation lives": "Der automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flyter er det som skjer uten at noen klikker: et objekt som stemples ved lagring, en app som varsles når en post endres. Her leser og redigerer du dem. Ingenting å bygge nå.", + "Open Flows in the menu": "Åpne Flyter i menyen" }, "plurals": { "{count} email": [ diff --git a/l10n/nl.js b/l10n/nl.js index 95ed4d79d6..69bf81f0dc 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2775,7 +2775,10 @@ OC.L10N.register( "Register descriptors": "Register-descriptors", "ships v{shipped}": "levert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → levert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → levert v{shipped}", + "Where the automation lives": "Waar de automatisering zit", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows zijn wat er gebeurt zonder dat iemand klikt: een object dat bij opslaan wordt gestempeld, een afnemende app die bericht krijgt wanneer een record verandert. Hier leest en bewerkt u ze. U hoeft nu niets te bouwen.", + "Open Flows in the menu": "Open Flows in het menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index ffe87e8376..52e78b60be 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -2777,7 +2777,10 @@ "Register descriptors": "Register-descriptors", "ships v{shipped}": "levert v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → levert v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → levert v{shipped}", + "Where the automation lives": "Waar de automatisering zit", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flows zijn wat er gebeurt zonder dat iemand klikt: een object dat bij opslaan wordt gestempeld, een afnemende app die bericht krijgt wanneer een record verandert. Hier leest en bewerkt u ze. U hoeft nu niets te bouwen.", + "Open Flows in the menu": "Open Flows in het menu" }, "plurals": { "{count} email": [ diff --git a/l10n/pl.js b/l10n/pl.js index 1ba69bdf4f..03bafe9c07 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskryptory rejestrów", "ships v{shipped}": "dostarcza v{shipped}", "State": "Stan", - "v{installed} → ships v{shipped}": "v{installed} → dostarcza v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dostarcza v{shipped}", + "Where the automation lives": "Gdzie mieszka automatyzacja", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Przepływy to co dzieje się bez kliknięcia: obiekt stemplowany przy zapisie, aplikacja powiadamiana gdy rekord się zmienia. Tutaj je czytasz i edytujesz. Teraz nic nie trzeba budować.", + "Open Flows in the menu": "Otwórz Przepływy w menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pl.json b/l10n/pl.json index 71238e558e..3ed5fdec54 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Deskryptory rejestrów", "ships v{shipped}": "dostarcza v{shipped}", "State": "Stan", - "v{installed} → ships v{shipped}": "v{installed} → dostarcza v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dostarcza v{shipped}", + "Where the automation lives": "Gdzie mieszka automatyzacja", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Przepływy to co dzieje się bez kliknięcia: obiekt stemplowany przy zapisie, aplikacja powiadamiana gdy rekord się zmienia. Tutaj je czytasz i edytujesz. Teraz nic nie trzeba budować.", + "Open Flows in the menu": "Otwórz Przepływy w menu" }, "plurals": { "{count} email": [ diff --git a/l10n/pt.js b/l10n/pt.js index 5da2bd72ce..0ae4e004e1 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descritores de registo", "ships v{shipped}": "fornece v{shipped}", "State": "Estado", - "v{installed} → ships v{shipped}": "v{installed} → fornece v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fornece v{shipped}", + "Where the automation lives": "Onde vive a automação", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Os fluxos são o que acontece sem ninguém clicar: um objeto carimbado ao guardar, uma aplicação avisada quando um registo muda. É aqui que os lê e edita. Nada a construir agora.", + "Open Flows in the menu": "Abrir Fluxos no menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 0df1efd2a5..2e34f969fb 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Descritores de registo", "ships v{shipped}": "fornece v{shipped}", "State": "Estado", - "v{installed} → ships v{shipped}": "v{installed} → fornece v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → fornece v{shipped}", + "Where the automation lives": "Onde vive a automação", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Os fluxos são o que acontece sem ninguém clicar: um objeto carimbado ao guardar, uma aplicação avisada quando um registo muda. É aqui que os lê e edita. Nada a construir agora.", + "Open Flows in the menu": "Abrir Fluxos no menu" }, "plurals": { "{count} email": [ diff --git a/l10n/rm.js b/l10n/rm.js index 711314e13e..431e212a47 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descripturs da register", "ships v{shipped}": "furnescha v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → furnescha v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → furnescha v{shipped}", + "Where the automation lives": "Nua che l'automatisaziun viva", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flussins èn quai che capita senza che insatgi cliccheschia: in object che vegn stampà cun memorisar, ina app che vegn infurmada cura ch'ina endataziun mida. Qua als legias e modifitgeschas. Uss n'è nagut da construir.", + "Open Flows in the menu": "Avrir Flussins en il menu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/rm.json b/l10n/rm.json index 7bf3ca2a25..22522079b8 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Descripturs da register", "ships v{shipped}": "furnescha v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → furnescha v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → furnescha v{shipped}", + "Where the automation lives": "Nua che l'automatisaziun viva", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flussins èn quai che capita senza che insatgi cliccheschia: in object che vegn stampà cun memorisar, ina app che vegn infurmada cura ch'ina endataziun mida. Qua als legias e modifitgeschas. Uss n'è nagut da construir.", + "Open Flows in the menu": "Avrir Flussins en il menu" }, "pluralForm": "nplurals=2; plural=(n != 1);", "plurals": { diff --git a/l10n/ro.js b/l10n/ro.js index 446e6f90c9..74a74c706f 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Descriptori de registru", "ships v{shipped}": "livrează v{shipped}", "State": "Stare", - "v{installed} → ships v{shipped}": "v{installed} → livrează v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → livrează v{shipped}", + "Where the automation lives": "Unde trăiește automatizarea", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Fluxurile sunt ce se întâmplă fără ca cineva să dea clic: un obiect ștampilat la salvare, o aplicație anunțată când un înregistrare se schimbă. Aici le citiți și le editați. Nu e nimic de construit acum.", + "Open Flows in the menu": "Deschideți Fluxuri în meniu" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ro.json b/l10n/ro.json index fd89123ebd..fc82375952 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Descriptori de registru", "ships v{shipped}": "livrează v{shipped}", "State": "Stare", - "v{installed} → ships v{shipped}": "v{installed} → livrează v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → livrează v{shipped}", + "Where the automation lives": "Unde trăiește automatizarea", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Fluxurile sunt ce se întâmplă fără ca cineva să dea clic: un obiect ștampilat la salvare, o aplicație anunțată când un înregistrare se schimbă. Aici le citiți și le editați. Nu e nimic de construit acum.", + "Open Flows in the menu": "Deschideți Fluxuri în meniu" }, "plurals": { "{count} email": [ diff --git a/l10n/ru.js b/l10n/ru.js index db9c164af6..4273463a46 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескрипторы реестров", "ships v{shipped}": "поставляет v{shipped}", "State": "Состояние", - "v{installed} → ships v{shipped}": "v{installed} → поставляет v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → поставляет v{shipped}", + "Where the automation lives": "Где живёт автоматизация", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоки — это то, что происходит без нажатий: объект, помечаемый при сохранении, приложение, уведомляемое при изменении записи. Здесь вы их читаете и редактируете. Сейчас ничего строить не нужно.", + "Open Flows in the menu": "Откройте Потоки в меню" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ru.json b/l10n/ru.json index fe4230231d..9ab5f760c3 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Дескрипторы реестров", "ships v{shipped}": "поставляет v{shipped}", "State": "Состояние", - "v{installed} → ships v{shipped}": "v{installed} → поставляет v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → поставляет v{shipped}", + "Where the automation lives": "Где живёт автоматизация", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоки — это то, что происходит без нажатий: объект, помечаемый при сохранении, приложение, уведомляемое при изменении записи. Здесь вы их читаете и редактируете. Сейчас ничего строить не нужно.", + "Open Flows in the menu": "Откройте Потоки в меню" }, "plurals": { "{count} email": [ diff --git a/l10n/sk.js b/l10n/sk.js index cee47e9037..6e960a9028 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptory registrov", "ships v{shipped}": "dodáva v{shipped}", "State": "Stav", - "v{installed} → ships v{shipped}": "v{installed} → dodáva v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dodáva v{shipped}", + "Where the automation lives": "Kde žije automatizácia", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Toky sú to, čo sa deje bez kliknutia: objekt označený pri uložení, aplikácia informovaná pri zmene záznamu. Tu ich čítate a upravujete. Teraz netreba nič stavať.", + "Open Flows in the menu": "Otvorte Toky v ponuke" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sk.json b/l10n/sk.json index 3d17424ae7..6f7c14e020 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -2726,6 +2726,9 @@ "Register descriptors": "Deskriptory registrov", "ships v{shipped}": "dodáva v{shipped}", "State": "Stav", - "v{installed} → ships v{shipped}": "v{installed} → dodáva v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dodáva v{shipped}", + "Where the automation lives": "Kde žije automatizácia", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Toky sú to, čo sa deje bez kliknutia: objekt označený pri uložení, aplikácia informovaná pri zmene záznamu. Tu ich čítate a upravujete. Teraz netreba nič stavať.", + "Open Flows in the menu": "Otvorte Toky v ponuke" } } diff --git a/l10n/sl.js b/l10n/sl.js index 301e97c50f..ca2840c03f 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Deskriptorji registrov", "ships v{shipped}": "dobavlja v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → dobavlja v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dobavlja v{shipped}", + "Where the automation lives": "Kje živi avtomatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi so tisto, kar se zgodi brez klika: predmet, žigosan ob shranjevanju, aplikacija, obveščena ob spremembi zapisa. Tu jih berete in urejate. Zdaj ni treba ničesar graditi.", + "Open Flows in the menu": "Odprite Tokove v meniju" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sl.json b/l10n/sl.json index 053c2d5323..42aef4612c 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -2727,7 +2727,10 @@ "Register descriptors": "Deskriptorji registrov", "ships v{shipped}": "dobavlja v{shipped}", "State": "Stanje", - "v{installed} → ships v{shipped}": "v{installed} → dobavlja v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → dobavlja v{shipped}", + "Where the automation lives": "Kje živi avtomatizacija", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Tokovi so tisto, kar se zgodi brez klika: predmet, žigosan ob shranjevanju, aplikacija, obveščena ob spremembi zapisa. Tu jih berete in urejate. Zdaj ni treba ničesar graditi.", + "Open Flows in the menu": "Odprite Tokove v meniju" }, "plurals": { "{count} email": [ diff --git a/l10n/sq.js b/l10n/sq.js index c4ff8ce5cd..d308622ac5 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Përshkrues regjistrash", "ships v{shipped}": "jep v{shipped}", "State": "Gjendja", - "v{installed} → ships v{shipped}": "v{installed} → jep v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → jep v{shipped}", + "Where the automation lives": "Ku jeton automatizimi", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Rrjedhat janë ajo që ndodh pa klikuar askush: një objekt i vulosur në ruajtje, një aplikacion i njoftuar kur ndryshon një regjistrim. Këtu i lexoni dhe i redaktoni. Tani nuk ka gjë për të ndërtuar.", + "Open Flows in the menu": "Hapni Rrjedhat në meny" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index bef3041f17..42732e656c 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Përshkrues regjistrash", "ships v{shipped}": "jep v{shipped}", "State": "Gjendja", - "v{installed} → ships v{shipped}": "v{installed} → jep v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → jep v{shipped}", + "Where the automation lives": "Ku jeton automatizimi", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Rrjedhat janë ajo që ndodh pa klikuar askush: një objekt i vulosur në ruajtje, një aplikacion i njoftuar kur ndryshon një regjistrim. Këtu i lexoni dhe i redaktoni. Tani nuk ka gjë për të ndërtuar.", + "Open Flows in the menu": "Hapni Rrjedhat në meny" }, "plurals": { "{count} email": [ diff --git a/l10n/sr.js b/l10n/sr.js index 2a16b0cd81..111909d0ee 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескриптори регистара", "ships v{shipped}": "испоручује v{shipped}", "State": "Стање", - "v{installed} → ships v{shipped}": "v{installed} → испоручује v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → испоручује v{shipped}", + "Where the automation lives": "Где живи аутоматизација", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Токови су оно што се дешава без клика: објекат означен при чувању, апликација обавештена када се запис промени. Овде их читате и уређујете. Сада нема шта да се гради.", + "Open Flows in the menu": "Отворите Токове у менију" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sr.json b/l10n/sr.json index 74cb057493..83adf244ad 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Дескриптори регистара", "ships v{shipped}": "испоручује v{shipped}", "State": "Стање", - "v{installed} → ships v{shipped}": "v{installed} → испоручује v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → испоручује v{shipped}", + "Where the automation lives": "Где живи аутоматизација", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Токови су оно што се дешава без клика: објекат означен при чувању, апликација обавештена када се запис промени. Овде их читате и уређујете. Сада нема шта да се гради.", + "Open Flows in the menu": "Отворите Токове у менију" }, "plurals": { "{count} email": [ diff --git a/l10n/sv.js b/l10n/sv.js index ba87df1681..ff72868074 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "levererar v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → levererar v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → levererar v{shipped}", + "Where the automation lives": "Där automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flöden är det som händer utan att någon klickar: ett objekt som stämplas vid sparande, en app som meddelas när en post ändras. Här läser och redigerar du dem. Inget att bygga nu.", + "Open Flows in the menu": "Öppna Flöden i menyn" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index c0d2247143..bd70d369db 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Registerdeskriptorer", "ships v{shipped}": "levererar v{shipped}", "State": "Status", - "v{installed} → ships v{shipped}": "v{installed} → levererar v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → levererar v{shipped}", + "Where the automation lives": "Där automatiseringen bor", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Flöden är det som händer utan att någon klickar: ett objekt som stämplas vid sparande, en app som meddelas när en post ändras. Här läser och redigerar du dem. Inget att bygga nu.", + "Open Flows in the menu": "Öppna Flöden i menyn" }, "plurals": { "{count} email": [ diff --git a/l10n/tr.js b/l10n/tr.js index a49108eb7f..c36af23ef4 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Kayıt tanımlayıcıları", "ships v{shipped}": "v{shipped} sunuyor", "State": "Durum", - "v{installed} → ships v{shipped}": "v{installed} → v{shipped} sunuyor" + "v{installed} → ships v{shipped}": "v{installed} → v{shipped} sunuyor", + "Where the automation lives": "Otomasyonun yaşadığı yer", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Akışlar kimse tıklamadan olan şeylerdir: kaydederken damgalanan bir nesne, bir kayıt değiştiğinde haber verilen bir uygulama. Bunları burada okur ve düzenlersiniz. Şimdi inşa edilecek bir şey yok.", + "Open Flows in the menu": "Menüden Akışlar'ı açın" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/tr.json b/l10n/tr.json index 7437cea572..67e4cc1387 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -2725,7 +2725,10 @@ "Register descriptors": "Kayıt tanımlayıcıları", "ships v{shipped}": "v{shipped} sunuyor", "State": "Durum", - "v{installed} → ships v{shipped}": "v{installed} → v{shipped} sunuyor" + "v{installed} → ships v{shipped}": "v{installed} → v{shipped} sunuyor", + "Where the automation lives": "Otomasyonun yaşadığı yer", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Akışlar kimse tıklamadan olan şeylerdir: kaydederken damgalanan bir nesne, bir kayıt değiştiğinde haber verilen bir uygulama. Bunları burada okur ve düzenlersiniz. Şimdi inşa edilecek bir şey yok.", + "Open Flows in the menu": "Menüden Akışlar'ı açın" }, "plurals": { "{count} email": [ diff --git a/l10n/uk.js b/l10n/uk.js index 7e875fbb16..0e0068f2f1 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -2723,7 +2723,10 @@ OC.L10N.register( "Register descriptors": "Дескриптори реєстрів", "ships v{shipped}": "постачає v{shipped}", "State": "Стан", - "v{installed} → ships v{shipped}": "v{installed} → постачає v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → постачає v{shipped}", + "Where the automation lives": "Де живе автоматизація", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоки — це те, що відбувається без кліків: об'єкт, що позначається під час збереження, застосунок, сповіщений про зміну запису. Тут ви їх читаєте та редагуєте. Зараз нічого будувати не потрібно.", + "Open Flows in the menu": "Відкрийте Потоки в меню" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/uk.json b/l10n/uk.json index 64075a88f9..0836a59077 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -2726,7 +2726,10 @@ "Register descriptors": "Дескриптори реєстрів", "ships v{shipped}": "постачає v{shipped}", "State": "Стан", - "v{installed} → ships v{shipped}": "v{installed} → постачає v{shipped}" + "v{installed} → ships v{shipped}": "v{installed} → постачає v{shipped}", + "Where the automation lives": "Де живе автоматизація", + "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.": "Потоки — це те, що відбувається без кліків: об'єкт, що позначається під час збереження, застосунок, сповіщений про зміну запису. Тут ви їх читаєте та редагуєте. Зараз нічого будувати не потрібно.", + "Open Flows in the menu": "Відкрийте Потоки в меню" }, "plurals": { "{count} email": [ diff --git a/src/manifest.json b/src/manifest.json index 4694e9e9da..82a8876fb4 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", - "version": "1.0.0", + "version": "1.1.0", "nav": { "includePersonalSettings": false }, @@ -795,6 +795,24 @@ "route": "tables" } }, + { + "id": "see-flows", + "sinceVersion": "1.1.0", + "placement": "right", + "optional": true, + "allowManualNext": true, + "title": "Where the automation lives", + "body": "Flows are what happens without anyone clicking: an object that gets stamped on save, a downstream app told when a record changes. This is where you read and edit them. Nothing to build now.", + "task": "Open Flows in the menu", + "target": { + "kind": "nav-item", + "ref": "flows" + }, + "advanceOn": { + "type": "route-match", + "route": "flows" + } + }, { "id": "done", "sinceVersion": "1.1.5", From 8ee91220c97efc972a7e31663984f5b73427d1db Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 28 Aug 2026 21:00:56 +0200 Subject: [PATCH 02/42] docs: add a local demo environment (openregister-compose.yaml + setup page) (#2978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add a local demo environment Adds `openregister-compose.yaml` and a setup page describing it. The compose brings up Postgres and Nextcloud, installs openregister (required), thematiq and integriq (optional) and openregister from release tarballs, and enables them in dependency order. Nothing is bind-mounted: Nextcloud installs an app by deleting its directory and extracting an archive over it, so pointing that at a checkout deletes the working tree — measured on a development machine on 2026-08-27, where an app-store update fired on a container restart and removed every top-level file including .git. Release tarballs rather than a clone for a second reason: a tarball is a complete app carrying vendor/ and the built js/, and an app with no vendor/ does not fail loudly — it warns once and keeps loading, so it looks installed while every service needing a dependency is absent. The openregister dependency is not declared in appinfo/info.xml — no app in the fleet declares an dependency — so the compose encodes what the manifest does not. Verified: docker compose config parses and interpolates; the same generated file was booted end to end for portaliq, which produced 17 registers, 86 schemas and 13 magic tables for its own register, with the portal content API returning a real site rather than an empty shell. * docs: make the demo verification command actually pass The verification curl was unauthenticated, and a Nextcloud app page requires a login, so it printed 401 on a healthy demo while the page described it as a pass. Measured on two booted demos: 401 without credentials, 200 with them. The command now carries them and says a bare 401 is expected. * test(e2e): check a demo environment the way its docs say to Validates a booted demo against the steps its own documentation tells the reader to run. Lives outside tests/e2e/ because the root config sets testDir: './tests/e2e' and this suite needs an already-booted demo that CI does not have -- collected there it would fail, and gated with a skip it would look identical in CI to a suite that ran and found nothing. None of the assertions is a status code, because two measurements taken while writing it show why: - the Nextcloud LOGIN page is served with HTTP 200, and basic auth does not authenticate a browser navigation (only API routes), so a status-only test passes while sitting on the login screen; - an unauthenticated app URL answers 401 on a healthy demo -- the request the demo documentation used to describe as a pass. Shown to fail, not just to pass: pointed at an app that is not installed, the two reachability tests fail; portal assertions forced on against a demo with no portal fail both. Green against two independently booted demos, portaliq (6 passed) and shillinq (4 passed, 2 correctly skipped). * fix(test): keep the demo e2e suite out of jest, and format it Adding tests/demo-e2e/ broke two frontend jobs, both my doing. jest collected the Playwright spec. Its ignore list named /tests/e2e/ specifically -- the Playwright suite -- and the demo suite deliberately sits OUTSIDE that directory, because playwright.config.ts sets testDir: './tests/e2e' and a spec placed there is collected by CI, which has no booted demo to point at. Solving the one collector walked straight into the other: two runners with opposite exclusions, so the file has to be named in both. Verified by listing tests rather than by reading the pattern: with the ignore jest collects 0 files under tests/demo-e2e/, without it 1. The two .ts files were also not in the repo's prettier style, which `prettier --check "**/*.{js,ts,vue,css,scss}"` covers. Formatted with the repo's own config; prettier --check is now clean on both. * style: format the demo e2e files with the repo's own prettier config The first attempt formatted copies in /tmp, which resolves a different prettier config and a different .prettierignore than the repo does -- so it reported the files clean while `prettier --check` at the repo root, which is what CI runs, still rejected them. It also made the repo's own jest.config.js look unclean, which it is not. Formatted in place. `prettier --check "**/*.{js,ts,vue,css,scss}"` -- CI's exact command, run from the repository root -- is now clean across the whole tree. --------- Co-authored-by: Conduction Release Bot --- docs/Installation/demo-environment.md | 130 ++++++++++++++ jest.config.js | 8 + openregister-compose.yaml | 250 ++++++++++++++++++++++++++ tests/demo-e2e/README.md | 67 +++++++ tests/demo-e2e/demo-journey.spec.ts | 145 +++++++++++++++ tests/demo-e2e/playwright.config.ts | 20 +++ 6 files changed, 620 insertions(+) create mode 100644 docs/Installation/demo-environment.md create mode 100644 openregister-compose.yaml create mode 100644 tests/demo-e2e/README.md create mode 100644 tests/demo-e2e/demo-journey.spec.ts create mode 100644 tests/demo-e2e/playwright.config.ts diff --git a/docs/Installation/demo-environment.md b/docs/Installation/demo-environment.md new file mode 100644 index 0000000000..df984647bc --- /dev/null +++ b/docs/Installation/demo-environment.md @@ -0,0 +1,130 @@ +# Run a local demo + +This page gets a working OpenRegister running on your own machine in two commands. You end with a registry with registers and schemas you can model against. + +It is a **demo**, not a development environment. Nothing is mounted from a checkout, and that is deliberate — see [What this is not](#what-this-is-not). + +## What you need + +Docker, with Compose v2.23 or newer. Nothing else — no PHP, no Node, no Nextcloud. + +```bash +docker --version +docker compose version +``` + +If `docker compose version` prints v2.22 or older, upgrade first. The compose file declares its scripts inline via `configs`, and older versions ignore the `content:` field **silently** — which produces an instance with no apps installed and nothing in the logs to explain why. + +## Step 1 — get the compose file + +```bash +curl -fsSLO https://raw.githubusercontent.com/ConductionNL/openregister/development/openregister-compose.yaml +``` + +A single self-contained file. There is nothing else to fetch and nothing to edit. + +## Step 2 — start it + +```bash +docker compose -f openregister-compose.yaml up -d +``` + +The first run takes a few minutes: it pulls three images and downloads the application archives. Watch it work if you like: + +```bash +docker compose -f openregister-compose.yaml logs -f app-installer +``` + +You are looking for: + +``` +==> installing openregister +==> installing thematiq +==> installing integriq +==> apps present: integriq openregister thematiq +``` + +Then Nextcloud installs itself and enables the apps **in dependency order**. OpenRegister goes first: it owns the registers and schemas the others declare against, and a leaf app enabled before it finds no register to attach to. + +That is done when this returns `"installed":true`: + +```bash +curl -s http://localhost:8601/status.php +``` + +## Step 3 — open the demo + +| What | Where | +| --- | --- | +| **OpenRegister** | [http://localhost:8601/apps/openregister/](http://localhost:8601/apps/openregister/) | +| Admin interface | [http://localhost:8601](http://localhost:8601) — `admin` / `admin` | + +## What gets installed, and why more than one app + +| App | Why | +| --- | --- | +| `openregister` | **Required.** Every Connext app declares its registers and schemas against OpenRegister. | +| `thematiq` | Optional. Government theming. Absent, the UI renders unthemed rather than wrong. | +| `integriq` | Optional. The connector, for feeding in data from systems you do not control. | +| `openregister` | The app this page is about. | + +That OpenRegister dependency is **not declared** in `appinfo/info.xml` — no app in the fleet declares an `` dependency — so nothing stops the App Store from installing openregister without it. It would then load, find no register to attach to, and show you an empty app rather than an error. The compose file encodes the dependency the manifest does not. + +## Verifying it actually worked + +A page loading is not the same as a page working. Nextcloud serves its shell before the app decides whether it has anything to render, so an app URL returns HTTP 200 even when it resolves to nothing at all. A smoke test that checks for a 200 would call that a success. + +Check content instead: + +```bash +# The app answers. Note the credentials: an app page requires a login, so the +# SAME request without -u returns 401, which is not a broken demo — measured +# on a booted demo while writing this page. +curl -s -o /dev/null -w '%{http_code}\n' -u admin:admin -L "http://localhost:8601/apps/openregister/" + +# OpenRegister has registers — an empty list means the configuration +# was never imported, which is not the same as "nothing configured yet" +curl -s -u admin:admin "http://localhost:8601/apps/openregister/api/registers" | head -c 300 +``` + +## Changing the defaults + +The port and every version are overridable: + +```bash +DEMO_PORT=9000 \ +OPENREGISTER_VERSION=1.2.3 \ +docker compose -f openregister-compose.yaml up -d +``` + +Leaving a version empty resolves the newest release for that app, pre-releases included — which is what most Connext apps still ship, so that is the default. + +## Tearing it down + +```bash +# Stop, keep the data +docker compose -f openregister-compose.yaml down + +# Stop and delete everything, including the database +docker compose -f openregister-compose.yaml down -v +``` + +## What this is not + +**It is not a development environment, and it cannot be turned into one by adding a bind mount.** + +Nextcloud installs and updates an app by deleting the app directory and extracting a fresh archive over it. Point that at a checkout and an app-store update will delete your working tree — measured on a development machine on 27 August 2026, where `\OC\Updater::upgradeAppStoreApp` fired on a container restart and removed every top-level file from a bind-mounted checkout, including its `.git` directory. Only the subdirectories it lacked permission to unlink survived. + +So this compose keeps its apps in a named volume and installs them from release archives. That also happens to be the only thing that works: a release archive is a **complete** app carrying `vendor/` and the built `js/` bundle, while a `git clone` carries neither — and a Nextcloud app with no `vendor/` does not fail loudly. It warns once and keeps loading, so the app appears installed while every service that needs a dependency is quietly absent. + +To work *on* these apps rather than *with* them, use the development environment instead. + +## Troubleshooting + +**`app-installer` exits non-zero.** It could not download an archive. Check the log for the URL it tried; the most common cause is a pinned version with no matching release. + +**It stops with `openregister missing; aborting`.** Deliberate. Every other app declares registers against OpenRegister, so a stack without it would start and then fail in a dozen confusing ways instead of one clear one. + +**The UI renders unthemed.** Thematiq is not installed or not enabled. Expected, and cosmetic — the theme resolver renders unthemed rather than wrong when it is absent. + +**Everything returns 404 or a maintenance page after a restart.** Nextcloud is waiting for an upgrade. Run `docker compose -f openregister-compose.yaml exec -u www-data nextcloud php occ upgrade`. diff --git a/jest.config.js b/jest.config.js index 0b2eede0fe..5133e506a5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -68,6 +68,14 @@ module.exports = { '/\\.playwright-mcp/', // Playwright suite — has its own runner via `npx playwright test`. '/tests/e2e/', + // The demo-environment Playwright suite. It sits OUTSIDE tests/e2e/ on + // purpose: playwright.config.ts sets testDir: './tests/e2e', so a spec + // placed there is collected by CI, which has no booted demo to point + // at. Moving it out solves that and creates this: jest's ignore list + // named tests/e2e/ specifically, so the new directory was collected + // here instead and jest tried to run a Playwright spec. Two collectors + // with opposite exclusions — a file has to be named in both. + '/tests/demo-e2e/', ], modulePathIgnorePatterns: [ '/custom_apps/', diff --git a/openregister-compose.yaml b/openregister-compose.yaml new file mode 100644 index 0000000000..634cbac02a --- /dev/null +++ b/openregister-compose.yaml @@ -0,0 +1,250 @@ +# OpenRegister — local demo environment +# +# docker compose -f openregister-compose.yaml up -d +# +# Then open http://localhost:8601/apps/openregister/ +# Admin UI: http://localhost:8601 (admin / admin) +# +# Tear down, including all data: +# docker compose -f openregister-compose.yaml down -v +# +# --------------------------------------------------------------------------- +# THIS IS A DEMO ENVIRONMENT, NOT A DEVELOPMENT ENVIRONMENT. +# +# Nothing here is bind-mounted from your working copy, and that is deliberate +# rather than merely simpler. Nextcloud installs and updates an app by DELETING +# its directory and extracting a fresh archive over it. Measured 2026-08-27 on +# a development machine: `\OC\Updater::upgradeAppStoreApp` fired on a container +# restart and removed every top-level file from a bind-mounted checkout — +# including its `.git` directory — leaving only the subdirectories it lacked +# permission to unlink. A demo rig has no business pointing at a checkout, so +# this one owns its apps in a named volume. +# +# If you want to work ON these apps rather than WITH them, use the development +# environment instead. This file cannot serve that purpose and should not try. +# +# --------------------------------------------------------------------------- +# WHY RELEASE TARBALLS RATHER THAN `git clone` +# +# A release tarball is a COMPLETE app: it carries `vendor/` and the built `js/` +# bundle. A git checkout carries neither, and a Nextcloud app whose `vendor/` +# is missing does not fail loudly — `include_once` warns and the app keeps +# loading, so the app appears installed while every service that needs a +# dependency is absent. +# +# --------------------------------------------------------------------------- +# WHAT IS INSTALLED, AND WHY MORE THAN ONE APP +# +# openregister REQUIRED. Every Connext app declares its registers and +# schemas against OpenRegister. Note that this dependency is +# NOT declared in appinfo/info.xml — no app in the fleet +# declares an dependency — so nothing stops the App +# Store installing openregister without it. It would then load +# and find no register to attach to. +# thematiq Optional. Government theming. Absent, the UI renders +# unthemed rather than wrong. +# integriq Optional. The connector, for feeding data in from systems +# you do not control. +# openregister the app this file is for. +# +# Versions float to the newest release by default, pre-releases included, +# because most Connext apps do not yet publish a stable one. Pin any of them: +# +# OPENREGISTER_VERSION=1.2.3 docker compose -f openregister-compose.yaml up -d +# +# The Nextcloud image is pinned to a MAJOR tag rather than `:latest`. A demo +# that is stopped and restarted weeks later would otherwise boot a drifted +# Nextcloud over its existing data volume, which lands the instance in +# maintenance mode with its apps disabled. + +name: openregister-demo + +volumes: + db: + nextcloud: + apps: + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + POSTGRES_DB: nextcloud + volumes: + - db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"] + interval: 5s + timeout: 3s + retries: 20 + + # Downloads each app's release tarball into the shared `apps` volume before + # Nextcloud starts. It runs to completion and exits; Nextcloud waits for that + # exit via `condition: service_completed_successfully`, so there is no window + # in which Nextcloud boots against a half-populated app directory. + # + # Idempotent: an app whose appinfo/info.xml is already present is skipped, so + # `up` on an existing demo does not re-download 100MB per app. + app-installer: + image: alpine:3.20 + restart: "no" + environment: + OPENREGISTER_VERSION: ${OPENREGISTER_VERSION:-} + THEMATIQ_VERSION: ${THEMATIQ_VERSION:-} + INTEGRIQ_VERSION: ${INTEGRIQ_VERSION:-} + volumes: + - apps:/apps + configs: + - source: install-apps + target: /install-apps.sh + mode: "0755" + command: ["/bin/sh", "/install-apps.sh"] + + nextcloud: + image: nextcloud:34-apache + restart: unless-stopped + ports: + - "${DEMO_PORT:-8601}:80" + depends_on: + db: + condition: service_healthy + app-installer: + condition: service_completed_successfully + environment: + POSTGRES_HOST: db + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + POSTGRES_DB: nextcloud + NEXTCLOUD_ADMIN_USER: admin + NEXTCLOUD_ADMIN_PASSWORD: admin + # The port has to appear here as well as in `ports:`. Nextcloud rejects a + # request whose Host header names a domain it does not trust, and + # "localhost" and "localhost:8601" are different entries. + NEXTCLOUD_TRUSTED_DOMAINS: "localhost localhost:${DEMO_PORT:-8601} 127.0.0.1 127.0.0.1:${DEMO_PORT:-8601}" + # OVERWRITECLIURL IS LOAD-BEARING, NOT COSMETIC. Apps that federate + # advertise this address to peers. It is a local address here, and a + # local address is refused rather than broadcast — which is what keeps a + # demo on somebody's laptop out of the national directory. + OVERWRITECLIURL: "http://localhost:${DEMO_PORT:-8601}" + OVERWRITEPROTOCOL: http + volumes: + - nextcloud:/var/www/html + - apps:/var/www/html/custom_apps + configs: + - source: enable-apps + target: /docker-entrypoint-hooks.d/post-installation/10-enable-connext-apps.sh + mode: "0755" + +configs: + # EVERY SHELL VARIABLE BELOW IS WRITTEN `$$name`, NOT `$name`. + # + # Compose interpolates `$name` inside `configs.content` before the file is + # written, so an un-escaped shell variable arrives as an EMPTY STRING and the + # script runs on silently. Measured while building this file: `$version` was + # blanked, which made the "no version pinned" branch look true for an app + # whose version WAS pinned, and the resulting error named no repository — + # `could not resolve a release for ` — because `$repo` had been blanked too. + # + # `$$` is the escape that survives interpolation and reaches /bin/sh as `$`. + # `${DEMO_PORT:-8601}` is deliberately NOT escaped: that one is Compose's + # to substitute. + install-apps: + content: | + #!/bin/sh + set -eu + apk add --no-cache curl tar jq >/dev/null + + # RESOLVING "NEWEST" TAKES TWO CORRECTIONS, NOT ONE. + # + # 1. The GitHub "latest release" endpoint EXCLUDES prereleases and answers + # 404 for a repository that has only ever shipped them — which reads + # exactly like "no such app". Ask the releases LIST instead. + # + # 2. THE LIST IS NOT ORDERED BY CREATION DATE. Taking the first entry looks + # correct and is not. Measured 2026-08-27 on openregister: + # + # v1.1.6 created 10:17:45 <- returned first + # v1.1.6-unstable.20260827110807 created 11:09:37 <- actually newest + # + # Taking the first entry installs an OLDER build than intended, and the + # failure surfaces far from the cause — as a missing CLASS in a + # different app, not as a version complaint. + # + # Sorting by created_at explicitly is the fix; jq is here for that. + resolve_latest() { + curl -fsSL "https://api.github.com/repos/ConductionNL/$$1/releases?per_page=50" \ + | jq -r '[.[] | select(.draft | not)] | sort_by(.created_at) | last | .tag_name // empty' \ + | sed 's/^v//' + } + + install_app() { + repo="$$1"; appid="$$2"; version="$$3"; asset="$$4" + + if [ -f "/apps/$$appid/appinfo/info.xml" ]; then + echo "==> $$appid already present, skipping" + return 0 + fi + + if [ -z "$$version" ]; then + version="$$(resolve_latest "$$repo")" + [ -n "$$version" ] || { echo "!! could not resolve a release for $$repo"; return 1; } + echo "==> $$appid: no version pinned, resolved $$version" + fi + + url="https://github.com/ConductionNL/$$repo/releases/download/v$$version/$$asset-$$version.tar.gz" + echo "==> installing $$appid $$version" + + # Extract into a staging directory rather than straight into /apps. + # The archive's own top-level directory name is not guaranteed to equal + # the Nextcloud app id — Nextcloud resolves an app by its DIRECTORY + # name, so an archive that unpacks under the old name after a rename + # yields an app that is silently never loaded. Several Connext apps were + # renamed in August 2026, so this is a live concern, not a hypothetical. + rm -rf /tmp/stage && mkdir -p /tmp/stage + curl -fsSL "$$url" | tar -xz -C /tmp/stage + + top="$$(ls /tmp/stage | head -n1)" + if [ "$$top" != "$$appid" ]; then + echo " archive unpacked as '$$top', installing it as '$$appid'" + fi + mv "/tmp/stage/$$top" "/apps/$$appid" + rm -rf /tmp/stage + } + + # OpenRegister is not optional. If it could not be fetched, stop here + # rather than let Nextcloud boot into a dozen confusing downstream + # failures instead of one clear one. + install_app openregister openregister "$$OPENREGISTER_VERSION" openregister + install_app thematiq thematiq "$$THEMATIQ_VERSION" thematiq + install_app integriq integriq "$$INTEGRIQ_VERSION" integriq + + [ -f /apps/openregister/appinfo/info.xml ] || { echo "!! openregister missing; aborting"; exit 1; } + + # 33 is www-data inside the Nextcloud image. + chown -R 33:33 /apps + echo "==> apps present: $$(ls /apps | tr '\n' ' ')" + + enable-apps: + content: | + #!/bin/sh + set -eu + # Runs once, after Nextcloud has installed itself. + # + # ORDER IS NOT ARBITRARY. OpenRegister owns the registers and schemas the + # other apps declare against, and a leaf app enabled before it finds no + # register to attach to. Enabling them in dependency order is what makes + # a first boot produce a working instance instead of an empty one. + for app in openregister thematiq integriq; do + echo "==> enabling $$app" + php /var/www/html/occ app:enable "$$app" || echo "!! failed to enable $$app" + done + + # Several apps ship a schema whose slug is not unique across the + # instance. OpenRegister resolves a duplicate slug by tie-break and warns, + # which means a leaf app can silently read another app's schema. This is a + # no-op on a clean install and a repair on one that has drifted. + php /var/www/html/occ openregister:schemas:dedup || true + + echo "==> OpenRegister demo: http://localhost:${DEMO_PORT:-8601}/apps/openregister/" diff --git a/tests/demo-e2e/README.md b/tests/demo-e2e/README.md new file mode 100644 index 0000000000..db531e4338 --- /dev/null +++ b/tests/demo-e2e/README.md @@ -0,0 +1,67 @@ +# Demo-environment end-to-end checks + +Validates a running demo environment — the one a `-compose.yaml` brings up — +against the steps its own documentation tells the reader to run. + +## Why it lives outside `tests/e2e/` + +`playwright.config.ts` at the repository root sets `testDir: './tests/e2e'`, so +anything placed there runs in CI. This suite needs an **already booted demo** to +point at, which CI does not have. Put here, CI never collects it, and there is no +skip to misread later: a suite that silently skips in CI looks identical to one +that ran and found nothing. + +## Running it + +Boot a demo first, then point the suite at it: + +```bash +docker compose -f portaliq-compose.yaml up -d + +DEMO_APP=portaliq \ +DEMO_BASE_URL=http://localhost:8613 \ +DEMO_HAS_PORTAL=1 \ +npx playwright test --config tests/demo-e2e/playwright.config.ts +``` + +| Variable | Meaning | +| --- | --- | +| `DEMO_APP` | The app id under test. Default `portaliq`. | +| `DEMO_BASE_URL` | The booted demo. Default `http://localhost:8613`. | +| `DEMO_HAS_PORTAL` | Set to `1` when the demo installs `portaliq`; the two portal tests skip otherwise. | + +`DEMO_HAS_PORTAL` is an explicit opt-in rather than an auto-detect, and that is +deliberate. A suite that decides for itself whether a portal *should* exist +cannot tell "this demo has no portal" from "the portal failed to seed", and +would report the second as a skip. + +## What it asserts, and why none of it is a status code + +Nextcloud serves its page shell before an app decides whether it has anything to +render, so an app URL returns HTTP 200 even when it resolves to nothing at all. +Two measurements from building this suite make the point: + +- **The login page is served with HTTP 200.** Basic auth authenticates + OpenRegister's API routes but not a browser *navigation* — Nextcloud redirects + that to `/login`, which answers 200. A test asserting only on the status code + passes while sitting on the login screen. That is why the page test logs in + properly and then asserts on content and on the resulting URL. +- **An unauthenticated app URL answers 401 on a perfectly healthy demo.** The + demo documentation used to describe exactly that request as a pass. + +So the assertions are: `status.php` reports `installed:true` (an uninstalled +instance also answers 200); OpenRegister returns a non-empty register list (an +empty one means the configuration was never imported, which from outside is +indistinguishable from "nothing configured yet"); the app page renders its own +content; and, where a portal is installed, the portal API names a real site +rather than answering `{"error":"not_found"}` with a 200. + +## It has been shown to fail + +A suite that has only ever passed is not evidence. Both controls were run: + +- pointed at an app that is not installed → the two app-reachability tests fail +- portal assertions forced on against a demo with no portal → both portal tests fail + +Verified green against two independently booted demos: `portaliq` (6 passed) and +`shillinq` (4 passed, 2 correctly skipped). diff --git a/tests/demo-e2e/demo-journey.spec.ts b/tests/demo-e2e/demo-journey.spec.ts new file mode 100644 index 0000000000..d3bb7eef4b --- /dev/null +++ b/tests/demo-e2e/demo-journey.spec.ts @@ -0,0 +1,145 @@ +import { test, expect, request as pwRequest } from '@playwright/test' + +/** + * The demo environment, checked the way its documentation says to check it. + * + * The point of these assertions is that they can fail. Nextcloud serves its + * page shell before an app decides whether it has anything to render, so an + * app URL returns HTTP 200 even when it resolves to nothing at all -- which + * is exactly why none of these assert on a status code alone. + */ + +const APP = process.env.DEMO_APP || 'portaliq' +const BASE = process.env.DEMO_BASE_URL || 'http://localhost:8613' +const HAS_PORTAL = process.env.DEMO_HAS_PORTAL === '1' +// `send: 'always'` is load-bearing. By default Playwright withholds Basic +// credentials until it sees a 401 challenge, and Nextcloud answers an app page +// with a bare 401 carrying no WWW-Authenticate header -- so the retry never +// fires and every authenticated assertion fails against a healthy demo. +const CREDS = { username: 'admin', password: 'admin', send: 'always' as const } + +test.describe(`demo environment: ${APP}`, () => { + test('Nextcloud reports itself installed, not merely reachable', async () => { + const api = await pwRequest.newContext({ baseURL: BASE }) + const res = await api.get('/status.php') + expect(res.status()).toBe(200) + + const body = await res.json() + // `installed:false` also returns 200. The flag is the assertion, not the code. + expect(body.installed).toBe(true) + expect(body.maintenance).toBe(false) + expect(body.needsDbUpgrade).toBe(false) + await api.dispose() + }) + + test('OpenRegister has registers, so the app has something to attach to', async () => { + const api = await pwRequest.newContext({ + baseURL: BASE, + httpCredentials: CREDS, + }) + const res = await api.get('/apps/openregister/api/registers') + expect(res.status()).toBe(200) + + const body = await res.json() + const rows = body.results ?? body + expect(Array.isArray(rows)).toBe(true) + + // An empty list here is the failure this whole demo exists to catch: it + // means the register configuration was never imported, which from outside + // is indistinguishable from "nothing configured yet". + expect(rows.length).toBeGreaterThan(0) + + // A register with no slug is a row that exists but resolves to nothing. + for (const r of rows.slice(0, 5)) { + expect(r.slug ?? r.title).toBeTruthy() + } + await api.dispose() + }) + + test('the app page renders its own UI, not just a Nextcloud shell', async ({ + browser, + }) => { + // Basic auth is NOT enough for a page navigation. Nextcloud accepts it on + // API routes but redirects a browser navigation to /login -- and serves + // that login page with HTTP 200. A test asserting only on the status code + // would pass while sitting on the login screen. Measured here, which is + // why this logs in properly and then asserts on content. + const ctx = await browser.newContext() + const page = await ctx.newPage() + + await page.goto(`${BASE}/login`) + await page + .getByRole('textbox', { name: /account name or email/i }) + .fill(CREDS.username) + await page.getByRole('textbox', { name: /^password$/i }).fill(CREDS.password) + // `exact` matters: "Log in with a device" also matches a loose /log in/. + await page.getByRole('button', { name: 'Log in', exact: true }).click() + await page.waitForURL((u) => !u.pathname.startsWith('/login'), { + timeout: 30_000, + }) + + const entry = + APP === 'thematiq' ? '/settings/admin/theming' : `/apps/${APP}/` + const res = await page.goto(`${BASE}${entry}`) + expect(res?.status()).toBe(200) + + // The login page is also 200, so prove we are not on it. + expect(page.url()).not.toContain('/login') + + // The shell alone would satisfy a status check. Require app content. + await expect( + page.locator('#content, #app-content, .app-content').first(), + ).toBeVisible({ timeout: 30_000 }) + + const text = (await page.locator('body').innerText()).trim() + expect(text.length).toBeGreaterThan(50) + // A Nextcloud error page also returns 200 in some configurations. + expect(text).not.toMatch( + /Internal Server Error|not installed|Page not found/i, + ) + await ctx.close() + }) + + test('the app is enabled and reachable under its own id', async () => { + const api = await pwRequest.newContext({ + baseURL: BASE, + httpCredentials: CREDS, + }) + const entry = + APP === 'thematiq' ? '/settings/admin/theming' : `/apps/${APP}/` + const res = await api.get(entry, { maxRedirects: 5 }) + + // Unauthenticated this is 401 on a perfectly healthy demo -- the defect + // the documentation used to describe as a pass. + expect(res.status()).toBe(200) + await api.dispose() + }) + + test('the public portal resolves to a real site', async () => { + test.skip(!HAS_PORTAL, 'this demo does not install portaliq') + + const api = await pwRequest.newContext({ baseURL: BASE }) + const res = await api.get('/apps/portaliq/api/content/site?portal=demo') + expect(res.status()).toBe(200) + + const body = await res.json() + // Portaliq answers {"error":"not_found"} with a 200 when the slug resolves + // to nothing, so the body is the assertion. + expect(body.error).toBeUndefined() + expect(body.slug).toBe('demo') + expect(body.title).toBeTruthy() + await api.dispose() + }) + + test('the portal page is served without a login', async ({ page }) => { + test.skip(!HAS_PORTAL, 'this demo does not install portaliq') + + const res = await page.goto(`${BASE}/apps/portaliq/site?portal=demo`) + expect(res?.status()).toBe(200) + + const text = (await page.locator('body').innerText()).trim() + expect(text.length).toBeGreaterThan(50) + // Reaching the login form means the portal did not resolve as public. + expect(text).not.toMatch(/Log in|Wachtwoord vergeten/i) + }) +}) diff --git a/tests/demo-e2e/playwright.config.ts b/tests/demo-e2e/playwright.config.ts new file mode 100644 index 0000000000..063fda5c10 --- /dev/null +++ b/tests/demo-e2e/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test' + +/** + * Validates a generated per-app demo environment against the steps its own + * documentation tells the reader to run. + * + * It is pointed at an ALREADY BOOTED demo via DEMO_BASE_URL rather than + * starting one itself. A spec that boots its own fixture proves the fixture + * works; this one has to prove the documented instructions work. + */ +export default defineConfig({ + testDir: '.', + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + use: { + baseURL: process.env.DEMO_BASE_URL || 'http://localhost:8613', + ignoreHTTPSErrors: true, + }, +}) From 43bdf59d61806a5deda63f25d18433a87c6cb08b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 28 Aug 2026 21:05:37 +0200 Subject: [PATCH 03/42] feat(flow): attribute every object a run touches to the run, node and step (#2979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flow): attribute every write in a run to the run, node and step A flow run recorded exactly one object — `FlowRun.subjectUuid`, the thing that TRIGGERED it. Everything the run went on to touch was attributable to nothing, so neither "which node last touched this case" nor "what did this run change" could be answered. `FlowRunStep` already said what happened; it did not say what it happened to. The engine now establishes an ambient frame around each hop and the audit builder stamps it onto every row. Ambient rather than a parameter because the point is to catch writes made by code that has never heard of flows — a leaf app a node calls into, a cascade, a lifecycle hook. A node cannot report what it did not know it did. The pop is in a `finally`, and that is the whole safety property: every other exit from a hop is a `return` inside a catch — a stop, a suspension, a terminally-failed step. A frame left standing attributes LATER writes to a finished run, across runs, since one worker advances several. It produces no error and no wrong-looking row. FlowEngineAttributionTest asserts the leak direction; deleting the `finally` turns 5 of its 8 tests red. Step numbers are `base + index-in-log`, not a dispatch counter: a PINNED step logs an entry without ever reaching the dispatcher, so a counter would silently desynchronise from the FlowRunStep rows it has to line up with. ADR-003 Rule 4 — the three fields join the canonical JSON, so re-pointing a row at a different run breaks verification instead of going unnoticed. That makes it a seed migration (v1 → v2) rather than a column addition. The repair step verifies the OLD chain against a FROZEN v1 canonicaliser and records that verdict before re-sealing: verifying with the current canonicaliser would include the new keys, report every pre-existing row broken whether tampered with or not, and the re-chain would bless it either way — a check that cannot tell an intact chain from a compromised one is not a check. It refuses to start if it cannot store the verdict, because a re-chain with no account of what it replaced has no remedy. Also closes a pre-existing gap found on the way: `FlowRunController::show()` was unscoped while `index()` has been scoped since shared-credentials-and-flows D7 — and a run's serialisation carries its log, which records the subject data the flow touched. Both now resolve through one predicate in the mapper rather than two copies that drift. Refs: openspec/changes/flow-object-attribution * refactor(flow): one assignee rule, reachable from outside the controller `refuseUnlessAssignee()` guarded the HTTP resume endpoint, which was the whole story while HTTP was the only way to answer a step. It is not: a leaf app whose own object completes a task resumes the run IN-PROCESS through `FlowRunService::signal()`, which never passes the controller. Left where it was, every such caller re-implements the rule. Re-implementing it is the failure mode. Two copies of one access rule do not stay identical, and a divergence here does not throw — it lets the wrong person answer somebody else's question, correctly formatted, HTTP 200. The GROUP branch is the half a hand-written copy forgets, and forgetting it refuses the step's own intended audience while still reading as "the guard works". So the rule moves to FlowRunAssignee and the controller delegates. Its 24 existing tests pass unchanged, which is the point — behaviour is identical, only its reachability changed. The old private copy is deleted rather than left beside the new one. The new tests cover the three directions that pass while broken: the group branch, the deliberately-OPEN unassigned case (webhook and child-run signals record no assignee and must keep working), and the fail-closed anonymous case. Mutating `mayAnswer()` to return true kills 8 tests across both suites. Also adds `FlowNodeResumeState::nodeId()`. A node is handed its own slot but was never told its own name, which is fine until it must hand its identity to something outside the run — a task record that has to resume this exact node. A run holds one awaiting slot per node, so "resume this run" is not an answer. * refactor(audit): flow attribution gets its own home, and the gates pass phpmd caught a real regression rather than a style nit: adding the stamp and the query took AuditTrailMapper from clean to 27 non-accessor methods against a threshold of 25. Checked against origin/development rather than assumed — the base was clean, so this was mine. Both halves now live on AuditFlowAttribution. They belong together because they are one fact read from two ends: the stamp decides what a row claims, the query trusts that claim, and keeping them apart is how the column set they agree on drifts. The mapper is back under its ceiling and no longer has to know what a flow is. Other gate findings, each fixed at the cause: - FlowEngine::run() NPath 226/200. The 26 came from the `finally` that makes the attribution pop unconditional, and that is a correctness guarantee, not a convenience: every other exit from a hop is a `return` inside a catch, so a pop on the success path leaks the frame into a LATER run advanced by the same worker. Suppressed with that reasoning written down, rather than restructuring a walk to win a number. - AuditCanonicalV1's static access is suppressed with its reason: it is deliberately frozen, and presenting it as an injectable collaborator would imply it can be swapped or updated — the one thing it must never be. - `array_values()` after `usort()` was a no-op that read as a safeguard. - `@template-extends QBMapper`, matching FlowRunMapper's convention, rather than casting the return type. - Three phpcs errors of mine (two missing @param, one comment), and the @spec tags I had put on member variables, which that standard does not allow there. Gates now clean on every changed file: phpcs, phpmd, psalm, phpstan. 725 flow, controller and audit tests pass. * fix(ci): cover the new code, and stop tipping FlowRunService over its ceilings CI caught three things. **phpmd, twice, and both were mine.** An inline FQN in Application.php that wanted a `use`; and FlowRunService at 1047 lines / complexity 51 against thresholds of 1000 / 50. The base was at ~998 lines, so any addition trips it — my eight lines were simply the ones that did. Rather than trim a comment until the number passed, the step-history concern moved out whole. `FlowStepHistory` now owns both the NUMBERING and the RECORDING of a run's steps, and they belong together for a reason: attribution has to PREDICT a step's number before the walk, while the step row is written after it, and the two must arrive at the same value or an attributed audit row and its step row describe different steps. One class, one arithmetic — and `testRecordedSequencesContinueFromTheSameBaseAttributionUsed` asserts both ends in a single test, because checking either alone passes while they disagree. **The coverage ratchet was right too** (-2.49%). FlowStepHistory (10 tests) and AuditFlowAttribution (6) are now covered. The stamper's tests are all about the abnormal paths, because the normal one is a single line: a row written outside any run must carry NO attribution, an unresolvable context must still let the row be written, and something that is not a run context must not be trusted to be one. While writing them, `TestCase::run()` is final — the same trap the existing FlowEngineTest documents in a comment. Helper renamed. Gates: phpcs, phpmd, psalm, phpstan clean on every changed file. * test(repair): cover the one step in this change that cannot be undone The v1 → v2 migration had no tests, and it is the riskiest thing here: a re-chain recomputes every hash from current content, so afterwards an intact chain and a tampered one look identical, and the v1 hashes that could have told them apart are gone. So these test the ORDER and the REFUSALS rather than the happy path: - the verdict is stored BEFORE anything is re-sealed, and names the seed it moved from and to; - a verdict that cannot be stored means NO re-chain at all — the one refusal worth blocking an upgrade over, since a re-chain with no account of what it replaced has no remedy; - a second run is a no-op, because a v2 chain checked against the v1 form would report a false break and overwrite the real verdict with a meaningless one; - a re-seal that throws does not leave the step marked done, or the next `occ maintenance:repair` skips a half-sealed table. Worth noting how the first run failed: my IAppConfig double used an arrow function, which captures by VALUE, so the read-back always saw an empty store — and the step refused, exactly as designed. The double was wrong; the refusal it tripped was right, which is a reasonable way to learn the guard works. --- appinfo/info.xml | 10 + appinfo/routes.php | 1 + lib/AppInfo/Application.php | 13 + lib/Controller/FlowRunController.php | 201 +++++++--- lib/Db/AuditFlowAttribution.php | 130 ++++++ lib/Db/AuditTrail.php | 61 ++- lib/Db/AuditTrailMapper.php | 36 ++ lib/Db/FlowRunMapper.php | 124 +++++- lib/Migration/Version1Date20260828120000.php | 122 ++++++ .../RechainAuditTrailForFlowAttribution.php | 377 ++++++++++++++++++ lib/Service/AuditCanonicalV1.php | 162 ++++++++ lib/Service/AuditHashService.php | 16 +- lib/Service/Flow/FlowEngine.php | 70 ++++ lib/Service/Flow/FlowNodeResumeState.php | 19 + lib/Service/Flow/FlowRunAssignee.php | 145 +++++++ lib/Service/Flow/FlowRunContext.php | 177 ++++++++ lib/Service/Flow/FlowRunService.php | 111 ++---- lib/Service/Flow/FlowStepHistory.php | 187 +++++++++ .../flow-object-attribution/.openspec.yaml | 2 + .../changes/flow-object-attribution/design.md | 103 +++++ .../flow-object-attribution/proposal.md | 46 +++ .../specs/audit-hash-chain/spec.md | 68 ++++ .../specs/flow-engine/spec.md | 41 ++ .../specs/flow-object-attribution/spec.md | 90 +++++ .../changes/flow-object-attribution/tasks.md | 51 +++ openspec/specs/audit-hash-chain/spec.md | 9 +- openspec/specs/flow-engine/spec.md | 2 + tests/Unit/Db/AuditFlowAttributionTest.php | 130 ++++++ ...echainAuditTrailForFlowAttributionTest.php | 233 +++++++++++ tests/Unit/Service/AuditCanonicalV1Test.php | 134 +++++++ tests/Unit/Service/AuditHashServiceTest.php | 16 +- .../Flow/FlowEngineAttributionTest.php | 275 +++++++++++++ .../Unit/Service/Flow/FlowRunAssigneeTest.php | 154 +++++++ .../Unit/Service/Flow/FlowRunContextTest.php | 135 +++++++ .../Unit/Service/Flow/FlowStepHistoryTest.php | 222 +++++++++++ 35 files changed, 3522 insertions(+), 151 deletions(-) create mode 100644 lib/Db/AuditFlowAttribution.php create mode 100644 lib/Migration/Version1Date20260828120000.php create mode 100644 lib/Repair/RechainAuditTrailForFlowAttribution.php create mode 100644 lib/Service/AuditCanonicalV1.php create mode 100644 lib/Service/Flow/FlowRunAssignee.php create mode 100644 lib/Service/Flow/FlowRunContext.php create mode 100644 lib/Service/Flow/FlowStepHistory.php create mode 100644 openspec/changes/flow-object-attribution/.openspec.yaml create mode 100644 openspec/changes/flow-object-attribution/design.md create mode 100644 openspec/changes/flow-object-attribution/proposal.md create mode 100644 openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md create mode 100644 openspec/changes/flow-object-attribution/specs/flow-engine/spec.md create mode 100644 openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md create mode 100644 openspec/changes/flow-object-attribution/tasks.md create mode 100644 tests/Unit/Db/AuditFlowAttributionTest.php create mode 100644 tests/Unit/Repair/RechainAuditTrailForFlowAttributionTest.php create mode 100644 tests/Unit/Service/AuditCanonicalV1Test.php create mode 100644 tests/Unit/Service/Flow/FlowEngineAttributionTest.php create mode 100644 tests/Unit/Service/Flow/FlowRunAssigneeTest.php create mode 100644 tests/Unit/Service/Flow/FlowRunContextTest.php create mode 100644 tests/Unit/Service/Flow/FlowStepHistoryTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index a656b292fb..5366d1b4e3 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -227,6 +227,16 @@ Vrij en open source onder de EUPL-licentie. column while every read looks at the new one and finds null — no error, no data loss, invisible to the suite. --> OCA\OpenRegister\Repair\RenameDutchColumns + + OCA\OpenRegister\Repair\RechainAuditTrailForFlowAttribution OCA\OpenRegister\Repair\ReconcileDeclaredBackgroundJobs diff --git a/appinfo/routes.php b/appinfo/routes.php index ded64c7e03..f416bd9aab 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -1313,6 +1313,7 @@ // answered by `show('active')` → 404 for every request. ['name' => 'flowRun#active', 'url' => '/api/flow-runs/active', 'verb' => 'GET'], ['name' => 'flowRun#show', 'url' => '/api/flow-runs/{uuid}', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], + ['name' => 'flowRun#objects', 'url' => '/api/flow-runs/{uuid}/objects', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'flowRun#retry', 'url' => '/api/flow-runs/{uuid}/retry', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'flowRun#resume', 'url' => '/api/flow-runs/{uuid}/resume', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], // Interactive test run (or-flow-partial-run): run synchronously with optional startAt + pins + seed. diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e3749f1a33..45973c2c9c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -168,6 +168,7 @@ use OCA\OpenRegister\Service\File\FolderManagementHandler; use OCA\OpenRegister\Service\File\Pdf\Fallback\NullNcOfficeConverter; use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Flow\FlowRunContext; use OCA\OpenRegister\Service\Flow\RegistryStepDispatcher; use OCA\OpenRegister\Service\FlowLinkService; use OCA\OpenRegister\Service\Gdpr\Evidence\EvidenceSourceRegistry; @@ -413,6 +414,18 @@ function () { } ); + // The ambient flow-attribution stack MUST be shared. Nextcloud's + // container builds an auto-wired class fresh at every injection point, + // so without this registration the engine would push frames onto one + // instance and the audit mapper would read an empty stack on another — + // attributing nothing at all, silently and with no error anywhere. + $context->registerService( + FlowRunContext::class, + function () { + return new FlowRunContext(); + } + ); + // Register the LanguageMiddleware for Accept-Language header parsing. $context->registerMiddleware(LanguageMiddleware::class); diff --git a/lib/Controller/FlowRunController.php b/lib/Controller/FlowRunController.php index c822790c7a..34ab0ff18b 100644 --- a/lib/Controller/FlowRunController.php +++ b/lib/Controller/FlowRunController.php @@ -28,11 +28,12 @@ namespace OCA\OpenRegister\Controller; +use OCA\OpenRegister\Db\AuditFlowAttribution; use OCA\OpenRegister\Db\FlowRun; use OCA\OpenRegister\Db\FlowRunMapper; use OCA\OpenRegister\Service\Flow\FlowItems; use OCA\OpenRegister\Service\Flow\FlowLocator; -use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunAssignee; use OCA\OpenRegister\Service\Flow\FlowRunService; use OCA\OpenRegister\Service\Flow\FlowService; use OCA\OpenRegister\Service\OrganisationService; @@ -63,8 +64,8 @@ * deliberately unauthenticated because it runs flows as their owner with no * session. Moving them out would either re-open the bypass or add a second * indirection over four small private helpers. - * @SuppressWarnings(PHPMD.ExcessiveParameterList) Ten constructor parameters, the - * last three nullable-with-default precisely so adding them broke no existing + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Eleven constructor parameters, the + * last four nullable-with-default precisely so adding them broke no existing * construction site. They are collaborators of those same guards, not options. */ class FlowRunController extends Controller { @@ -87,6 +88,13 @@ class FlowRunController extends Controller { * native flow store. Nullable for the same * reason as $groupManager: absent yields no * owned ids, which scopes rather than widens. + * @param AuditFlowAttribution|null $auditTrails Reads the attribution stamped on + * audit rows, for the objects a run + * touched. Nullable and LAST so + * adding it shifts no positional + * caller; absent, the endpoint + * reports the surface unavailable + * rather than an empty run. */ public function __construct( string $appName, @@ -98,6 +106,10 @@ public function __construct( private readonly OrganisationService $organisationService, private readonly ?IGroupManager $groupManager = null, private readonly ?FlowService $flows = null, + // Appended LAST and nullable on purpose: a new constructor argument + // inserted anywhere else shifts every positional caller, and the + // resulting TypeError names the argument AFTER the one that moved. + private readonly ?AuditFlowAttribution $auditTrails = null, ) { parent::__construct(appName: $appName, request: $request); @@ -349,15 +361,132 @@ private function currentStep(FlowRun $run): ?string { #[NoAdminRequired] #[NoCSRFRequired] public function show(string $uuid): JSONResponse { - try { - $run = $this->mapper->findByUuid($uuid); - } catch (DoesNotExistException $e) { + // Scoped, as `index()` has been since `shared-credentials-and-flows` + // D7. It was not, and the omission was the whole point of that scoping: + // a run's serialisation carries its log, which records the subject data + // the flow touched, so an unscoped read by uuid handed any authenticated + // caller the contents of anyone's run. + $run = $this->visibleRun(uuid: $uuid); + if ($run === null) { return new JSONResponse(['error' => 'No such run'], Http::STATUS_NOT_FOUND); } return new JSONResponse($run->jsonSerialize()); }//end show() + /** + * The objects one run touched, grouped by the node that touched them. + * + * A run recorded what it DID (its steps) and, of what it did it TO, only + * the object that triggered it. This reads back the attribution stamped on + * every audit row the run caused, which is the other half. + * + * @param string $uuid The run uuid. + * + * @return JSONResponse The touched objects grouped by node, or 404. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function objects(string $uuid): JSONResponse { + // Resolved through the SAME visibility rule as reading the run itself. + // A new endpoint that answered for runs `show()` refuses would widen + // access without anything looking like an access change. + $run = $this->visibleRun(uuid: $uuid); + if ($run === null) { + return new JSONResponse(['error' => 'No such run'], Http::STATUS_NOT_FOUND); + } + + if ($this->auditTrails === null) { + return new JSONResponse(['error' => 'Audit trail unavailable'], Http::STATUS_SERVICE_UNAVAILABLE); + } + + $rows = $this->auditTrails->findByRun(runUuid: $uuid); + + $byNode = []; + foreach ($rows as $row) { + $node = (string)$row->getFlowNode(); + + if (isset($byNode[$node]) === false) { + $byNode[$node] = [ + 'node' => $node, + 'step' => $row->getFlowStep(), + 'objects' => [], + ]; + } + + $byNode[$node]['objects'][] = [ + 'objectUuid' => $row->getObjectUuid(), + 'register' => $row->getRegister(), + 'schema' => $row->getSchema(), + 'action' => $row->getAction(), + 'step' => $row->getFlowStep(), + 'created' => $row->getCreated()?->format('c'), + 'auditUuid' => $row->getUuid(), + ]; + } + + // Step order, so a reader follows the run in the order it happened + // rather than in whatever order the rows came back. + usort( + $byNode, + static fn (array $a, array $b): int => (((int)$a['step']) <=> ((int)$b['step'])) + ); + + return new JSONResponse( + [ + 'run' => $uuid, + 'flowId' => $run->getFlowId(), + // An empty list is the honest answer for a run that wrote + // nothing, and for a suspended run that has not written + // anything YET. Neither is an error, and neither is withheld + // until the run finishes. + // usort() re-indexed $byNode into a list, so no array_values() + // here: it would be a no-op that reads as a safeguard. + 'nodes' => $byNode, + ] + ); + }//end objects() + + /** + * Resolve a run by uuid, or null when this caller may not see it. + * + * One place, so `show()` and `objects()` cannot drift apart on who may read + * a run. Delegates the predicate itself to the mapper, which is where the + * list read gets it too. + * + * A non-admin with no session resolves to null rather than falling through: + * a null uid means "no scoping" at the mapper, which is an ADMINISTRATOR's + * semantics, so treating an absent caller as one would turn having no + * identity into the widest possible read. + * + * @param string $uuid The run uuid. + * + * @return FlowRun|null The run, or null when absent or not visible. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + private function visibleRun(string $uuid): ?FlowRun { + if ($this->isAdmin() === true) { + return $this->mapper->findByUuidVisibleTo(uuid: $uuid, requesterUid: null); + } + + $uid = $this->callerUid(); + if ($uid === null || $uid === '') { + return null; + } + + return $this->mapper->findByUuidVisibleTo( + uuid: $uuid, + requesterUid: $uid, + ownedFlowIds: $this->flowIdsOwnedByCaller() + ); + }//end visibleRun() + /** * Retry a finished run: queue a fresh one, leave the original untouched. * @@ -517,28 +646,29 @@ private function refuseUnlessRunnable(string $flowId): ?JSONResponse { * @return JSONResponse|null A 403 when the caller is not the assignee. */ private function refuseUnlessAssignee(FlowRun $run): ?JSONResponse { - $assignee = $this->recordedAssignee(run: $run); + // The rule itself lives in FlowRunAssignee, because HTTP is no longer + // the only way to answer a step: a leaf app whose object completes a + // task resumes the run in-process through FlowRunService::signal(), + // which never passes this controller. One implementation, so the two + // paths cannot drift into disagreeing about who may answer. + $assignee = $this->assignees()->recordedFor(run: $run); if ($assignee === '') { return null; } $uid = $this->callerUid(); + + if ($this->assignees()->mayAnswer(run: $run, uid: $uid) === true) { + return null; + } + if ($uid === null) { - // Fail CLOSED: an assigned decision is never anonymous. return new JSONResponse( ['error' => 'This step is assigned; sign in as the assignee to answer it.'], Http::STATUS_FORBIDDEN ); } - if ($uid === $assignee) { - return null; - } - - if ($this->groupManager !== null && $this->groupManager->isInGroup($uid, $assignee) === true) { - return null; - } - return new JSONResponse( ['error' => 'This step is assigned to someone else.'], Http::STATUS_FORBIDDEN @@ -546,40 +676,19 @@ private function refuseUnlessAssignee(FlowRun $run): ?JSONResponse { }//end refuseUnlessAssignee() /** - * The assignee recorded by whichever step is currently awaiting a signal. + * The assignee rule, made on demand when none was injected. * - * Reads the per-node resume slots the node wrote. A run can carry slots for - * several nodes across its life, so the one that matters is a slot that - * asked (`askedAt`) and has not been answered. + * Built locally rather than required as a constructor argument so adding it + * breaks no existing construction site; it holds no state, so a locally + * made one is indistinguishable from an injected one. * - * @param FlowRun $run The suspended run. + * @return FlowRunAssignee The rule. * - * @return string The assignee uid or group id, '' when unassigned. + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable */ - private function recordedAssignee(FlowRun $run): string { - $context = ($run->getContext() ?? []); - $slots = ($context[FlowResumeState::CONTEXT_KEY] ?? []); - if (is_array($slots) === false) { - return ''; - } - - foreach ($slots as $slot) { - if (is_array($slot) === false) { - continue; - } - - if (isset($slot['askedAt']) === false) { - continue; - } - - $assignee = trim((string)($slot['assignee'] ?? '')); - if ($assignee !== '') { - return $assignee; - } - } - - return ''; - }//end recordedAssignee() + private function assignees(): FlowRunAssignee { + return new FlowRunAssignee(groupManager: $this->groupManager); + }//end assignees() /** * The current caller's uid, or null when anonymous. diff --git a/lib/Db/AuditFlowAttribution.php b/lib/Db/AuditFlowAttribution.php new file mode 100644 index 0000000000..2999894b08 --- /dev/null +++ b/lib/Db/AuditFlowAttribution.php @@ -0,0 +1,130 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Db + * @package OCA\OpenRegister\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Db; + +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; +use Psr\Container\ContainerInterface; +use Throwable; + +/** + * Applies the ambient flow attribution to an audit row, and reads it back. + * + * @template-extends QBMapper + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ +class AuditFlowAttribution extends QBMapper { + /** + * Constructor. + * + * @param IDBConnection $db The database. + * @param ContainerInterface $container Resolves the shared run context. + */ + public function __construct( + IDBConnection $db, + private readonly ContainerInterface $container, + ) { + parent::__construct(db: $db, tableName: 'openregister_audit_trails', entityClass: AuditTrail::class); + }//end __construct() + + /** + * Stamp the executing run, node and step onto a row. + * + * MUST be called before the row is written: these three fields are part of + * the canonical JSON the hash covers, exactly like `expires`, so setting + * them afterwards would put them outside the hash the row is later given. + * + * Fail-soft. If the context cannot be resolved the row is written + * unattributed — an audit row is evidence and must survive a bookkeeping + * problem, and an unattributed row is honest about what it does not know. + * + * @param AuditTrail $auditTrail The row being built. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function apply(AuditTrail $auditTrail): void { + try { + $context = $this->container->get(FlowRunContext::class); + } catch (Throwable $e) { + return; + } + + if (($context instanceof FlowRunContext) === false) { + return; + } + + $frame = $context->current(); + if ($frame === null) { + return; + } + + $auditTrail->setFlowRun($frame['run']); + $auditTrail->setFlowNode($frame['node']); + $auditTrail->setFlowStep($frame['step']); + }//end apply() + + /** + * Every audit row attributed to one flow run, oldest first. + * + * Ordered by id rather than by `flow_step` so a run that visited the same + * node twice — a loop — reads in the order the writes actually happened + * rather than collapsing both visits into one position. + * + * @param string $runUuid The flow run's uuid. + * @param integer $limit Maximum rows to return. + * + * @return AuditTrail[] The rows this run caused. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function findByRun(string $runUuid, int $limit = 1000): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('openregister_audit_trails') + ->where($qb->expr()->eq('flow_run', $qb->createNamedParameter($runUuid))) + ->orderBy('id', 'ASC') + ->setMaxResults($limit); + + return $this->findEntities(query: $qb); + }//end findByRun() +}//end class diff --git a/lib/Db/AuditTrail.php b/lib/Db/AuditTrail.php index b7ab9f1273..6dd1e49df3 100644 --- a/lib/Db/AuditTrail.php +++ b/lib/Db/AuditTrail.php @@ -80,6 +80,12 @@ * @method void setParamsDigest(?string $paramsDigest) * @method array|null getResultSummary() * @method void setResultSummary(?array $resultSummary) + * @method string|null getFlowRun() + * @method void setFlowRun(?string $flowRun) + * @method string|null getFlowNode() + * @method void setFlowNode(?string $flowNode) + * @method integer|null getFlowStep() + * @method void setFlowStep(?int $flowStep) * * @psalm-suppress PossiblyUnusedMethod * @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class @@ -363,6 +369,41 @@ class AuditTrail extends Entity implements JsonSerializable { */ protected ?array $resultSummary = null; + /** + * The uuid of the flow run that was executing when this row was written. + * + * Set from the ambient run context rather than passed by the writing code, + * so a write made by an app that has never heard of flows is attributed + * exactly like a write made by the node itself. Null on every row written + * outside a run — and null is the ONLY way to say "no run", because a run + * uuid is never an empty string. + * + * Stored as a plain stamp with no foreign key: flow-run retention prunes + * runs, and an audit row is immutable, so a reference that could be + * invalidated by retention must not be one the reader depends on + * resolving. + * + * @var string|null Uuid of the attributing flow run. + */ + protected ?string $flowRun = null; + + /** + * The id, within the flow graph, of the node that was executing. + * + * @var string|null Node id of the attributing step. + */ + protected ?string $flowNode = null; + + /** + * The sequence number of the step that was executing. + * + * Steps are appended across a resume and never renumbered, so this orders + * a run's writes even when the run suspended and continued days later. + * + * @var integer|null Step sequence of the attributing step. + */ + protected ?int $flowStep = null; + /** * Constructor for the AuditTrail class * @@ -401,6 +442,9 @@ public function __construct() { $this->addType(fieldName: 'paramsDigest', type: 'string'); $this->addType(fieldName: 'resultSummary', type: 'json'); $this->addType(fieldName: 'purgedAt', type: 'datetime'); + $this->addType(fieldName: 'flowRun', type: 'string'); + $this->addType(fieldName: 'flowNode', type: 'string'); + $this->addType(fieldName: 'flowStep', type: 'integer'); }//end __construct() /** @@ -508,7 +552,10 @@ public function hydrate(array $object): static { * previousHash: null|string, * toolId: null|string, * paramsDigest: null|string, - * resultSummary: array|null + * resultSummary: array|null, + * flowRun: null|string, + * flowNode: null|string, + * flowStep: int|null * } */ public function jsonSerialize(): array { @@ -554,6 +601,18 @@ public function jsonSerialize(): array { 'toolId' => $this->toolId, 'paramsDigest' => $this->paramsDigest, 'resultSummary' => $this->resultSummary, + // ⚠️ These three are COVERED BY THE HASH CHAIN. They are part of the + // canonical JSON that AuditHashService seals, which is what makes + // re-pointing a row at a different run detectable rather than + // merely unlikely. That coverage is also why adding them was a + // genesis-seed migration (v1 → v2, ADR-003 Rule 4) rather than a + // column addition: any key added here changes the canonical form of + // every row ever written. Do not add a key to this array without + // reading that ADR — and note that `purgedAt` is deliberately + // ABSENT for exactly this reason. + 'flowRun' => $this->flowRun, + 'flowNode' => $this->flowNode, + 'flowStep' => $this->flowStep, ]; }//end jsonSerialize() diff --git a/lib/Db/AuditTrailMapper.php b/lib/Db/AuditTrailMapper.php index 7a6502b8fb..30ec351ebe 100644 --- a/lib/Db/AuditTrailMapper.php +++ b/lib/Db/AuditTrailMapper.php @@ -111,6 +111,9 @@ public function __construct( parent::__construct(db: $db, tableName: 'openregister_audit_trails', entityClass: AuditTrail::class); }//end __construct() + + + /** * Insert an audit-trail entry sealed into the SHA-256 hash chain. * @@ -150,6 +153,19 @@ private function insertHashChained(AuditTrail $auditTrail): AuditTrail { // minutes). verifyChain() skips unsealed rows and carries the last // sealed hash forward, so a tail of them is a smaller claim, never a // false alarm. + // + // Attribution is applied here as well as in buildAuditTrail(), which + // covers the entry points that do NOT build their row there — + // createAuditTrailEntry() (archival/retention) and + // createToolInvocationEntry() (MCP). Both can be reached from inside a + // flow. Re-applying to a row the builder already stamped is idempotent: + // it reads the same ambient frame and writes the same three values. + // + // It must happen before the row is INSERTED, not merely before it is + // sealed: the sweep seals whatever is in the row, so a field added + // after insert would be outside the hash it is later given. + (new AuditFlowAttribution($this->db, $this->container))->apply(auditTrail: $auditTrail); + return $this->insert(entity: $auditTrail); }//end insertHashChained() @@ -288,6 +304,14 @@ function ($key) { 'ip_address', 'version', 'created', + // Flow attribution. Absent from this allowlist a filter is + // not rejected — it is silently DROPPED by the `continue` + // below, so `?flow_run=` would return the whole + // unfiltered audit trail with a 200 and read as a run that + // had touched everything on the instance. + 'flow_run', + 'flow_node', + 'flow_step', ] ) === false ) { @@ -344,6 +368,11 @@ function ($key) { 'ip_address', 'version', 'created', + // Sortable for the same reason they are filterable: a run's + // writes are read in step order. + 'flow_run', + 'flow_node', + 'flow_step', ] ) === false ) { @@ -574,6 +603,13 @@ public function buildAuditTrail( $auditTrail->setImportJobId($importJobId); } + // Flow attribution — which run, node and step caused this write. + // Applied HERE, in the shared builder, and not in the two insert + // methods: `insertAuditTrails()` (the batched path) builds its rows + // through this same method, and stamping the inserts instead would have + // left every bulk write in a flow silently unattributed. + (new AuditFlowAttribution($this->db, $this->container))->apply(auditTrail: $auditTrail); + // Set the size to the byte size of the serialized object, with a minimum default of 14 bytes. $serializedSize = strlen(serialize($objectEntity->jsonSerialize())); $auditTrail->setSize(max($serializedSize, 14)); diff --git a/lib/Db/FlowRunMapper.php b/lib/Db/FlowRunMapper.php index 0770903e68..21ee1d91db 100644 --- a/lib/Db/FlowRunMapper.php +++ b/lib/Db/FlowRunMapper.php @@ -133,25 +133,123 @@ public function findAllRuns( $qb->andWhere($qb->expr()->eq('status', $qb->createNamedParameter($status))); } - if ($requesterUid !== null) { - $visible = $qb->expr()->orX( - $qb->expr()->eq('triggered_by', $qb->createNamedParameter($requesterUid)) + $this->scopeToVisible(qb: $qb, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds); + + return $this->findEntities(query: $qb); + }//end findAllRuns() + + /** + * Narrow a run query to what one caller may see. + * + * Extracted so the LIST and the SINGLE-RUN reads apply the same predicate + * rather than each carrying its own copy. Two copies of one access rule do + * not stay identical, and the copy that drifts is the one nobody is looking + * at — a divergence here does not throw, it just answers with somebody + * else's run, correctly formatted, HTTP 200. + * + * A null `$requesterUid` applies NO scoping. That is an administrator's + * semantics and must never be reached by falling through from a missing + * session; callers resolve "no identity" to a refusal before calling. + * + * @param IQueryBuilder $qb The query being built. + * @param string|null $requesterUid The caller, or null for no scoping. + * @param array $ownedFlowIds Flow ids the caller owns. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + private function scopeToVisible(IQueryBuilder $qb, ?string $requesterUid, array $ownedFlowIds): void { + if ($requesterUid === null) { + return; + } + + $visible = $qb->expr()->orX( + $qb->expr()->eq('triggered_by', $qb->createNamedParameter($requesterUid)) + ); + + if (empty($ownedFlowIds) === false) { + $visible->add( + $qb->expr()->in( + 'flow_id', + $qb->createNamedParameter($ownedFlowIds, IQueryBuilder::PARAM_STR_ARRAY) + ) ); + } - if (empty($ownedFlowIds) === false) { - $visible->add( - $qb->expr()->in( - 'flow_id', - $qb->createNamedParameter($ownedFlowIds, IQueryBuilder::PARAM_STR_ARRAY) - ) - ); - } + $qb->andWhere($visible); + }//end scopeToVisible() - $qb->andWhere($visible); + /** + * The suspended runs whose subject is this object. + * + * "Which run is waiting on this thing" is the question a leaf app asks when + * something outside the engine finishes — a decision concluded, a document + * signed — and needs to wake whatever was waiting for it. + * + * Deliberately narrowed to SUSPENDED. A completed or failed run is not + * waiting for anything, and signalling one would be a no-op at best and a + * second advance of a finished run at worst. + * + * This does NOT scope by caller: it is an engine-side lookup used to route + * an external outcome, not a user-facing read. Callers that expose anything + * derived from it must apply their own visibility rule. + * + * @param string $subjectUuid The subject object's uuid. + * @param integer $limit Maximum runs to return. + * + * @return FlowRun[] The suspended runs for that subject, oldest first. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function findSuspendedBySubject(string $subjectUuid, int $limit = 25): array { + if (trim($subjectUuid) === '') { + return []; } + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('subject_uuid', $qb->createNamedParameter($subjectUuid))) + ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(FlowRun::STATUS_SUSPENDED))) + ->orderBy('id', 'ASC') + ->setMaxResults($limit); + return $this->findEntities(query: $qb); - }//end findAllRuns() + }//end findSuspendedBySubject() + + /** + * Find one run by uuid, but only if this caller may see it. + * + * Returns null both when the run does not exist and when it exists but is + * not the caller's, so the caller cannot distinguish the two — the absence + * of a run and the absence of permission look identical from outside, which + * is what stops the endpoint being a probe for which runs exist. + * + * @param string $uuid The run uuid. + * @param string|null $requesterUid The caller, or null for an administrator. + * @param array $ownedFlowIds Flow ids the caller owns. + * + * @return FlowRun|null The run, or null when it does not exist or is not visible. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function findByUuidVisibleTo(string $uuid, ?string $requesterUid, array $ownedFlowIds = []): ?FlowRun { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid))) + ->setMaxResults(1); + + $this->scopeToVisible(qb: $qb, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds); + + $found = $this->findEntities(query: $qb); + if ($found === []) { + return null; + } + + return $found[0]; + }//end findByUuidVisibleTo() /** * Delete terminal runs older than a cutoff, optionally for one flow only. diff --git a/lib/Migration/Version1Date20260828120000.php b/lib/Migration/Version1Date20260828120000.php new file mode 100644 index 0000000000..dea520bb89 --- /dev/null +++ b/lib/Migration/Version1Date20260828120000.php @@ -0,0 +1,122 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use OCP\DB\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Add flow_run / flow_node / flow_step to openregister_audit_trails. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ +class Version1Date20260828120000 extends SimpleMigrationStep { + /** + * Add the attribution columns and the run index. + * + * @param IOutput $output Output for the migration process. + * @param Closure $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The modified schema, or null when nothing changed. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* + * @var ISchemaWrapper $schema + */ + + $schema = $schemaClosure(); + + if ($schema->hasTable('openregister_audit_trails') === false) { + return null; + } + + $table = $schema->getTable('openregister_audit_trails'); + $changed = false; + + // Every column is added independently and guarded on its own presence. + // A single "if the first column is missing, add all three" guard is the + // shape that leaves a half-migrated table permanently half-migrated + // after one interrupted upgrade. + if ($table->hasColumn('flow_run') === false) { + $table->addColumn('flow_run', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $changed = true; + } + + if ($table->hasColumn('flow_node') === false) { + $table->addColumn('flow_node', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $changed = true; + } + + if ($table->hasColumn('flow_step') === false) { + $table->addColumn('flow_step', Types::INTEGER, [ + 'notnull' => false, + ]); + $changed = true; + } + + // The run direction ("what did this run touch") is the one that needs + // help; the object direction is already served by the existing + // object_uuid index. No composite until a query asks for one — a + // speculative index on this table is a write cost on every audited + // mutation forever (ADR-009). + if ($table->hasIndex('idx_audit_flow_run') === false) { + $table->addIndex(['flow_run'], 'idx_audit_flow_run'); + $changed = true; + } + + if ($changed === false) { + return null; + } + + $output->info('Added flow_run / flow_node / flow_step and idx_audit_flow_run to openregister_audit_trails'); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Repair/RechainAuditTrailForFlowAttribution.php b/lib/Repair/RechainAuditTrailForFlowAttribution.php new file mode 100644 index 0000000000..fcb35c1310 --- /dev/null +++ b/lib/Repair/RechainAuditTrailForFlowAttribution.php @@ -0,0 +1,377 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Repair; + +use DateTime; +use OCA\OpenRegister\Db\AuditTrail; +use OCA\OpenRegister\Service\AuditCanonicalV1; +use OCA\OpenRegister\Service\AuditHashService; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Pre-verifies the v1 chain and re-seals the audit trail under the v2 seed. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ +class RechainAuditTrailForFlowAttribution implements IRepairStep { + /** + * App-config key recording that the v1 → v2 re-chain has been performed. + * + * Presence is what makes the step idempotent: a re-chain is expensive and, + * far more importantly, its pre-verification is only meaningful ONCE. After + * the first pass every row is sealed under v2, so a second pre-verify would + * compare v2 rows against the v1 form and report a false break — recording + * a scary, meaningless verdict over the real one. + * + * @var string + */ + public const DONE_KEY = 'audit_chain_seed_v2_rechained_at'; + + /** + * App-config key holding the pre-migration verdict, as JSON. + * + * @var string + */ + public const VERDICT_KEY = 'audit_chain_seed_v2_preverify'; + + /** + * How many rows to verify per query window. + * + * @var integer + */ + private const WINDOW = 500; + + /** + * Constructor. + * + * @param IDBConnection $db Reads the audit rows to verify. + * @param IAppConfig $config Stores the verdict and the done-marker. + * @param AuditHashService $hashes Performs the re-seal. + * @param LoggerInterface $logger Records the verdict in the log as well. + */ + public function __construct( + private readonly IDBConnection $db, + private readonly IAppConfig $config, + private readonly AuditHashService $hashes, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * The step's name, as shown by `occ maintenance:repair`. + * + * @return string The human-readable name. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public function getName(): string { + return 'Verify and re-seal the OpenRegister audit chain for flow attribution (seed v1 → v2)'; + }//end getName() + + /** + * Verify under v1, record the verdict, then re-chain under v2. + * + * @param IOutput $output Progress output. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public function run(IOutput $output): void { + if ($this->config->getValueString('openregister', self::DONE_KEY, '') !== '') { + $output->info('Audit chain already re-sealed under seed v2; nothing to do.'); + return; + } + + $verdict = $this->verifyUnderV1(); + + // Refuse before touching a single row if the account of what we are + // about to overwrite cannot be kept. A re-chain is not reversible, so + // "it ran but we do not know what it ran over" is strictly worse than + // "it has not run yet". + if ($this->recordVerdict(verdict: $verdict, output: $output) === false) { + $output->warning( + 'REFUSING to re-chain: the pre-verification verdict could not be stored. ' + . 'A re-chain that leaves no record of the chain state it replaced is not recoverable. ' + . 'Fix app-config writes and re-run `occ maintenance:repair`.' + ); + return; + } + + $this->reportVerdict(verdict: $verdict, output: $output); + + $result = $this->hashes->rechainAll(); + + $this->config->setValueString('openregister', self::DONE_KEY, (new DateTime())->format('c')); + + $output->info( + sprintf( + 'Re-sealed %d audit row(s) under seed v2; %d retention tombstone(s) carried forward.', + (int)($result['rechained'] ?? 0), + (int)($result['tombstonesPreserved'] ?? 0) + ) + ); + }//end run() + + /** + * Say what the pre-check found, in the register an operator will actually read. + * + * A broken chain is a WARNING and not a refusal: an operator whose chain is + * already broken still needs their instance to upgrade, and refusing would + * strand them. What must not happen is the re-seal going by unremarked, + * because afterwards the break is no longer detectable. + * + * @param array $verdict The pre-verification result. + * @param IOutput $output Progress output. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + private function reportVerdict(array $verdict, IOutput $output): void { + if ($verdict['valid'] === true) { + $output->info( + sprintf( + 'Audit chain verified intact under seed v1 (%d row(s), %d tombstone(s)). Re-sealing under v2.', + $verdict['entriesVerified'], + $verdict['purgedTombstones'] + ) + ); + + return; + } + + $output->warning( + sprintf( + 'The audit chain did NOT verify under seed v1 before re-sealing ' + . '(first break at row id %s, %d row(s) verified). ' + . 'This is recorded under app-config `%s`. The re-seal below will make the ' + . 'chain verify again — it does NOT repair the cause, and after it runs the ' + . 'break is no longer detectable. Investigate using the stored verdict.', + var_export($verdict['brokenAt'], true), + $verdict['entriesVerified'], + self::VERDICT_KEY + ) + ); + }//end reportVerdict() + + /** + * Walk the chain using the FROZEN v1 canonical form. + * + * Mirrors the live verifier's tombstone rule: a purged row's content no + * longer re-hashes to its stored value by design, but that stored value is + * still the link the next row committed to, so the chain is carried across + * it rather than reported as a break. + * + * Unsealed rows (null hash) are skipped and carry the previous hash + * forward, exactly as the live verifier does — a tail of unsealed rows is a + * smaller claim, never a false alarm. + * + * @return array{valid: bool, entriesVerified: int, brokenAt: int|null, skippedNullHashes: int, purgedTombstones: int} + * + * @SuppressWarnings(PHPMD.StaticAccess) AuditCanonicalV1 is deliberately static and + * deliberately FROZEN. Injecting it would present it as a collaborator that could be + * swapped or updated, which is the one thing it must never be: it describes the audit + * entity as it WAS, and a substituted implementation would silently invalidate the + * only check that can still tell an intact v1 chain from a tampered one. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + private function verifyUnderV1(): array { + $previousHash = AuditCanonicalV1::genesisHash(); + $entriesVerified = 0; + $skippedNullHashes = 0; + $purgedTombstones = 0; + $brokenAt = null; + $afterId = 0; + + while ($brokenAt === null) { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('openregister_audit_trails') + ->where($qb->expr()->gt('id', $qb->createNamedParameter($afterId, IQueryBuilder::PARAM_INT))) + ->orderBy('id', 'ASC') + ->setMaxResults(self::WINDOW); + + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + if ($rows === []) { + break; + } + + foreach ($rows as $row) { + $afterId = (int)$row['id']; + + $entity = $this->hydrate(row: $row); + $storedHash = $entity->getHash(); + + if ($storedHash === null || $storedHash === '') { + $skippedNullHashes++; + continue; + } + + if ($entity->isPurged() === true) { + // A declared tombstone: carry its stored hash forward as + // the chain link without re-deriving its blanked content. + $purgedTombstones++; + $previousHash = $storedHash; + continue; + } + + $expected = AuditCanonicalV1::computeHash(entry: $entity, previousHash: $previousHash); + + if (hash_equals($expected, $storedHash) === false) { + $brokenAt = (int)$row['id']; + break; + } + + $entriesVerified++; + $previousHash = $storedHash; + }//end foreach + }//end while + + return [ + 'valid' => ($brokenAt === null), + 'entriesVerified' => $entriesVerified, + 'brokenAt' => $brokenAt, + 'skippedNullHashes' => $skippedNullHashes, + 'purgedTombstones' => $purgedTombstones, + ]; + }//end verifyUnderV1() + + /** + * Hydrate a raw row exactly the way the live verifier does. + * + * 🔑 This deliberately mirrors `AuditHashService::mapRowToEntity()` + + * `AuditTrail::hydrate()` rather than using `Entity::fromRow()`. The two are + * very nearly equivalent, and "very nearly" is worth nothing here: this + * method feeds the one comparison whose job is to tell an intact chain from + * a tampered one. Any difference in how a date is parsed or a JSON column is + * decoded would change the canonical JSON, mismatch every row, and report a + * healthy chain as broken — the precise false verdict this whole step is + * built to avoid. `hydrate()` also ignores unknown properties, where + * `fromRow()` would throw on a column the entity does not declare. + * + * @param array $row The raw database row. + * + * @return AuditTrail The hydrated entity. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + private function hydrate(array $row): AuditTrail { + $mapped = []; + foreach ($row as $key => $value) { + // Snake_case to camelCase, character for character as the verifier does. + $camelKey = lcfirst(str_replace('_', '', ucwords((string)$key, '_'))); + $mapped[$camelKey] = $value; + } + + $entity = new AuditTrail(); + $entity->hydrate(object: $mapped); + + return $entity; + }//end hydrate() + + /** + * Persist the verdict, and say whether it actually landed. + * + * The boolean is load-bearing: {@see run()} refuses to re-chain on false. + * It is verified by READING BACK what was written rather than by the write + * not throwing — a config backend that silently drops a write would + * otherwise report success and leave no record at all. + * + * @param array $verdict The pre-verification result. + * @param IOutput $output Progress output. + * + * @return boolean True when the verdict is durably stored. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + private function recordVerdict(array $verdict, IOutput $output): bool { + $payload = json_encode( + array_merge($verdict, [ + 'seedFrom' => AuditCanonicalV1::GENESIS_SEED, + 'seedTo' => 'openregister-genesis-v2', + 'verifiedAt' => (new DateTime())->format('c'), + ]) + ); + + if ($payload === false) { + return false; + } + + $this->logger->warning( + message: '[RechainAuditTrailForFlowAttribution] Pre-re-chain audit chain verdict: ' . $payload, + context: ['file' => __FILE__, 'line' => __LINE__, 'app' => 'openregister'] + ); + + try { + $this->config->setValueString('openregister', self::VERDICT_KEY, $payload); + } catch (Throwable $e) { + $output->warning('Could not store the pre-verification verdict: ' . $e->getMessage()); + return false; + } + + // Read it back. "The setter did not throw" is not evidence the value is + // there, and this is the one fact the whole step exists to preserve. + return ($this->config->getValueString('openregister', self::VERDICT_KEY, '') === $payload); + }//end recordVerdict() +}//end class diff --git a/lib/Service/AuditCanonicalV1.php b/lib/Service/AuditCanonicalV1.php new file mode 100644 index 0000000000..e3a9fcaaf6 --- /dev/null +++ b/lib/Service/AuditCanonicalV1.php @@ -0,0 +1,162 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service; + +use OCA\OpenRegister\Db\AuditTrail; + +/** + * Canonicalises an audit row the way the v1 chain did. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ +final class AuditCanonicalV1 { + /** + * The genesis seed the v1 chain was built from. + * + * @var string + */ + public const GENESIS_SEED = 'openregister-genesis-v1'; + + /** + * Every key the v1 canonical JSON contained, `hash` and `previousHash` + * excluded as they always were. + * + * 🔴 FROZEN. Adding an entry here changes what a v1 row is claimed to have + * been sealed over, which makes intact rows read as tampered. + * + * @var string[] + */ + private const FIELDS = [ + 'id', + 'uuid', + 'schema', + 'register', + 'object', + 'objectUuid', + 'registerUuid', + 'schemaUuid', + 'action', + 'changed', + 'user', + 'userName', + 'session', + 'request', + 'ipAddress', + 'version', + 'created', + 'organisationId', + 'organisationIdType', + 'processingActivityId', + 'processingActivityUrl', + 'processingId', + 'confidentiality', + 'retentionPeriod', + 'size', + 'expires', + 'toolId', + 'paramsDigest', + 'resultSummary', + ]; + + /** + * The v1 genesis hash. + * + * @return string SHA-256 of the v1 seed. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public static function genesisHash(): string { + return hash('sha256', self::GENESIS_SEED); + }//end genesisHash() + + /** + * The canonical JSON a v1 row was sealed over. + * + * Same rules the v1 canonicaliser used: the allowlisted fields only, sorted + * keys, compact form. + * + * @param AuditTrail $entry The row to canonicalise. + * + * @return string The v1 canonical JSON. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public static function canonicalJson(AuditTrail $entry): string { + $serialized = $entry->jsonSerialize(); + + $data = []; + foreach (self::FIELDS as $field) { + // Present-but-null and absent are different canonical forms, and + // every v1 field was always PRESENT — jsonSerialize() emitted the + // whole array unconditionally. So a missing key is filled with null + // rather than skipped. + $data[$field] = ($serialized[$field] ?? null); + } + + ksort($data); + + return json_encode($data, (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + }//end canonicalJson() + + /** + * The hash a v1 row should carry, given its predecessor. + * + * @param AuditTrail $entry The row. + * @param string $previousHash The hash of the row before it. + * + * @return string The expected v1 hash. + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + public static function computeHash(AuditTrail $entry, string $previousHash): string { + return hash('sha256', ($previousHash . self::canonicalJson(entry: $entry))); + }//end computeHash() +}//end class diff --git a/lib/Service/AuditHashService.php b/lib/Service/AuditHashService.php index 5966678644..e7209fc82f 100644 --- a/lib/Service/AuditHashService.php +++ b/lib/Service/AuditHashService.php @@ -73,9 +73,23 @@ class AuditHashService { /** * The genesis seed used for the first entry in the hash chain. * + * ⚠️ VERSIONED, and versioned TOGETHER WITH the canonical form. The seed and + * the set of fields {@see getCanonicalJson()} covers are jointly the chain's + * identity: change either and every previously stored hash stops being + * re-derivable. ADR-003 Rule 4 therefore treats a change to either as a + * migration event — verify under the outgoing form, record the verdict, + * then re-seal — never an in-place edit. + * + * v1 → v2 (2026-08-28): the flow attribution fields joined the canonical + * form so that re-pointing an audit row at a different flow run breaks + * verification instead of going unnoticed. The v1 form is preserved in + * {@see AuditCanonicalV1} solely so the migration could verify what it was + * about to overwrite; see + * {@see \OCA\OpenRegister\Migration\RechainAuditTrailForFlowAttribution}. + * * @var string */ - private const GENESIS_SEED = 'openregister-genesis-v1'; + private const GENESIS_SEED = 'openregister-genesis-v2'; /** * Well-known advisory lock key serializing ALL seal passes. diff --git a/lib/Service/Flow/FlowEngine.php b/lib/Service/Flow/FlowEngine.php index d824bd1cad..ab8ae72735 100644 --- a/lib/Service/Flow/FlowEngine.php +++ b/lib/Service/Flow/FlowEngine.php @@ -119,6 +119,10 @@ class FlowEngine { * exactly as an empty registry does. * @param FlowTokenRouter|null $router Decides which exit a token takes. * @param FlowItemPlacement|null $placement Decides which items sit on which place. + * @param FlowRunContext|null $runContext The ambient attribution stack. Nullable + * so the engine stays unit-testable without + * a container; absent, writes are simply + * unattributed rather than mis-attributed. */ public function __construct( private readonly FlowDefinitionBuilder $builder, @@ -126,10 +130,38 @@ public function __construct( private readonly ?FlowOversightRegistry $oversight = null, private readonly ?FlowTokenRouter $router = null, private readonly ?FlowItemPlacement $placement = null, + private readonly ?FlowRunContext $runContext = null, ) { }//end __construct() + /** + * Open an attribution frame for the hop about to run. + * + * Split out of run() so the walk reads as the walk. The arithmetic is the + * part worth keeping together: the step number is the run's sequence BASE + * plus this hop's index in the segment log, and `recordSteps()` numbers the + * same entries from the same base in the same order — so an attributed audit + * row and its FlowRunStep row carry the same number. Deriving it from a + * dispatch counter instead desynchronises the moment a step is PINNED, since + * a pin logs an entry without ever reaching the dispatcher. + * + * @param array $context The run context, carrying the run uuid and base. + * @param string $name The node about to run. + * @param integer $index This hop's index within the segment's log. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-engine/spec.md + */ + private function enterHop(array $context, string $name, int $index): void { + $this->runContext?->push( + runUuid: ($context[FlowRunContext::CONTEXT_RUN] ?? null), + nodeId: $name, + sequence: ((int)($context[FlowRunContext::CONTEXT_BASE] ?? 0) + $index) + ); + }//end enterHop() + /** * The token router, made on demand when none was injected. * @@ -262,6 +294,16 @@ private function assertOversightAllows(array $context, string $name, string $typ * * @return array The run result: `{status, log: [], context: [], items: []}`. * + * @SuppressWarnings(PHPMD.NPathComplexity) 226 against a threshold of 200, and the + * 26 came from adding a `finally` to the hop. That `finally` is the attribution + * safety property, not a convenience: every other exit from a hop is a `return` + * inside a catch — a stop, a suspension, a terminally-failed step — so a pop on the + * success path would leave the frame standing and attribute LATER writes, in a LATER + * run advanced by the same worker, to a run that had already finished. That failure + * is silent and produces well-formed rows. Restructuring the walk to win the metric + * would trade a real correctness guarantee for a number; the branches themselves are + * each one clearly-labelled outcome of a hop. + * * @spec openspec/changes/or-flow-engine/specs/flow-engine/spec.md */ public function run( @@ -416,6 +458,24 @@ public function run( continue; }//end if + // ATTRIBUTION, around the hop. Everything written from here until the + // matching pop is filed under this run and node — including writes + // made by code that has no idea a flow is running, which is the + // whole point (a node cannot report what it did not know it did). + // + // The step number is `base + count($log)`, and that is not an + // approximation: `recordSteps()` numbers this segment's entries from + // the same base in the same order, so an attributed audit row and + // its `FlowRunStep` row carry the SAME sequence. Deriving it from a + // dispatch counter instead would desynchronise the moment a step is + // PINNED — a pin logs an entry without ever reaching the dispatcher. + // + // Pushed here rather than around the pinned branch above because a + // pinned step is not executed at all: it produces no writes, so it + // needs no frame, and skipping it costs nothing since the index is + // read from the log rather than counted per push. + $this->enterHop(context: $context, name: $name, index: count($log)); + try { // OVERSIGHT, before the hop. A veto is raised as a FlowStop so it // travels the same path as an author's Stop step: the run ENDS. @@ -514,6 +574,16 @@ public function run( if ($outcome !== null) { return $outcome; } + } finally { + // 🔴 UNCONDITIONAL, and structurally so. Every other exit from + // this hop is a `return` inside a catch — a stop, a suspension, + // a failed step whose policy is terminal. A pop placed on the + // success path would leave the frame standing on all three, and + // the next write in the process — a LATER run advanced by the + // same worker — would be filed under a run that had already + // finished. Nothing about that row looks wrong, which is why it + // has to be impossible rather than merely remembered. + $this->runContext?->pop(); }//end try // Which single exit this firing takes. A token is unique and diff --git a/lib/Service/Flow/FlowNodeResumeState.php b/lib/Service/Flow/FlowNodeResumeState.php index 7dd247cbf0..ef1bdd0e32 100644 --- a/lib/Service/Flow/FlowNodeResumeState.php +++ b/lib/Service/Flow/FlowNodeResumeState.php @@ -64,6 +64,25 @@ public function __construct( }//end __construct() + /** + * Which node this slot belongs to. + * + * A node is handed its own slot but was never told its own NAME, which is + * fine while the only thing it does with the slot is read and write it. + * It stops being fine as soon as a node has to hand its identity to + * something OUTSIDE the run — a task record that must later resume this + * exact node, for instance. A run accumulates one awaiting slot per node, + * so "resume this run" is not an answer: the resumer has to name the node, + * and it can only do that if the node could name itself. + * + * @return string This node's id within the flow graph. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-node-must-be-able-to-resume-from-where-it-stopped + */ + public function nodeId(): string { + return $this->nodeId; + }//end nodeId() + /** * Whether this node has progress stored from an earlier pass. * diff --git a/lib/Service/Flow/FlowRunAssignee.php b/lib/Service/Flow/FlowRunAssignee.php new file mode 100644 index 0000000000..88d77143d8 --- /dev/null +++ b/lib/Service/Flow/FlowRunAssignee.php @@ -0,0 +1,145 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCP\IGroupManager; + +/** + * Reads a suspended run's recorded assignee and decides who may answer it. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ +class FlowRunAssignee { + /** + * Constructor. + * + * @param IGroupManager|null $groupManager Resolves group membership. Nullable so the + * service stays constructible without a + * container; absent, a group assignment + * REFUSES rather than admits — the + * fail-closed direction. + */ + public function __construct( + private readonly ?IGroupManager $groupManager = null, + ) { + }//end __construct() + + /** + * The assignee recorded by whichever step is currently awaiting an answer. + * + * Reads the per-node resume slots the node wrote. A run carries slots for + * several nodes across its life, so the one that matters is a slot that + * ASKED (`askedAt`) and has not been answered. + * + * @param FlowRun $run The suspended run. + * + * @return string The assignee uid or group id; '' when the step is unassigned. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ + public function recordedFor(FlowRun $run): string { + $context = ($run->getContext() ?? []); + $slots = ($context[FlowResumeState::CONTEXT_KEY] ?? []); + if (is_array($slots) === false) { + return ''; + } + + foreach ($slots as $slot) { + if (is_array($slot) === false) { + continue; + } + + if (isset($slot['askedAt']) === false) { + continue; + } + + $assignee = trim((string)($slot['assignee'] ?? '')); + if ($assignee !== '') { + return $assignee; + } + } + + return ''; + }//end recordedFor() + + /** + * Whether this user may answer the step the run is waiting on. + * + * @param FlowRun $run The suspended run. + * @param string|null $uid The acting user, or null when there is no session. + * + * @return boolean True when the answer may be accepted. + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ + public function mayAnswer(FlowRun $run, ?string $uid): bool { + $assignee = $this->recordedFor(run: $run); + + // Unassigned is deliberately open — see the class docblock. This is the + // one branch that must NOT be tightened without changing the spec. + if ($assignee === '') { + return true; + } + + // Fail CLOSED: an assigned decision is never anonymous. + if ($uid === null || trim($uid) === '') { + return false; + } + + if ($uid === $assignee) { + return true; + } + + // The group branch. Absent a group manager this refuses rather than + // admits, which is the safe direction — and is why its absence must be + // visible in tests rather than inferred from a passing suite. + if ($this->groupManager !== null && $this->groupManager->isInGroup($uid, $assignee) === true) { + return true; + } + + return false; + }//end mayAnswer() +}//end class diff --git a/lib/Service/Flow/FlowRunContext.php b/lib/Service/Flow/FlowRunContext.php new file mode 100644 index 0000000000..cc34fa3540 --- /dev/null +++ b/lib/Service/Flow/FlowRunContext.php @@ -0,0 +1,177 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +/** + * Holds the ambient attribution frame for the step currently being dispatched. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ +class FlowRunContext { + /** + * The context key carrying the executing run's uuid into the engine. + * + * @var string + */ + public const CONTEXT_RUN = 'x-openregister-attribution-run'; + + /** + * The context key carrying the run's step-sequence base into the engine. + * + * Steps are numbered continuously across a resume, so a segment that starts + * after a suspension must begin where the previous one stopped rather than + * at zero. The base is read once before the walk and the engine adds each + * hop's index to it, which is what makes an attributed audit row's step + * number the SAME number the matching `FlowRunStep` row is given. + * + * @var string + */ + public const CONTEXT_BASE = 'x-openregister-attribution-base'; + + /** + * The stack of attribution frames, innermost last. + * + * A frame is `['run' => string, 'node' => string, 'step' => int]`, or NULL + * for a hop that is not attributable (see {@see push()}). + * + * @var array + */ + private array $frames = []; + + /** + * Enter a step: everything written from here until the matching pop is + * attributed to this run, node and step. + * + * A null or empty `$runUuid` pushes an explicit NON-attributing frame + * rather than pushing nothing. Two reasons, both about failure modes: + * + * - It keeps push and pop unconditionally paired, so the caller cannot + * unbalance the stack by taking a different branch on the way out than + * it took on the way in. + * - It stops an unattributable inner hop from silently inheriting the + * ENCLOSING run's attribution, which would file a sub-flow's writes + * under its parent — wrong, and wrong in a way that reads as correct. + * + * @param string|null $runUuid The uuid of the executing run, or null when the caller has no run (the flow tester, node unit tests). + * @param string $nodeId The id of the node being dispatched. + * @param integer $sequence The step's sequence number within the run. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function push(?string $runUuid, string $nodeId, int $sequence): void { + if ($runUuid === null || trim($runUuid) === '') { + $this->frames[] = null; + return; + } + + $this->frames[] = [ + 'run' => $runUuid, + 'node' => $nodeId, + 'step' => $sequence, + ]; + }//end push() + + /** + * Leave the innermost step, restoring the enclosing one if there is one. + * + * Popping an empty stack is deliberately NOT an error. This is called from + * a `finally`, and a `finally` that can itself throw would replace the + * exception the step actually failed with — turning a diagnosable node + * failure into a confusing context-bookkeeping error. + * + * @return void + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function pop(): void { + array_pop($this->frames); + }//end pop() + + /** + * The frame to attribute a write to, or null when nothing is executing. + * + * @return array{run: string, node: string, step: int}|null The innermost frame, or null outside any run. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function current(): ?array { + if ($this->frames === []) { + return null; + } + + // The INNERMOST frame, even when it is null. A null innermost frame + // means "the hop currently executing is not attributable", and must not + // fall through to an enclosing run's frame. + return $this->frames[(count($this->frames) - 1)]; + }//end current() + + /** + * How deep the stack is. Test and diagnostic use only. + * + * Exposed so a test can assert the stack is EMPTY after a step rather than + * inferring it from an attribution being absent — an assertion that would + * also pass if attribution were broken for some unrelated reason. + * + * @return integer The number of frames currently held. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function depth(): int { + return count($this->frames); + }//end depth() +}//end class diff --git a/lib/Service/Flow/FlowRunService.php b/lib/Service/Flow/FlowRunService.php index d75ebe001f..790e66b90e 100644 --- a/lib/Service/Flow/FlowRunService.php +++ b/lib/Service/Flow/FlowRunService.php @@ -125,6 +125,22 @@ public function __construct( }//end __construct() + /** + * The run's step history — its numbering, and its recording. + * + * Made on demand rather than injected: this constructor is called explicitly + * by three test suites, and inserting a parameter would silently shift every + * later slot for them. It holds no state beyond the collaborators this + * service already has. + * + * @return FlowStepHistory The history recorder. + * + * @spec openspec/changes/flow-engine-unification/specs/flow-execution-history/spec.md + */ + private function stepHistory(): FlowStepHistory { + return new FlowStepHistory(steps: $this->steps, logger: $this->logger); + }//end stepHistory() + /** * Make an unattributed refusal visible on the flow, and stop a dead schedule. * @@ -267,12 +283,18 @@ private function nodeContextFor(FlowRun $run, bool $resuming, FlowRunGuard $guar $context[FlowResumeState::CONTEXT_KEY] = FlowResumeState::fromArray(($context[FlowResumeState::CONTEXT_KEY] ?? null)); $context[FlowRunGuard::CONTEXT_KEY] = $guard; + // ATTRIBUTION. Read BEFORE the walk: the audit rows are written during + // it, so the base has to be predicted. See {@see FlowStepHistory}. + $context[FlowRunContext::CONTEXT_RUN] = (string)$run->getUuid(); + $context[FlowRunContext::CONTEXT_BASE] = $this->stepHistory()->baseFor(runUuid: (string)$run->getUuid()); + $this->flowState->attach(run: $run, context: $context); return $context; }//end nodeContextFor() + /** * The liveness-and-deadline handle for one run of one flow. * @@ -793,93 +815,6 @@ public function execute(FlowRun $run, array $flow, object $subject, ?array $seed * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md */ - /** - * Write one step row per node execution in this segment. - * - * The aggregate `log` column answers "what happened in this run" and - * nothing else — "which node type fails", "every failed step for this - * flow", "what did node X output" all require loading and walking every - * run's blob. One row per hop makes those queryable, and gives retention - * something it can prune per flow. - * - * Sequence CONTINUES from the highest already recorded rather than - * restarting at zero, so a run that suspends on a wait node and resumes - * later reads as one ordered history instead of two interleaved ones. - * - * Failing to record history must never fail the run itself: the run is the - * work, the rows are the account of it. - * - * @param FlowRun $run The run these steps belong to. - * @param array $entries The engine log entries for this segment. - * - * @return void - * - * @spec openspec/changes/flow-engine-unification/specs/flow-execution-history/spec.md - */ - private function recordSteps(FlowRun $run, array $entries): void { - if ($this->steps === null || empty($entries) === true) { - return; - } - - $runUuid = (string)$run->getUuid(); - - try { - $sequence = ($this->steps->highestSequence(runUuid: $runUuid) + 1); - } catch (Throwable $e) { - $this->logger->warning( - message: '[FlowRunService] Could not read the step sequence for run ' . $runUuid . ': ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - return; - } - - foreach ($entries as $entry) { - if (is_array($entry) === false) { - continue; - } - - $step = new FlowRunStep(); - $step->setRunUuid($runUuid); - $step->setFlowId((string)$run->getFlowId()); - $step->setNodeId((string)($entry['transition'] ?? '')); - $step->setNodeType(($entry['type'] ?? null)); - $step->setSequence($sequence); - $step->setStatus((string)($entry['status'] ?? 'unknown')); - $step->setDurationMs(($entry['durationMs'] ?? null)); - $step->setCreated(new DateTime()); - $step->setFinished(new DateTime()); - - // `error` and `reason` are distinct outcomes that both belong in - // the error column: a thrown step and a deliberately stopped one - // are each something a person needs to read back. - $step->setError(($entry['error'] ?? ($entry['reason'] ?? null))); - - // What the node produced, minus the items themselves — a step row - // is an index into the run, not a second copy of its data. - $step->setOutput( - array_filter( - [ - 'itemsIn' => ($entry['itemsIn'] ?? null), - 'itemsOut' => ($entry['itemsOut'] ?? null), - 'checkId' => ($entry['checkId'] ?? null), - ], - static fn ($v): bool => $v !== null - ) - ); - - try { - $this->steps->insert($step); - } catch (Throwable $e) { - $this->logger->warning( - message: '[FlowRunService] Could not record a step row for run ' . $runUuid . ': ' . $e->getMessage(), - context: ['file' => __FILE__, 'line' => __LINE__] - ); - } - - $sequence++; - }//end foreach - - }//end recordSteps() /** * Write back what a walk produced. @@ -901,7 +836,7 @@ private function persistResult(FlowRun $run, array $result): FlowRun { // Promote THIS segment's entries to step rows. Only the new entries — // `$result['log']`, not the merged `$log` — or every resume would // re-record the whole history it had already written. - $this->recordSteps(run: $run, entries: (array)($result['log'] ?? [])); + $this->stepHistory()->record(run: $run, entries: (array)($result['log'] ?? [])); // The token travels as an object so steps can write to it; the column // holds JSON. Serialising here — on the suspended path as much as the diff --git a/lib/Service/Flow/FlowStepHistory.php b/lib/Service/Flow/FlowStepHistory.php new file mode 100644 index 0000000000..66e40e4d18 --- /dev/null +++ b/lib/Service/Flow/FlowStepHistory.php @@ -0,0 +1,187 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Service + * @package OCA\OpenRegister\Service\Flow + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Flow; + +use DateTime; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunStep; +use OCA\OpenRegister\Db\FlowRunStepMapper; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Computes the step-sequence base for a walk about to start. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ +class FlowStepHistory { + /** + * Constructor. + * + * @param FlowRunStepMapper|null $steps Reads the highest sequence already recorded. + * Null where history is not being recorded at + * all, in which case numbering starts at zero. + * @param LoggerInterface|null $logger Records a failed read. + */ + public function __construct( + private readonly ?FlowRunStepMapper $steps = null, + private readonly ?LoggerInterface $logger = null, + ) { + }//end __construct() + + /** + * The sequence the first step of this segment will carry. + * + * Continues from the highest already recorded, so a run that suspended and + * resumed days later reads as ONE ordered history rather than two + * interleaved ones starting at zero. + * + * A failure to read is not a reason to fail the run: the work is the run, + * and the numbering is the account of it. Attribution degrades to numbering + * from zero — wrong only in its offset, and still correctly ordering the + * run's writes among themselves. + * + * @param string $runUuid The run about to be walked. + * + * @return integer The base sequence. + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + public function baseFor(string $runUuid): int { + if ($this->steps === null || trim($runUuid) === '') { + return 0; + } + + try { + return ($this->steps->highestSequence(runUuid: $runUuid) + 1); + } catch (Throwable $e) { + $this->logger?->warning( + message: '[FlowStepHistory] Could not read the step sequence base for attribution: ' + . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + + return 0; + } + }//end baseFor() + + /** + * Write one step row per node execution in this segment. + * + * The aggregate `log` column answers "what happened in this run" and + * nothing else — "which node type fails", "every failed step for this + * flow", "what did node X output" all require loading and walking every + * run's blob. One row per hop makes those queryable, and gives retention + * something it can prune per flow. + * + * Sequence CONTINUES from the highest already recorded rather than + * restarting at zero, so a run that suspends on a wait node and resumes + * later reads as one ordered history instead of two interleaved ones. + * + * Failing to record history must never fail the run itself: the run is the + * work, the rows are the account of it. + * + * @param FlowRun $run The run these steps belong to. + * @param array $entries The engine log entries for this segment. + * + * @return void + * + * @spec openspec/changes/flow-engine-unification/specs/flow-execution-history/spec.md + */ + public function record(FlowRun $run, array $entries): void { + if ($this->steps === null || empty($entries) === true) { + return; + } + + $runUuid = (string)$run->getUuid(); + + // The SAME computation the attribution used before the walk. One method, + // so the number an audit row was stamped with and the number its step row + // is given cannot drift apart. + $sequence = $this->baseFor(runUuid: $runUuid); + + foreach ($entries as $entry) { + if (is_array($entry) === false) { + continue; + } + + $step = new FlowRunStep(); + $step->setRunUuid($runUuid); + $step->setFlowId((string)$run->getFlowId()); + $step->setNodeId((string)($entry['transition'] ?? '')); + $step->setNodeType(($entry['type'] ?? null)); + $step->setSequence($sequence); + $step->setStatus((string)($entry['status'] ?? 'unknown')); + $step->setDurationMs(($entry['durationMs'] ?? null)); + $step->setCreated(new DateTime()); + $step->setFinished(new DateTime()); + + // `error` and `reason` are distinct outcomes that both belong in + // the error column: a thrown step and a deliberately stopped one + // are each something a person needs to read back. + $step->setError(($entry['error'] ?? ($entry['reason'] ?? null))); + + // What the node produced, minus the items themselves — a step row + // is an index into the run, not a second copy of its data. + $step->setOutput( + array_filter( + [ + 'itemsIn' => ($entry['itemsIn'] ?? null), + 'itemsOut' => ($entry['itemsOut'] ?? null), + 'checkId' => ($entry['checkId'] ?? null), + ], + static fn ($v): bool => $v !== null + ) + ); + + try { + $this->steps->insert($step); + } catch (Throwable $e) { + $this->logger->warning( + message: '[FlowStepHistory] Could not record a step row for run ' . $runUuid . ': ' . $e->getMessage(), + context: ['file' => __FILE__, 'line' => __LINE__] + ); + } + + $sequence++; + }//end foreach + + }//end record() +}//end class diff --git a/openspec/changes/flow-object-attribution/.openspec.yaml b/openspec/changes/flow-object-attribution/.openspec.yaml new file mode 100644 index 0000000000..7f2cf9bc02 --- /dev/null +++ b/openspec/changes/flow-object-attribution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-28 diff --git a/openspec/changes/flow-object-attribution/design.md b/openspec/changes/flow-object-attribution/design.md new file mode 100644 index 0000000000..19afa56cbc --- /dev/null +++ b/openspec/changes/flow-object-attribution/design.md @@ -0,0 +1,103 @@ +## Context + +See `proposal.md — Why`. The constraints that shape the approach: + +- **The audit trail is hash-chained (ADR-003).** `AuditHashService::getCanonicalJson()` hashes `AuditTrail::jsonSerialize()` minus `hash`/`previousHash`. Adding a key to that serialisation changes the canonical form of every row ever written. This is the reason `purgedAt` was deliberately left OUT of the hash, with the residual weakness documented in `AuditHashService` (~line 1074). +- **Both audit insert paths share one builder.** `createAuditTrail()` and the batched `insertAuditTrails()` both go through `buildAuditTrail()`. A stamp applied to the insert methods rather than the builder would silently miss every batched write. +- **`FlowRun` already carries `subjectUuid`** — the trigger's object. It is not a record of what the run touched and must not be confused with one. +- **`AuditHashService` already has what a re-seal needs**: `rechainAll()` (batched), `sealRows()`, an advisory lock serialising seal passes, and tombstone-aware verification. +- **A run is not one process.** `FlowRunWorker` advances several runs in sequence, and a suspended run resumes in a later process entirely. Anything process-global must be scoped per step, not per request. + +## Goals / Non-Goals + +**Goals:** +- Attribution that captures writes made by code that has never heard of flows. +- One place that decides whether a write is attributed, so the answer cannot differ per call site. +- A seed migration that cannot silently convert a tampered chain into a verified one. + +**Non-Goals:** +- Re-attributing history. Rows written before this change stay unattributed; there is no inference pass. +- Attribution for non-OpenRegister side effects (an email sent, a webhook posted). Those are `FlowRunStep.output`'s job. +- The dossiq flow itself, and the human-task model — companion change `case-flow-human-steps`. +- Making `purgedAt` hash-covered. It stays out; this change does not reopen it. + +## Decisions + +### D1 — Ambient context, not a parameter + +A `FlowRunContext` service holds the current `(runUuid, nodeId, sequence)`. `RegistryStepDispatcher::dispatch()` pushes before the step and pops in a `finally`. + +*Why:* the whole point is to catch writes the writing code does not know are part of a flow — a leaf app called by a node, a cascade, a lifecycle hook. Threading a parameter through `ObjectService` would attribute only what a node explicitly passed, which is the same blind spot as the link-table option that was rejected. + +*Alternative rejected:* passing attribution down `SaveObject`/`DeleteObject` signatures. Wider blast radius (every caller of a changed signature — and a new argument breaks positional callers one slot later), and still blind to leaf-app writes. + +*Shape:* a **stack**, not a scalar. A sub-flow node dispatches a nested run; popping must restore the parent's frame rather than clear to empty, or the parent's remaining steps in that same dispatch go unattributed. + +### D2 — Stamp in `buildAuditTrail()` + +One read of the context, in the shared builder, so single and batched inserts are identical. `createAuditTrailEntry()` (archival/retention entries) and `createToolInvocationEntry()` (MCP) get the same treatment for consistency. + +*Consequence, accepted:* `GetObject` writes a `read` audit row, so reads performed during a run are attributed too. This is more truthful than filtering them out — the run did touch the object. The read surface exposes `action`, so a caller wanting writes only can say so. + +### D3 — Inside the hash, with a v2 seed and a full re-chain + +Per the decision recorded on this change: the three fields join the canonical JSON, `GENESIS_SEED` becomes `openregister-genesis-v2`, and `rechainAll()` re-seals the table. Attribution is therefore tamper-evident — re-pointing a row at a different run breaks verification. + +*Alternatives rejected:* excluding the keys from the canonical form (the `purgedAt` precedent) would ship with zero risk to the existing chain but leaves attribution unprotected; emitting the keys only when set would preserve old rows byte-identically but introduces an omit-when-null rule in the canonicaliser. + +### D4 — The pre-migration check uses a FROZEN v1 canonicaliser + +This is the decision that makes D3 safe, and it is the one that is easy to get wrong. + +The migration must verify the chain **before** re-sealing it. If that verification canonicalises with the new code, it includes the new keys, every pre-existing row mismatches, and the verdict is "broken" whether the chain was healthy or compromised. The re-chain then overwrites the evidence either way. The check would run, produce output, and be worthless. + +So the migration carries `AuditCanonicalV1` — a frozen, private copy of the v1 key list and canonicalisation rules, never updated again. The pre-check verifies against it, and its verdict is written as an audit row (action `audit.rechain.preverify`, sealed under v1 as the last v1 row) recording `valid`, `entriesVerified`, `brokenAt`, and the seed version moved from/to. + +**Test the check by breaking the thing it checks**: seed a chain, tamper with one row, run the migration, and assert the recorded verdict names that row. A test that only seeds a healthy chain passes identically when the pre-check is a stub returning `valid: true`. + +### D5 — A stamp, not a foreign key + +`flow_run` is a plain string column with no referential link. `FlowRunRetentionJob` prunes runs; a FK would either block pruning or cascade into immutable audit rows. Reading an object's history must not fail because the run has been pruned — the identifiers are the historical fact, and resolving them to a live run is best-effort. + +### D6 — Index for the run direction + +`(flow_run)` alone is enough for "what did this run touch"; the object direction is already served by the existing `object_uuid` index. No composite index until a query needs one — ADR-009 treats speculative indexes on the audit hot path as a cost, not a hedge. + +## Declarative-vs-imperative decision (ADR-031) + +| Behaviour | Path | Rationale | +|---|---|---| +| Stamping attribution onto audit rows | **imperative** | Engine and persistence internals in OpenRegister core. There is no app schema here and no `x-openregister-*` extension that expresses "attribute the ambient run onto the audit row" — this is the mechanism such declarations would be recorded BY. | +| Canonicalisation and re-seal | **imperative** | Cryptographic integrity operation, migration-time. | +| Read surfaces (run → objects, object → runs) | **imperative** | A query filter and a controller endpoint over a native (non-OR-object) table; `flow_runs` and `audit_trails` are native tables by design (`flow-storage`). | + +No part of this change introduces or modifies an OpenRegister schema, so it declares no lifecycle, aggregation, calculation, notification, relation or widget. + +## Seed Data (ADR-001) + +**None.** This change adds no OpenRegister schemas and no register objects, so there is no `_registers.json` entry to generate. Attribution rows are produced by running a flow, not by seeding. The demonstrable data for this change is a flow run in the companion dossiq change; a seeded audit row would be a fabricated record of something that never happened, which is precisely what an immutable audit trail must not contain. + +## Risks / Trade-offs + +| Risk | Mitigation | +|---|---| +| **A context left in place attributes later, unrelated writes to a finished run.** Silent: the rows are well-formed and plausible. | Push/pop in `finally`. The test that matters asserts the LEAK direction — perform a non-flow write after a run and require the columns to be null. A happy-path test passes with the leak present. | +| **The re-chain re-blesses history.** Once re-sealed under v2, any tampering that predates the migration is undetectable, permanently. | Accepted deliberately (D3). D4's pre-check plus its persisted verdict is the compensating control: the state of the chain at migration time survives the migration that erases the ability to re-derive it. | +| **Rollback is not available.** Reverting the code does not restore v1 hashes; the v1 values are overwritten in place. | Treat as a one-way migration. Require a database backup before the release, state it in the upgrade note, and make the migration refuse to start if it cannot write its pre-verify verdict. | +| **Mixed code versions during a rolling deploy** would seal some rows under v1 and some under v2, interleaved. | The existing advisory seal lock serialises seal passes; the migration runs to completion within one release step, and the seed version is read from one constant rather than per-row. | +| **A long re-chain on a large audit table** blocks the upgrade. | `rechainAll()` already batches; the migration is resumable and idempotent (spec scenario) so an interrupted upgrade continues rather than restarts. | +| **Attribution widens what an audit row discloses** — it names a run and node a reader might not otherwise see. | Attribution is returned only on rows the caller may already read; the run→objects endpoint reuses the run visibility rule that `resume()` applies rather than inventing a second one. A validator and an executor each owning a copy of the same rule is how they drift apart. | +| **`FlowRunWorker` advances several runs per process**, so a leak crosses runs, not just steps. | Same `finally`; plus a test that runs two runs in sequence in one process and asserts the second's writes carry the second's run uuid. | + +## Migration Plan + +1. **Schema migration** — add `flow_run`, `flow_node`, `flow_step` to `oc_openregister_audit_trails`, plus the `flow_run` index. Nullable; no backfill. +2. **Repair step, registered in `appinfo/info.xml` in the same commit.** (A repair step written but never registered does nothing and reports nothing — four apps in the fleet were found in that state.) In order: pre-verify against `AuditCanonicalV1` → persist the verdict as a v1-sealed audit row → `rechainAll()` under the v2 seed. +3. **Code cutover** — `jsonSerialize()` emits the three keys; `GENESIS_SEED` is `openregister-genesis-v2`. +4. **Post-check** — `verifyChain()` under v2 returns `valid: true` over the full table. + +**Rollback:** none for step 2/3 (see Risks). Steps 1 and 4 are reversible; the re-seal is not. + +## Open Questions + +- Whether the run→objects endpoint should page. Deferred: it does not change the specs, the approach or the task breakdown, and the shape of real runs will answer it. The first consumer (a case flow with ~12 nodes) is far below any page boundary. diff --git a/openspec/changes/flow-object-attribution/proposal.md b/openspec/changes/flow-object-attribution/proposal.md new file mode 100644 index 0000000000..f44f7ee689 --- /dev/null +++ b/openspec/changes/flow-object-attribution/proposal.md @@ -0,0 +1,46 @@ +--- +kind: code +--- + +## Why + +A flow run records exactly one object: `FlowRun.subjectUuid`, the thing that triggered it. Everything the run goes on to touch — the objects it creates, the ones its nodes update, the ones a leaf app writes on its behalf — is recorded nowhere. So neither of the two questions people actually ask can be answered today: + +- **From the object:** "which run, and which node of it, last touched this case?" +- **From the run:** "what did this run actually change?" + +`FlowRunStep` already answers *what happened* per node. It does not answer *what it happened to*. That gap is why a flow that spans a case, its tasks, its decisions and its documents is unauditable in practice: the history exists, and it points at nothing. + +## What Changes + +- Every audit-trail row written while a flow run is executing is stamped with the run uuid, the node id and the step sequence that caused it. The stamp is set from an **ambient run context** established by the step dispatcher, so it captures writes made by code that has never heard of flows — including leaf apps a node calls into. A node-level record would see only what the node itself knew it wrote. +- Two read directions: `GET /api/flow-runs/{uuid}/objects` (objects touched, grouped by node) and a `flowRun` filter plus the three new fields on the existing audit-trail query. +- **BREAKING (audit chain):** the three keys join the canonical JSON that the hash chain covers, so the stamp is tamper-evident. Per ADR-003 Rule 4 this is a migration event: the genesis seed moves to `v2` and the table is re-sealed by the existing `AuditHashService::rechainAll()`. +- The re-chain is **verify-then-rechain**. The migration first verifies the existing chain against a *frozen copy* of the v1 canonicaliser and persists that verdict, then re-seals under v2. Without the frozen copy the pre-check would canonicalise under v2, report every row broken, and the re-chain would bless it anyway — a check that cannot distinguish a healthy chain from a tampered one is not a check. +- UI: the objects a run touched on the run detail, and the runs that touched an object in its sidebar. + +## Capabilities + +### New Capabilities +- `flow-object-attribution`: what a flow run records about the objects it touches — the ambient run context and its lifecycle, what a stamped row means, and how the attribution is read back from either end. + +### Modified Capabilities +- `audit-hash-chain`: the canonical JSON gains three flow keys and the genesis seed is versioned; adds the requirement that a seed change is a verify-then-rechain migration with the outgoing canonicaliser frozen for the verification. +- `flow-engine`: the step dispatcher establishes and unconditionally clears the run context around every step, and a run can report the objects it touched. + +## Impact + +| Area | Change | +|---|---| +| `lib/Db/AuditTrail.php` | three fields + `jsonSerialize()` keys (hash-covered) | +| `lib/Db/AuditTrailMapper.php` | both insert paths read the ambient context; `flowRun` query filter | +| `lib/Service/AuditHashService.php` | seed becomes `v2`; frozen `v1` canonicaliser retained for migration verification | +| `lib/Service/Flow/RegistryStepDispatcher.php` | establishes / clears the context per step | +| `lib/Service/Flow/FlowRunContext.php` | new — the ambient holder | +| `lib/Controller/FlowRunController.php` | `objects` endpoint | +| `lib/Migration/` | columns + index; verify-then-rechain repair step | +| Frontend | run detail "objects touched"; object sidebar "flow runs" | + +**Risk owned by this change:** a context that is not cleared attributes later, unrelated writes to a finished run. It is silent — the rows look correct. The clear is `finally`-bound and asserted by a test that performs a non-flow write *after* a run and requires the columns to be null. + +**Consumers:** dossiq is the first, via the companion `case-flow-human-steps` change. Nothing in this change is dossiq-specific. diff --git a/openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md b/openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md new file mode 100644 index 0000000000..ded56b28d3 --- /dev/null +++ b/openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md @@ -0,0 +1,68 @@ +## MODIFIED Requirements + +### Requirement: Every audit trail entry MUST include a SHA-256 hash chained to the previous entry +Each audit trail entry MUST contain a `hash` field computed as `SHA-256(previous_hash + canonical_json(entry_data))`. The `previous_hash` field links to the preceding entry's hash, forming a tamper-evident chain. + +The genesis seed is versioned. The current seed is `openregister-genesis-v2`, which supersedes `openregister-genesis-v1`; the version reflects the canonical form the chain commits to, and the two MUST move together. + +The canonical form covers the flow attribution fields — the run, node and step that caused the row — so that re-pointing a row at a different run, or stripping its attribution, breaks verification exactly as altering any other field does. + +#### Scenario: First audit entry uses genesis hash +- **WHEN** the first audit trail entry is created in the system (no previous entries exist) +- **THEN** the entry MUST have `previousHash` set to `SHA-256("openregister-genesis-v2")` +- **AND** the entry MUST have `hash` set to `SHA-256(genesis_hash + canonical_json(entry_data))` + +#### Scenario: Subsequent entries chain to previous hash +- **WHEN** audit trail entry N is created after entry N-1 with hash `abc123...` +- **THEN** entry N MUST have `previousHash` set to `abc123...` +- **AND** entry N MUST have `hash` set to `SHA-256("abc123..." + canonical_json(entry_data_N))` + +#### Scenario: Canonical JSON excludes hash fields +- **WHEN** computing the hash for an audit trail entry +- **THEN** the canonical JSON MUST include all entry fields except `hash` and `previousHash` +- **AND** the JSON MUST use sorted keys and no whitespace (compact canonical form) + +#### Scenario: Flow attribution is covered by the hash +- **WHEN** a row's recorded run, node or step is altered directly in the database +- **AND** the chain is verified +- **THEN** verification MUST report the chain as broken at that row + +## ADDED Requirements + +### Requirement: Changing the canonical form is a verify-then-rechain migration @e2e exclude migration-time integrity operation with no user-facing surface; asserted by migration tests over seeded chains + +A change to the canonical form or the genesis seed SHALL be performed as a single migration that, in order: + +1. verifies the existing chain **against the canonical form the existing rows were sealed under**, not the incoming one; +2. durably records that verdict — whether the chain was intact, and if not, where it first broke — before any row is altered; +3. re-seals every row under the new seed and canonical form. + +The verification in step 1 SHALL use a frozen copy of the outgoing canonicaliser retained in the codebase for that purpose. Verifying with the incoming canonicaliser would report every pre-existing row as broken regardless of whether it had been tampered with, making the verdict indistinguishable between a healthy chain and a compromised one — and a re-chain then permanently conceals the difference. + +The recorded verdict SHALL be readable after the migration completes. A re-chain over a chain that did not verify is permitted, but SHALL NOT be silent. + +#### Scenario: An intact chain is re-sealed and recorded as intact +- **WHEN** the migration runs against a chain that verifies under the outgoing canonical form +- **THEN** the verdict recorded before re-sealing states the chain was intact and names the number of rows verified +- **AND** every row is afterwards sealed under the new seed +- **AND** verification under the new canonical form succeeds + +#### Scenario: A broken chain is recorded before it is re-sealed over +- **WHEN** the migration runs against a chain that does NOT verify under the outgoing canonical form +- **THEN** the recorded verdict states the chain was broken and identifies where +- **AND** that verdict remains readable after the re-seal has made the break undetectable + +#### Scenario: The pre-check uses the outgoing canonical form +- **WHEN** the pre-migration verification runs +- **THEN** it computes canonical JSON without the fields the migration is introducing +- **AND** a chain sealed before the migration verifies as intact + +#### Scenario: Re-sealing is resumable and idempotent +- **WHEN** the migration is interrupted part-way and re-run +- **THEN** it completes the re-seal without double-sealing rows already migrated +- **AND** the final chain verifies end to end + +#### Scenario: Tombstoned rows survive the re-seal as tombstones +- **WHEN** the chain being re-sealed contains rows purged under retention +- **THEN** those rows are carried forward as declared tombstones +- **AND** the re-sealed chain does not report them as breaks diff --git a/openspec/changes/flow-object-attribution/specs/flow-engine/spec.md b/openspec/changes/flow-object-attribution/specs/flow-engine/spec.md new file mode 100644 index 0000000000..8e48e654df --- /dev/null +++ b/openspec/changes/flow-object-attribution/specs/flow-engine/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: The dispatcher establishes and unconditionally clears the run context around every step @e2e exclude engine-internal execution context; asserted by dispatcher unit tests including the leak-direction test + +Before dispatching a step, the engine SHALL establish an ambient context naming the executing run, the node being dispatched, and the step's sequence number. After the step ends the engine SHALL clear that context, and SHALL do so whether the step completed, stopped, suspended, or threw. + +A context left in place after a step attributes later, unrelated writes to a run that is no longer executing. This failure is silent — the resulting rows are well-formed and look correct — so the clear SHALL be structurally unconditional rather than placed on the success path. + +Nested dispatch SHALL restore the enclosing context rather than clearing it: a sub-flow's steps are attributed to the sub-flow's run, and the parent run's remaining steps are attributed to the parent. + +#### Scenario: The context names the step being dispatched +- **WHEN** the engine dispatches a node +- **THEN** the ambient context names that run, that node id, and that step's sequence + +#### Scenario: A throwing step still clears the context +- **WHEN** a dispatched node throws +- **THEN** the context is cleared before control leaves the dispatcher +- **AND** a write performed afterwards outside any run is unattributed + +#### Scenario: A suspended step clears the context +- **WHEN** a node suspends the run to await a signal or a time +- **THEN** the context is cleared +- **AND** the run's later resumption establishes a fresh context for the step it resumes into + +#### Scenario: A sub-flow does not leak into its parent +- **WHEN** a node dispatches a sub-flow run and that sub-flow completes +- **THEN** writes during the sub-flow are attributed to the sub-flow's run and nodes +- **AND** the parent run's subsequent steps are attributed to the parent run + +### Requirement: A run's history names what each step touched @e2e exclude read model asserted by controller and store tests + +The objects a run touched SHALL be readable alongside the run's step history, so that a reader following a run can see, per node, both what the node did and what it did it to. The step history SHALL remain the record of execution; the attribution SHALL be joined to it by run, node and step rather than duplicated into it. + +#### Scenario: A step's entry can be expanded to the objects it touched +- **WHEN** a reader views a run's step history +- **THEN** each step can be related to the objects attributed to that run and node +- **AND** a step that touched nothing is shown as such rather than omitted + +#### Scenario: The step history remains authoritative for execution +- **WHEN** attribution for a step is absent because no write occurred +- **THEN** the step is still present in the history with its own status and timing diff --git a/openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md b/openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md new file mode 100644 index 0000000000..2d03ff5891 --- /dev/null +++ b/openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md @@ -0,0 +1,90 @@ +## Purpose + +Defines what a flow run records about the objects it touches. A run already records what it DID, step by step; this capability makes it record what it did it TO, so an object can name the run and node that changed it and a run can list everything it changed. + +## ADDED Requirements + +### Requirement: Every object write caused by a flow run is attributed to that run, node and step @e2e exclude write-path attribution asserted by AuditTrailMapper + dispatcher integration tests; no user-facing surface performs the write + +While a flow run is executing a step, every audit-trail row produced by any write SHALL carry the executing run's uuid, the node id, and the step sequence number. The attribution SHALL be ambient — derived from the executing run rather than passed by the writing code — so that a write performed by code with no knowledge of flows, including another app called into by a node, is attributed identically to a write performed by the node itself. + +A row written outside any flow run SHALL carry no attribution: the three values SHALL be absent, and absence SHALL be distinguishable from a run whose identifiers are empty strings. + +#### Scenario: A node's own write is attributed +- **WHEN** a flow run executes a node that writes an object +- **THEN** the resulting audit-trail row names the run uuid, that node's id, and that step's sequence number + +#### Scenario: A write made by another app during the step is attributed +- **WHEN** a node calls into another app, and that app writes an object during the step +- **THEN** the resulting audit-trail row carries the same run, node and step as the node's own writes +- **AND** the writing app is not required to know it is running inside a flow + +#### Scenario: A write outside any run is unattributed +- **WHEN** an object is written by a user action, an import, or a background job with no flow run executing +- **THEN** the resulting audit-trail row has no run, node or step value + +#### Scenario: Attribution does not survive the step that established it +- **WHEN** a flow run finishes, fails, or suspends +- **AND** any object is subsequently written in the same process +- **THEN** that write is unattributed +- **AND** this holds when the step ended by throwing, not only when it ended normally + +#### Scenario: Two runs writing the same object are separately attributed +- **WHEN** two different flow runs each write the same object +- **THEN** each write produces its own audit-trail row naming its own run and node +- **AND** neither row's attribution is overwritten by the other + +### Requirement: A run reports the objects it touched, grouped by node @e2e exclude read surface covered by controller tests; the UI consuming it is specified under flow-engine + +The system SHALL expose the objects a run touched, addressed by run uuid, grouped by the node that touched each. Each entry SHALL name the object, the action performed on it, the node, and the step sequence, so a reader can reconstruct the order in which the run changed things. + +The response SHALL be scoped by the same visibility rule that governs reading the run itself. A caller who may not read a run SHALL NOT learn what it touched. + +#### Scenario: A completed run lists what it changed +- **WHEN** a caller who may read a run requests the objects it touched +- **THEN** the response lists every object the run wrote, grouped by node, in step order + +#### Scenario: A run that touched nothing returns an empty result +- **WHEN** a run completed without writing any object +- **THEN** the response is an empty collection and not an error + +#### Scenario: Visibility is not widened by the new surface +- **WHEN** a caller who may NOT read a run requests the objects it touched +- **THEN** the request is refused +- **AND** the refusal does not reveal whether the run exists or what it touched + +#### Scenario: A suspended run reports what it has touched so far +- **WHEN** a run is suspended awaiting a signal +- **THEN** the objects touched by the steps already executed are reported +- **AND** the result is not withheld until the run completes + +### Requirement: An object reports the flow runs that touched it @e2e exclude query-layer filter; asserted by audit-trail query tests + +The audit-trail query SHALL accept a run uuid as a filter, and audit-trail records SHALL expose their run, node and step values to readers permitted to read them. Reading the history of a single object SHALL therefore answer which run and which node caused each change, without a separate lookup. + +#### Scenario: An object's history names the responsible node +- **WHEN** an object's audit history is read after a flow run changed it +- **THEN** each row caused by the run names that run, the node, and the step + +#### Scenario: Filtering by run returns only that run's writes +- **WHEN** the audit trail is queried filtered by a run uuid +- **THEN** only rows attributed to that run are returned + +#### Scenario: Attribution is visible only to readers of the row +- **WHEN** a caller reads an audit-trail record they are permitted to read +- **THEN** the run, node and step values are present +- **AND** no attribution is disclosed for records the caller may not read + +### Requirement: Attribution outlives the run record but never claims more than it knows @e2e exclude retention interaction; asserted by retention job tests + +The attribution SHALL be stored as a plain identifier stamp rather than a referential link, so that pruning a run under retention does not alter, delete, or invalidate the audit rows it caused. A stamp naming a run that no longer exists SHALL remain readable as a historical fact. + +#### Scenario: Pruning a run leaves its attribution intact +- **WHEN** flow-run retention deletes a run and its steps +- **THEN** audit rows attributed to that run keep their run, node and step values +- **AND** the hash chain over those rows still verifies + +#### Scenario: A dangling run reference reads as history, not as an error +- **WHEN** an object's history names a run that has since been pruned +- **THEN** the reader is shown the recorded identifiers +- **AND** the response does not fail because the run cannot be resolved diff --git a/openspec/changes/flow-object-attribution/tasks.md b/openspec/changes/flow-object-attribution/tasks.md new file mode 100644 index 0000000000..c3f523bfc4 --- /dev/null +++ b/openspec/changes/flow-object-attribution/tasks.md @@ -0,0 +1,51 @@ +## 1. Storage + +- [ ] 1.1 Add `flow_run`, `flow_node`, `flow_step` (nullable) plus a `flow_run` index to `oc_openregister_audit_trails` in a new `lib/Migration/Version1Date*.php`; verify `occ migrations:execute` applies and the columns exist on MySQL, PostgreSQL and SQLite +- [ ] 1.2 Add the three fields to `AuditTrail` with getters/setters and `addType()` registration; verify a hydrated row round-trips the values through the mapper + +## 2. Ambient run context + +- [ ] 2.1 Add `lib/Service/Flow/FlowRunContext.php` holding a STACK of `(runUuid, nodeId, sequence)` frames with `push()`, `pop()` and `current()`; verify unit tests cover empty, single and nested frames +- [ ] 2.2 Push in `RegistryStepDispatcher::dispatch()` before the step and pop in a `finally`; verify a test asserts the context is empty after a step that THROWS, not only after one that succeeds +- [ ] 2.3 Verify the leak direction end to end: run a flow, then perform a non-flow object write in the same process, and assert the resulting audit row's three columns are null — this test must fail if the `finally` is removed +- [ ] 2.4 Verify the two cross-run isolation cases: a sub-flow restores its parent frame (its writes name the sub-flow, the parent's next step names the parent), and two runs advanced sequentially by one `FlowRunWorker` process each attribute to their own run uuid + +## 3. Stamping + +- [ ] 3.1 Read `FlowRunContext::current()` in `AuditTrailMapper::buildAuditTrail()` and stamp the three fields; verify BOTH `createAuditTrail()` and the batched `insertAuditTrails()` produce stamped rows from the one change +- [ ] 3.2 Apply the same stamp in `createAuditTrailEntry()` and `createToolInvocationEntry()`; verify a retention entry written during a run is attributed, and that a write made by a leaf app called from inside a node is stamped identically to the node's own write without that app referencing any flow API + +## 4. Hash chain (ADR-003 Rule 4) + +- [ ] 4.1 Add the three keys to `AuditTrail::jsonSerialize()` and move `GENESIS_SEED` to `openregister-genesis-v2`; verify a freshly seeded chain verifies end to end under v2 +- [ ] 4.2 Add `AuditCanonicalV1` — a frozen private copy of the v1 key list and canonicalisation rules, marked never-to-be-updated; verify it reproduces the stored hash of a row sealed before this change +- [ ] 4.3 Verify tampering with `flow_run`, `flow_node` or `flow_step` on a sealed row makes `verifyChain()` report a break at that row + +## 5. Verify-then-rechain migration + +- [ ] 5.1 Add the repair step (pre-verify against `AuditCanonicalV1` → persist verdict as a v1-sealed `audit.rechain.preverify` row → `rechainAll()` under v2) AND register it in `appinfo/info.xml` in the same commit; verify `occ maintenance:repair` runs it and the registration is present +- [ ] 5.2 Verify the pre-check discriminates: seed a chain, tamper with one row, run the migration, and assert the persisted verdict names that row — the test must fail if the pre-check is stubbed to return valid +- [ ] 5.3 Verify the migration's three safety properties: it refuses to start when it cannot persist its verdict, it is resumable and idempotent when interrupted mid-re-seal, and a chain containing retention tombstones re-seals with those rows carried forward as tombstones rather than reported as breaks + +## 6. Read surfaces + +- [ ] 6.1 Add `GET /api/flow-runs/{uuid}/objects` returning objects grouped by node with action and step, reusing the visibility rule `FlowRunController::resume()` applies; verify a caller who may not read the run is refused without learning the run exists, a suspended run reports what it has touched so far, and a run that wrote nothing returns an empty collection rather than an error +- [ ] 6.2 Add a `flowRun` filter to the audit-trail query and expose the three fields in the audit-trail serialisation; verify filtering returns only that run's rows and a pruned run's rows still read + +## 7. Frontend + +- [ ] 7.1 Show both directions — the objects a run touched on the run detail (grouped by node, joined to the existing step history) and the runs that touched an object in its sidebar; verify a step that touched nothing still renders with its status and timing, and a pruned run renders its recorded identifiers instead of failing + +## 8. Quality + +- [ ] 8.1 Run `composer check:strict` and `npm run lint`, and fix any pre-existing findings touched by these files +- [ ] 8.2 Mutation-check the two guards that fail silently — the `finally` pop (2.3) and the pre-verify discrimination (5.2): disable each, confirm the suite goes red, restore, confirm the source is byte-identical + +**Acceptance criteria** + +- Every audit row written during a run names the run, node and step; every row written outside one names none. +- A write by an app that has never heard of flows is attributed identically to a node's own write. +- `verifyChain()` returns `valid: true` over the full table after the migration, and `valid: false` when any attribution value is altered. +- The pre-migration verdict is readable after the re-seal, and states where the chain stood before it. +- No `@spec` tag is missing on a changed method; `@e2e exclude` reasons are carried on the backend-only scenarios. +- i18n: new frontend strings go through `t()`; no hardcoded user-facing text. diff --git a/openspec/specs/audit-hash-chain/spec.md b/openspec/specs/audit-hash-chain/spec.md index e4338d4aeb..8f92614f61 100644 --- a/openspec/specs/audit-hash-chain/spec.md +++ b/openspec/specs/audit-hash-chain/spec.md @@ -1,5 +1,5 @@ --- -status: done +status: in-progress --- # audit-hash-chain Specification @@ -23,6 +23,13 @@ Cryptographic SHA-256 hash chaining on audit trail entries with genesis hash, ve for the backlog cursor and a cutover marker so `verifyChain()` stops reporting `valid: true` over rows it silently skipped. +- `flow-object-attribution` (active) — the canonical form gains the three flow + attribution fields so a row's run/node/step is hash-covered, the genesis seed + moves to `openregister-genesis-v2`, and a seed change is defined as a + verify-then-rechain migration: the outgoing canonicaliser is frozen in the + codebase and used for the pre-check, whose verdict is persisted before the + re-seal makes the prior state underivable. + ## Requirements ### Requirement: Every audit trail entry MUST include a SHA-256 hash chained to the previous entry Each audit trail entry MUST contain a `hash` field computed as `SHA-256(previous_hash + canonical_json(entry_data))`. The `previous_hash` field links to the preceding entry's hash, forming a tamper-evident chain. diff --git a/openspec/specs/flow-engine/spec.md b/openspec/specs/flow-engine/spec.md index 78610717e2..462a550c82 100644 --- a/openspec/specs/flow-engine/spec.md +++ b/openspec/specs/flow-engine/spec.md @@ -9,6 +9,8 @@ status: in-progress - `or-delegation-grants` (active) — turns a DECLARED acting identity into an AUTHORIZED one: a delegation grant record with a consent lifecycle, refusal at save and at every fire when the author holds no grant for the user they named, and an `awaiting_consent` run state deduped on (principal, actingAs, scope) (ADR-099). +- `flow-object-attribution` (active) — the dispatcher establishes an ambient run context before every step and clears it unconditionally afterwards, including when the step throws or suspends, so every write caused by a step is attributed to that run and node; a sub-flow restores its parent's frame rather than clearing it; and a run can report the objects it touched alongside its step history. + ## Purpose Defines how OpenRegister reads a flow document and turns it into a run: what carries behaviour (the node), what carries sequence (the edge), when converging paths merge versus synchronise, how a path is allowed to end, and what a flow records about its own runnability and its last run. This is the fleet's single flow engine (ADR-065) — openconnector and hermiq contribute node types to it and do not implement their own. diff --git a/tests/Unit/Db/AuditFlowAttributionTest.php b/tests/Unit/Db/AuditFlowAttributionTest.php new file mode 100644 index 0000000000..50cc50486c --- /dev/null +++ b/tests/Unit/Db/AuditFlowAttributionTest.php @@ -0,0 +1,130 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +namespace Unit\Db; + +use OCA\OpenRegister\Db\AuditFlowAttribution; +use OCA\OpenRegister\Db\AuditTrail; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCP\IDBConnection; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use RuntimeException; + +class AuditFlowAttributionTest extends TestCase { + + /** + * A stamper whose container yields whatever is given. + * + * @param mixed $resolved What the container returns for FlowRunContext, + * or an exception instance to throw. + * + * @return AuditFlowAttribution The stamper. + */ + private function stamper(mixed $resolved): AuditFlowAttribution { + $container = $this->createMock(ContainerInterface::class); + + if ($resolved instanceof \Throwable) { + $container->method('get')->willThrowException($resolved); + } else { + $container->method('get')->willReturn($resolved); + } + + return new AuditFlowAttribution($this->createMock(IDBConnection::class), $container); + }//end stamper() + + public function testAnExecutingStepIsStampedOntoTheRow(): void { + $context = new FlowRunContext(); + $context->push(runUuid: 'run-abc', nodeId: 'node-1', sequence: 7); + + $row = new AuditTrail(); + $this->stamper($context)->apply(auditTrail: $row); + + $this->assertSame('run-abc', $row->getFlowRun()); + $this->assertSame('node-1', $row->getFlowNode()); + $this->assertSame(7, $row->getFlowStep()); + }//end testAnExecutingStepIsStampedOntoTheRow() + + /** + * 🔴 Outside a run the row carries NO attribution. + * + * Null is the only way to say "no run" — a run uuid is never an empty + * string, so an empty one would be a claim rather than an absence. + */ + public function testARowWrittenOutsideAnyRunIsUnattributed(): void { + $row = new AuditTrail(); + $this->stamper(new FlowRunContext())->apply(auditTrail: $row); + + $this->assertNull($row->getFlowRun()); + $this->assertNull($row->getFlowNode()); + $this->assertNull($row->getFlowStep()); + }//end testARowWrittenOutsideAnyRunIsUnattributed() + + /** + * An unresolvable context leaves the row unattributed rather than failing. + * + * An audit row is evidence and must survive a bookkeeping problem; an + * unattributed row is honest about what it does not know. + */ + public function testAnUnresolvableContextDoesNotPreventTheRow(): void { + $row = new AuditTrail(); + $this->stamper(new RuntimeException('no such service'))->apply(auditTrail: $row); + + $this->assertNull($row->getFlowRun()); + }//end testAnUnresolvableContextDoesNotPreventTheRow() + + /** + * Something that is not a run context is not trusted to be one. + */ + public function testAWrongTypeFromTheContainerIsIgnored(): void { + $row = new AuditTrail(); + $this->stamper(new \stdClass())->apply(auditTrail: $row); + + $this->assertNull($row->getFlowRun()); + }//end testAWrongTypeFromTheContainerIsIgnored() + + /** + * The INNERMOST frame wins, so a sub-flow's writes are its own. + */ + public function testTheInnermostFrameIsTheOneStamped(): void { + $context = new FlowRunContext(); + $context->push(runUuid: 'parent', nodeId: 'call-sub', sequence: 1); + $context->push(runUuid: 'child', nodeId: 'child-node', sequence: 0); + + $row = new AuditTrail(); + $this->stamper($context)->apply(auditTrail: $row); + + $this->assertSame('child', $row->getFlowRun()); + }//end testTheInnermostFrameIsTheOneStamped() + + /** + * Stamping twice is idempotent — the builder and the insert path both call + * it, and they must not disagree. + */ + public function testStampingTwiceWritesTheSameValues(): void { + $context = new FlowRunContext(); + $context->push(runUuid: 'run-1', nodeId: 'n', sequence: 3); + + $stamper = $this->stamper($context); + $row = new AuditTrail(); + + $stamper->apply(auditTrail: $row); + $stamper->apply(auditTrail: $row); + + $this->assertSame('run-1', $row->getFlowRun()); + $this->assertSame(3, $row->getFlowStep()); + }//end testStampingTwiceWritesTheSameValues() +}//end class diff --git a/tests/Unit/Repair/RechainAuditTrailForFlowAttributionTest.php b/tests/Unit/Repair/RechainAuditTrailForFlowAttributionTest.php new file mode 100644 index 0000000000..1424296a1d --- /dev/null +++ b/tests/Unit/Repair/RechainAuditTrailForFlowAttributionTest.php @@ -0,0 +1,233 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + +namespace Unit\Repair; + +use OCA\OpenRegister\Repair\RechainAuditTrailForFlowAttribution; +use OCA\OpenRegister\Service\AuditHashService; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IAppConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; +use RuntimeException; + +class RechainAuditTrailForFlowAttributionTest extends TestCase { + + /** + * An app-config double backed by a plain array. + * + * @param boolean $writable Whether writes are kept. False models a config + * backend that silently drops them. + * + * @return IAppConfig The config double. + */ + private function config(bool $writable = true, array &$store = []): IAppConfig { + $config = $this->createMock(IAppConfig::class); + + // A regular closure with `use (&$store)`, NOT an arrow function: `fn` + // captures by VALUE at definition time, so the read-back would see the + // store as it was before any write. That mistake made this double report + // a dropped write on every call — which is exactly what the code under + // test refuses on, so the first run of these tests failed for a reason + // that had nothing to do with the code. + $config->method('getValueString')->willReturnCallback( + function (string $app, string $key, string $default = '') use (&$store): string { + return ($store[$key] ?? $default); + } + ); + + $config->method('setValueString')->willReturnCallback( + static function (string $app, string $key, string $value) use (&$store, $writable): bool { + if ($writable === true) { + $store[$key] = $value; + } + + return true; + } + ); + + return $config; + }//end config() + + /** + * A database double whose audit-trail query returns no rows. + * + * An empty table is the right fixture for these tests: the ORDERING and the + * REFUSALS are what is under test, and they do not depend on there being + * rows. Chain-walking itself is covered by the AuditHashService suites. + * + * @return IDBConnection The database double. + */ + private function emptyDb(): IDBConnection { + $result = $this->createMock(\OCP\DB\IResult::class); + $result->method('fetchAll')->willReturn([]); + + $expr = $this->createMock(\OCP\DB\QueryBuilder\IExpressionBuilder::class); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('setMaxResults')->willReturnSelf(); + $qb->method('expr')->willReturn($expr); + $qb->method('createNamedParameter')->willReturn(':p'); + $qb->method('executeQuery')->willReturn($result); + + $db = $this->createMock(IDBConnection::class); + $db->method('getQueryBuilder')->willReturn($qb); + + return $db; + }//end emptyDb() + + public function testItRecordsAVerdictAndThenReSeals(): void { + $store = []; + $hashes = $this->createMock(AuditHashService::class); + $hashes->expects($this->once())->method('rechainAll') + ->willReturn(['rechained' => 3, 'tombstonesPreserved' => 1]); + + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(store: $store), + $hashes, + new NullLogger() + ); + + $step->run($this->createMock(IOutput::class)); + + $this->assertArrayHasKey( + RechainAuditTrailForFlowAttribution::VERDICT_KEY, + $store, + 'The state of the chain before the re-seal is the one fact the re-seal destroys.' + ); + $this->assertArrayHasKey(RechainAuditTrailForFlowAttribution::DONE_KEY, $store); + + $verdict = json_decode($store[RechainAuditTrailForFlowAttribution::VERDICT_KEY], true); + $this->assertSame('openregister-genesis-v1', $verdict['seedFrom']); + $this->assertSame('openregister-genesis-v2', $verdict['seedTo']); + }//end testItRecordsAVerdictAndThenReSeals() + + /** + * 🔴 NO VERDICT, NO RE-CHAIN. + * + * A re-chain that leaves no account of the chain it replaced has no remedy, + * so this is the one refusal that must hold even at the cost of blocking an + * upgrade. + */ + public function testItRefusesToReSealWhenTheVerdictCannotBeStored(): void { + $store = []; + $hashes = $this->createMock(AuditHashService::class); + $hashes->expects($this->never())->method('rechainAll'); + + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(writable: false, store: $store), + $hashes, + new NullLogger() + ); + + $output = $this->createMock(IOutput::class); + $output->expects($this->atLeastOnce())->method('warning'); + + $step->run($output); + + $this->assertArrayNotHasKey(RechainAuditTrailForFlowAttribution::DONE_KEY, $store); + }//end testItRefusesToReSealWhenTheVerdictCannotBeStored() + + /** + * 🔴 It does not run twice. + * + * After the first pass every row is sealed under v2, so a second pre-verify + * would compare v2 rows against the v1 form, report a false break, and + * overwrite the real verdict with a meaningless one. + */ + public function testASecondRunIsANoOp(): void { + $store = [RechainAuditTrailForFlowAttribution::DONE_KEY => '2026-08-28T00:00:00+00:00']; + + $hashes = $this->createMock(AuditHashService::class); + $hashes->expects($this->never())->method('rechainAll'); + + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(store: $store), + $hashes, + new NullLogger() + ); + + $output = $this->createMock(IOutput::class); + $output->expects($this->once())->method('info'); + + $step->run($output); + }//end testASecondRunIsANoOp() + + /** + * A failure inside the re-seal must not leave the step marked done. + * + * Otherwise the next `occ maintenance:repair` skips it, and the table is + * left half-sealed with nothing left to finish it. + */ + public function testAFailedReSealDoesNotMarkTheStepDone(): void { + $store = []; + $hashes = $this->createMock(AuditHashService::class); + $hashes->method('rechainAll')->willThrowException(new RuntimeException('lock unavailable')); + + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(store: $store), + $hashes, + new NullLogger() + ); + + try { + $step->run($this->createMock(IOutput::class)); + } catch (RuntimeException $e) { + // The step does not swallow it; maintenance:repair reports it. + } + + $this->assertArrayNotHasKey( + RechainAuditTrailForFlowAttribution::DONE_KEY, + $store, + 'A half-finished re-seal must not look complete.' + ); + $this->assertArrayHasKey( + RechainAuditTrailForFlowAttribution::VERDICT_KEY, + $store, + 'The verdict is written first and survives the failure.' + ); + }//end testAFailedReSealDoesNotMarkTheStepDone() + + public function testItNamesItselfForTheRepairRunner(): void { + $step = new RechainAuditTrailForFlowAttribution( + $this->emptyDb(), + $this->config(), + $this->createMock(AuditHashService::class), + new NullLogger() + ); + + $this->assertStringContainsString('audit chain', strtolower($step->getName())); + }//end testItNamesItselfForTheRepairRunner() +}//end class diff --git a/tests/Unit/Service/AuditCanonicalV1Test.php b/tests/Unit/Service/AuditCanonicalV1Test.php new file mode 100644 index 0000000000..0434a0d4f0 --- /dev/null +++ b/tests/Unit/Service/AuditCanonicalV1Test.php @@ -0,0 +1,134 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/audit-hash-chain/spec.md + */ + +namespace Unit\Service; + +use OCA\OpenRegister\Db\AuditTrail; +use OCA\OpenRegister\Service\AuditCanonicalV1; +use PHPUnit\Framework\TestCase; + +class AuditCanonicalV1Test extends TestCase { + + /** + * A row with a couple of fields set and everything else at its default. + */ + private function row(): AuditTrail { + $entry = new AuditTrail(); + $entry->setId(42); + $entry->setAction('create'); + + return $entry; + }//end row() + + /** + * 🔒 THE FREEZE. The exact bytes a v1 row was hashed over. + * + * If this assertion fails, the v1 form has moved and every hash sealed under + * seed v1 has become unverifiable. The correct response is to restore the + * old behaviour, NOT to update the expectation — updating it is how the + * check silently stops meaning anything. + */ + public function testTheV1CanonicalFormIsFrozen(): void { + $expected = '{"action":"create","changed":null,"confidentiality":null,"created":null,' + . '"expires":null,"id":42,"ipAddress":null,"object":null,"objectUuid":null,' + . '"organisationId":null,"organisationIdType":null,"paramsDigest":null,' + . '"processingActivityId":null,"processingActivityUrl":null,"processingId":null,' + . '"register":null,"registerUuid":null,"request":null,"resultSummary":null,' + . '"retentionPeriod":null,"schema":null,"schemaUuid":null,"session":null,' + . '"size":null,"toolId":null,"user":null,"userName":null,"uuid":null,"version":null}'; + + $this->assertSame($expected, AuditCanonicalV1::canonicalJson(entry: $this->row())); + }//end testTheV1CanonicalFormIsFrozen() + + /** + * The v1 form does not contain the fields v2 added. + * + * Stated separately from the freeze above because it is the specific + * regression this class was written to prevent, and a separate failure + * message says so directly. + */ + public function testTheV1FormExcludesTheFlowAttributionFields(): void { + $canonical = AuditCanonicalV1::canonicalJson(entry: $this->row()); + + $this->assertStringNotContainsString('flowRun', $canonical); + $this->assertStringNotContainsString('flowNode', $canonical); + $this->assertStringNotContainsString('flowStep', $canonical); + }//end testTheV1FormExcludesTheFlowAttributionFields() + + /** + * 🔑 THE PROPERTY THE MIGRATION DEPENDS ON. + * + * Two rows differing ONLY in flow attribution must canonicalise identically + * under v1. That is what makes a pre-existing row — which has no attribution + * — verify against a hash sealed before the columns existed. + */ + public function testAttributionDoesNotChangeTheV1Form(): void { + $without = $this->row(); + + $with = $this->row(); + $with->setFlowRun('run-abc'); + $with->setFlowNode('node-1'); + $with->setFlowStep(3); + + $this->assertSame( + AuditCanonicalV1::canonicalJson(entry: $without), + AuditCanonicalV1::canonicalJson(entry: $with), + 'The v1 form must be blind to fields v2 introduced, or no pre-existing row can verify.' + ); + }//end testAttributionDoesNotChangeTheV1Form() + + /** + * A change to any v1 field DOES change the form — the canonicaliser is + * blind to the new fields, not blind in general. + */ + public function testAV1FieldStillChangesTheForm(): void { + $before = AuditCanonicalV1::canonicalJson(entry: $this->row()); + + $changed = $this->row(); + $changed->setAction('delete'); + + $this->assertNotSame($before, AuditCanonicalV1::canonicalJson(entry: $changed)); + }//end testAV1FieldStillChangesTheForm() + + /** + * The v1 genesis seed is the v1 seed, not the current one. + */ + public function testTheGenesisHashIsTheV1Seed(): void { + $this->assertSame('openregister-genesis-v1', AuditCanonicalV1::GENESIS_SEED); + $this->assertSame( + hash('sha256', 'openregister-genesis-v1'), + AuditCanonicalV1::genesisHash() + ); + }//end testTheGenesisHashIsTheV1Seed() + + /** + * Hashing chains the predecessor in, so the same row after a different + * predecessor hashes differently. + */ + public function testTheHashChainsThePredecessor(): void { + $row = $this->row(); + + $this->assertNotSame( + AuditCanonicalV1::computeHash(entry: $row, previousHash: 'aaa'), + AuditCanonicalV1::computeHash(entry: $row, previousHash: 'bbb') + ); + }//end testTheHashChainsThePredecessor() +}//end class diff --git a/tests/Unit/Service/AuditHashServiceTest.php b/tests/Unit/Service/AuditHashServiceTest.php index 8f00e691ec..fd910d9239 100644 --- a/tests/Unit/Service/AuditHashServiceTest.php +++ b/tests/Unit/Service/AuditHashServiceTest.php @@ -36,12 +36,26 @@ protected function setUp(): void { ); } + /** + * The chain's seed is v2. + * + * Moved from v1 when the flow-attribution fields joined the canonical JSON + * (ADR-003 Rule 4: the seed and the canonical form are one identity and are + * versioned together). The v1 value is asserted to be DIFFERENT rather than + * simply dropped, so a revert of the seed — which would silently make every + * re-sealed row unverifiable — fails here instead of in production. + */ public function testGetGenesisHash(): void { - $expected = hash('sha256', 'openregister-genesis-v1'); + $expected = hash('sha256', 'openregister-genesis-v2'); $result = $this->service->getGenesisHash(); $this->assertSame($expected, $result); $this->assertSame(64, strlen($result)); + $this->assertNotSame( + hash('sha256', 'openregister-genesis-v1'), + $result, + 'The seed must not fall back to v1: rows re-sealed under v2 would stop verifying.' + ); } public function testGetGenesisHashIsConsistent(): void { diff --git a/tests/Unit/Service/Flow/FlowEngineAttributionTest.php b/tests/Unit/Service/Flow/FlowEngineAttributionTest.php new file mode 100644 index 0000000000..21d5ff05a1 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowEngineAttributionTest.php @@ -0,0 +1,275 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/flow-engine/spec.md + */ + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Service\Flow\FlowDefinitionBuilder; +use OCA\OpenRegister\Service\Flow\FlowEngine; +use OCA\OpenRegister\Service\Flow\FlowItems; +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use OCA\OpenRegister\Service\Flow\FlowStepDispatcher; +use OCA\OpenRegister\Service\Flow\FlowStop; +use OCA\OpenRegister\Service\Flow\FlowSuspension; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; + +/** + * A subject whose marking is a plain property. + */ +class AttributionSubject { + + public $marking = []; +}//end class + +/** + * Reads the ambient frame at the moment each step runs, then optionally fails. + * + * Sampling INSIDE the step is the point: the frame has to be correct while the + * write would be happening, not merely at some point during the run. + */ +class AttributionSamplingDispatcher implements FlowStepDispatcher { + + /** + * The frame observed during each step, keyed by step id. + */ + public array $frames = []; + + public function __construct( + private readonly FlowRunContext $context, + private readonly ?string $failOn = null, + private readonly ?string $throwKind = null, + ) { + }//end __construct() + + public function dispatch(array $step, array $items, array $context): array { + $name = (string)($step['id'] ?? ''); + $this->frames[$name] = $this->context->current(); + + if ($this->failOn !== null && $name === $this->failOn) { + if ($this->throwKind === 'stop') { + throw new FlowStop('stopped on purpose'); + } + + if ($this->throwKind === 'suspend') { + // Named argument: the first positional parameter is the resume + // DateTime, not the reason. + throw new FlowSuspension(reason: 'waiting on purpose'); + } + + throw new RuntimeException('step blew up'); + } + + $out = []; + foreach ($items as $index => $item) { + $out[] = FlowItems::item(json: (array)($item['json'] ?? []), binary: [], fromItemIndex: $index); + } + + return $out; + }//end dispatch() +}//end class + +class FlowEngineAttributionTest extends TestCase { + + private FlowRunContext $context; + + private FlowEngine $engine; + + protected function setUp(): void { + $this->context = new FlowRunContext(); + $this->engine = new FlowEngine( + new FlowDefinitionBuilder(), + $this->createMock(LoggerInterface::class), + null, + null, + null, + $this->context + ); + }//end setUp() + + /** + * A two-node flow. + */ + private function linearFlow(): array { + return [ + 'id' => 'linear', + 'nodes' => [ + ['id' => 'first', 'type' => 'test.step'], + ['id' => 'second', 'type' => 'test.step'], + ], + 'edges' => [['id' => 'first-second', 'from' => 'first', 'to' => 'second']], + ]; + }//end linearFlow() + + private function runFlow(array $flow, FlowStepDispatcher $dispatcher, array $context = []): array { + return $this->engine->run( + $flow, + new MethodMarkingStore(false, 'marking'), + new AttributionSubject(), + $dispatcher, + $context + ); + }//end runFlow() + + /** + * The attribution context the engine is handed for a run. + */ + private function attributionContext(string $run = 'run-abc', int $base = 0): array { + return [ + FlowRunContext::CONTEXT_RUN => $run, + FlowRunContext::CONTEXT_BASE => $base, + ]; + }//end attributionContext() + + /** + * Each step sees its OWN node id and its own step number. + */ + public function testEachStepSeesItsOwnFrame(): void { + $dispatcher = new AttributionSamplingDispatcher(context: $this->context); + + $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertSame( + ['run' => 'run-abc', 'node' => 'first', 'step' => 0], + $dispatcher->frames['first'] + ); + $this->assertSame( + ['run' => 'run-abc', 'node' => 'second', 'step' => 1], + $dispatcher->frames['second'] + ); + }//end testEachStepSeesItsOwnFrame() + + /** + * The step number continues from the base, so a resumed run keeps numbering + * where it stopped instead of restarting and colliding with its own history. + */ + public function testStepNumbersContinueFromTheBase(): void { + $dispatcher = new AttributionSamplingDispatcher(context: $this->context); + + $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext(base: 12)); + + $this->assertSame(12, $dispatcher->frames['first']['step']); + $this->assertSame(13, $dispatcher->frames['second']['step']); + }//end testStepNumbersContinueFromTheBase() + + /** + * 🔴 THE LEAK TEST. Nothing is attributed once the run is over. + * + * Delete the `finally` in FlowEngine and this is the assertion that goes + * red. Every other test in this file still passes. + */ + public function testNothingIsAttributedAfterASuccessfulRun(): void { + $dispatcher = new AttributionSamplingDispatcher(context: $this->context); + + $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertNull( + $this->context->current(), + 'A write after the run must be unattributed; a standing frame files it under a finished run.' + ); + $this->assertSame(0, $this->context->depth()); + }//end testNothingIsAttributedAfterASuccessfulRun() + + /** + * 🔴 A THROWING step still clears its frame. + * + * The failure path is the one where a success-path pop would have been + * skipped, so this is where a leak would actually happen in production. + */ + public function testAThrowingStepStillClearsItsFrame(): void { + $dispatcher = new AttributionSamplingDispatcher( + context: $this->context, + failOn: 'first' + ); + + $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testAThrowingStepStillClearsItsFrame() + + /** + * 🔴 A stopped run leaves nothing behind. A Stop returns from INSIDE the + * catch, so only a `finally` pops it. + */ + public function testAStoppedRunClearsItsFrame(): void { + $dispatcher = new AttributionSamplingDispatcher( + context: $this->context, + failOn: 'first', + throwKind: 'stop' + ); + + $result = $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertSame(FlowEngine::STATUS_STOPPED, $result['status']); + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testAStoppedRunClearsItsFrame() + + /** + * 🔴 A suspended run leaves nothing behind either — and this is the one that + * matters most in practice, because a suspended run is precisely the one + * whose process goes on to do other things. + */ + public function testASuspendedRunClearsItsFrame(): void { + $dispatcher = new AttributionSamplingDispatcher( + context: $this->context, + failOn: 'first', + throwKind: 'suspend' + ); + + $result = $this->runFlow($this->linearFlow(), $dispatcher, $this->attributionContext()); + + $this->assertSame(FlowEngine::STATUS_SUSPENDED, $result['status']); + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testASuspendedRunClearsItsFrame() + + /** + * Two runs walked in sequence — as FlowRunWorker does — attribute to + * themselves and not to their predecessor. + */ + public function testASecondRunIsNotAttributedToTheFirst(): void { + $first = new AttributionSamplingDispatcher(context: $this->context); + $this->runFlow($this->linearFlow(), $first, $this->attributionContext(run: 'run-1')); + + $second = new AttributionSamplingDispatcher(context: $this->context); + $this->runFlow($this->linearFlow(), $second, $this->attributionContext(run: 'run-2')); + + $this->assertSame('run-1', $first->frames['first']['run']); + $this->assertSame('run-2', $second->frames['first']['run']); + $this->assertNull($this->context->current()); + }//end testASecondRunIsNotAttributedToTheFirst() + + /** + * A run with no attribution context attributes nothing — the flow tester and + * the node unit tests dispatch this way, and must not crash or invent a run. + */ + public function testARunWithoutAttributionContextAttributesNothing(): void { + $dispatcher = new AttributionSamplingDispatcher(context: $this->context); + + $this->runFlow($this->linearFlow(), $dispatcher); + + $this->assertNull($dispatcher->frames['first']); + $this->assertNull($this->context->current()); + }//end testARunWithoutAttributionContextAttributesNothing() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunAssigneeTest.php b/tests/Unit/Service/Flow/FlowRunAssigneeTest.php new file mode 100644 index 0000000000..74559aaca9 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowRunAssigneeTest.php @@ -0,0 +1,154 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/flow-engine/spec.md#requirement-a-run-suspended-on-an-external-signal-must-be-reachable + */ + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Service\Flow\FlowResumeState; +use OCA\OpenRegister\Service\Flow\FlowRunAssignee; +use OCP\IGroupManager; +use PHPUnit\Framework\TestCase; + +class FlowRunAssigneeTest extends TestCase { + + /** + * A run whose resume slots are exactly as given. + * + * @param array $slots The per-node resume slots. + * + * @return FlowRun The run. + */ + private function runWithSlots(array $slots): FlowRun { + $run = new FlowRun(); + $run->setContext([FlowResumeState::CONTEXT_KEY => $slots]); + + return $run; + }//end runWithSlots() + + public function testAnUnaskedSlotNamesNobody(): void { + // A slot with no askedAt has not asked anything, so its assignee is not + // the person currently being waited on. + $run = $this->runWithSlots(['node-a' => ['assignee' => 'alice']]); + + $this->assertSame('', (new FlowRunAssignee())->recordedFor(run: $run)); + }//end testAnUnaskedSlotNamesNobody() + + public function testTheAskingSlotNamesItsAssignee(): void { + $run = $this->runWithSlots([ + 'node-a' => ['assignee' => 'alice'], + 'node-b' => ['askedAt' => '2026-08-28T10:00:00+00:00', 'assignee' => 'bob'], + ]); + + $this->assertSame('bob', (new FlowRunAssignee())->recordedFor(run: $run)); + }//end testTheAskingSlotNamesItsAssignee() + + public function testTheAssigneeMayAnswer(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'bob']]); + + $this->assertTrue((new FlowRunAssignee())->mayAnswer(run: $run, uid: 'bob')); + }//end testTheAssigneeMayAnswer() + + public function testSomebodyElseMayNot(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'bob']]); + + $this->assertFalse((new FlowRunAssignee())->mayAnswer(run: $run, uid: 'carol')); + }//end testSomebodyElseMayNot() + + /** + * 🔴 The unassigned case is deliberately OPEN. + * + * Webhook and child-run signals are not human decisions and record no + * assignee. An implementation that fails closed here would break every one + * of them — and would do it while looking more secure. + */ + public function testAnUnassignedStepIsAnswerableByAnyone(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now']]); + + $assignee = new FlowRunAssignee(); + + $this->assertTrue($assignee->mayAnswer(run: $run, uid: 'anyone')); + $this->assertTrue($assignee->mayAnswer(run: $run, uid: null)); + }//end testAnUnassignedStepIsAnswerableByAnyone() + + /** + * 🔴 An ASSIGNED step is never anonymous. + */ + public function testAnAssignedStepRefusesAnAnonymousCaller(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'bob']]); + + $assignee = new FlowRunAssignee(); + + $this->assertFalse($assignee->mayAnswer(run: $run, uid: null)); + $this->assertFalse($assignee->mayAnswer(run: $run, uid: '')); + }//end testAnAssignedStepRefusesAnAnonymousCaller() + + /** + * 🔴 THE GROUP BRANCH. A group-assigned step admits its members. + */ + public function testAGroupMemberMayAnswerAGroupAssignedStep(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'behandelaars']]); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('isInGroup')->with('carol', 'behandelaars')->willReturn(true); + + $this->assertTrue( + (new FlowRunAssignee(groupManager: $groups))->mayAnswer(run: $run, uid: 'carol') + ); + }//end testAGroupMemberMayAnswerAGroupAssignedStep() + + public function testANonMemberMayNot(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'behandelaars']]); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('isInGroup')->willReturn(false); + + $this->assertFalse( + (new FlowRunAssignee(groupManager: $groups))->mayAnswer(run: $run, uid: 'carol') + ); + }//end testANonMemberMayNot() + + /** + * With no group manager the group branch REFUSES rather than admits. + * + * Asserted explicitly so the fail-closed direction is a stated property + * rather than something inferred from a passing suite elsewhere. + */ + public function testWithoutAGroupManagerAGroupAssignmentRefuses(): void { + $run = $this->runWithSlots(['n' => ['askedAt' => 'now', 'assignee' => 'behandelaars']]); + + $this->assertFalse((new FlowRunAssignee())->mayAnswer(run: $run, uid: 'carol')); + }//end testWithoutAGroupManagerAGroupAssignmentRefuses() + + public function testARunWithNoSlotsNamesNobodyAndIsOpen(): void { + $run = new FlowRun(); + + $assignee = new FlowRunAssignee(); + + $this->assertSame('', $assignee->recordedFor(run: $run)); + $this->assertTrue($assignee->mayAnswer(run: $run, uid: 'anyone')); + }//end testARunWithNoSlotsNamesNobodyAndIsOpen() +}//end class diff --git a/tests/Unit/Service/Flow/FlowRunContextTest.php b/tests/Unit/Service/Flow/FlowRunContextTest.php new file mode 100644 index 0000000000..c5971e8269 --- /dev/null +++ b/tests/Unit/Service/Flow/FlowRunContextTest.php @@ -0,0 +1,135 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Service\Flow\FlowRunContext; +use PHPUnit\Framework\TestCase; + +class FlowRunContextTest extends TestCase { + + private FlowRunContext $context; + + protected function setUp(): void { + $this->context = new FlowRunContext(); + }//end setUp() + + /** + * Outside any run there is nothing to attribute to. + */ + public function testEmptyStackAttributesNothing(): void { + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testEmptyStackAttributesNothing() + + /** + * A pushed frame is what a write is filed under. + */ + public function testCurrentReturnsThePushedFrame(): void { + $this->context->push(runUuid: 'run-a', nodeId: 'node-1', sequence: 7); + + $this->assertSame( + ['run' => 'run-a', 'node' => 'node-1', 'step' => 7], + $this->context->current() + ); + }//end testCurrentReturnsThePushedFrame() + + /** + * Popping restores the ENCLOSING frame, not emptiness. + * + * This is the sub-flow case. If pop cleared instead of restoring, a parent + * run's remaining steps would silently stop being attributed the moment it + * called a sub-flow — and nothing about the resulting rows would look wrong. + */ + public function testNestedPopRestoresTheParentFrame(): void { + $this->context->push(runUuid: 'parent', nodeId: 'call-subflow', sequence: 2); + $this->context->push(runUuid: 'child', nodeId: 'child-node', sequence: 0); + + $this->assertSame('child', $this->context->current()['run']); + + $this->context->pop(); + + $this->assertSame( + 'parent', + $this->context->current()['run'], + 'A sub-flow returning must hand the parent run back its own attribution.' + ); + }//end testNestedPopRestoresTheParentFrame() + + /** + * A run that is not attributable does not INHERIT the enclosing one. + * + * The wrong behaviour here files a child's writes under its parent: still a + * plausible-looking row, attributed to a run that never performed it. + */ + public function testUnattributableFrameDoesNotInheritTheParent(): void { + $this->context->push(runUuid: 'parent', nodeId: 'outer', sequence: 1); + $this->context->push(runUuid: null, nodeId: 'inner', sequence: 0); + + $this->assertNull( + $this->context->current(), + 'An inner hop with no run must attribute to nothing, not to the run outside it.' + ); + + $this->context->pop(); + + $this->assertSame('parent', $this->context->current()['run']); + }//end testUnattributableFrameDoesNotInheritTheParent() + + /** + * An empty run uuid is the same as none. A run uuid is never blank, so a + * blank one is a bug upstream and must not become an attribution of "". + */ + public function testBlankRunUuidIsNotAnAttribution(): void { + $this->context->push(runUuid: ' ', nodeId: 'node', sequence: 0); + + $this->assertNull($this->context->current()); + $this->assertSame(1, $this->context->depth(), 'It still occupies a frame, so pop stays paired.'); + }//end testBlankRunUuidIsNotAnAttribution() + + /** + * Popping more than was pushed must not throw. + * + * pop() is called from a `finally`. A `finally` that throws REPLACES the + * exception the step actually failed with, turning a diagnosable node + * failure into a bookkeeping error about the context. + */ + public function testPoppingAnEmptyStackIsHarmless(): void { + $this->context->pop(); + $this->context->pop(); + + $this->assertNull($this->context->current()); + $this->assertSame(0, $this->context->depth()); + }//end testPoppingAnEmptyStackIsHarmless() + + /** + * Two sequential runs do not bleed into one another. + * + * FlowRunWorker advances several runs per process, so a leak here crosses + * runs rather than merely steps. + */ + public function testSequentialRunsDoNotBleed(): void { + $this->context->push(runUuid: 'run-1', nodeId: 'n', sequence: 0); + $this->context->pop(); + + $this->assertNull($this->context->current(), 'A finished run must leave nothing behind.'); + + $this->context->push(runUuid: 'run-2', nodeId: 'n', sequence: 0); + + $this->assertSame('run-2', $this->context->current()['run']); + }//end testSequentialRunsDoNotBleed() +}//end class diff --git a/tests/Unit/Service/Flow/FlowStepHistoryTest.php b/tests/Unit/Service/Flow/FlowStepHistoryTest.php new file mode 100644 index 0000000000..3729514ffb --- /dev/null +++ b/tests/Unit/Service/Flow/FlowStepHistoryTest.php @@ -0,0 +1,222 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/flow-object-attribution/specs/flow-object-attribution/spec.md + */ + +namespace Unit\Service\Flow; + +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunStep; +use OCA\OpenRegister\Db\FlowRunStepMapper; +use OCA\OpenRegister\Service\Flow\FlowStepHistory; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +class FlowStepHistoryTest extends TestCase { + + /** + * A run with a uuid. + * + * @param string $uuid The uuid. + * + * @return FlowRun The run. + * + * Named aRun(), not run(): PHPUnit's TestCase::run() is final. + */ + private function aRun(string $uuid = 'run-1'): FlowRun { + $run = new FlowRun(); + $run->setUuid($uuid); + $run->setFlowId('flow-1'); + + return $run; + }//end aRun() + + public function testWithNoStepMapperNumberingStartsAtZero(): void { + $history = new FlowStepHistory(); + + $this->assertSame(0, $history->baseFor(runUuid: 'run-1')); + }//end testWithNoStepMapperNumberingStartsAtZero() + + public function testAnEmptyRunUuidNumbersFromZero(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->expects($this->never())->method('highestSequence'); + + $this->assertSame(0, (new FlowStepHistory(steps: $steps))->baseFor(runUuid: ' ')); + }//end testAnEmptyRunUuidNumbersFromZero() + + /** + * The base CONTINUES from what is already recorded. + * + * A run that suspends and resumes days later must read as one ordered + * history, not two starting at zero. + */ + public function testTheBaseContinuesFromTheHighestRecordedSequence(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->with('run-1')->willReturn(11); + + $this->assertSame(12, (new FlowStepHistory(steps: $steps))->baseFor(runUuid: 'run-1')); + }//end testTheBaseContinuesFromTheHighestRecordedSequence() + + /** + * A failed read degrades to zero rather than failing the run. + * + * The work is the run; the numbering is the account of it. Wrong only in + * its offset, and still ordering the run's writes among themselves. + */ + public function testAFailedReadDegradesToZeroAndIsLogged(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willThrowException(new RuntimeException('db gone')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning'); + + $history = new FlowStepHistory(steps: $steps, logger: $logger); + + $this->assertSame(0, $history->baseFor(runUuid: 'run-1')); + }//end testAFailedReadDegradesToZeroAndIsLogged() + + public function testRecordingWithoutAStepMapperIsANoOp(): void { + $history = new FlowStepHistory(); + + $history->record(run: $this->aRun(), entries: [['transition' => 'a', 'status' => 'completed']]); + + $this->addToAssertionCount(1); + }//end testRecordingWithoutAStepMapperIsANoOp() + + public function testNoEntriesRecordsNothing(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->expects($this->never())->method('insert'); + + (new FlowStepHistory(steps: $steps))->record(run: $this->aRun(), entries: []); + }//end testNoEntriesRecordsNothing() + + /** + * 🔑 THE NUMBERS THE ROWS GET ARE THE NUMBERS ATTRIBUTION PREDICTED. + * + * `baseFor()` is what the engine stamped onto audit rows before the walk; + * these are the rows written after it. Asserted together, in one test, + * because their agreement is the property — checking either alone would + * pass while they disagreed. + */ + public function testRecordedSequencesContinueFromTheSameBaseAttributionUsed(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(4); + + $recorded = []; + $steps->method('insert')->willReturnCallback( + function (FlowRunStep $step) use (&$recorded) { + $recorded[] = [$step->getNodeId(), $step->getSequence()]; + + return $step; + } + ); + + $history = new FlowStepHistory(steps: $steps); + + $this->assertSame(5, $history->baseFor(runUuid: 'run-1')); + + $history->record( + run: $this->aRun(), + entries: [ + ['transition' => 'first', 'status' => 'completed'], + ['transition' => 'second', 'status' => 'completed'], + ] + ); + + $this->assertSame([['first', 5], ['second', 6]], $recorded); + }//end testRecordedSequencesContinueFromTheSameBaseAttributionUsed() + + public function testAFailedInsertDoesNotStopTheRemainingSteps(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(0); + + $seen = []; + $steps->method('insert')->willReturnCallback( + function (FlowRunStep $step) use (&$seen) { + $seen[] = $step->getNodeId(); + if ($step->getNodeId() === 'first') { + throw new RuntimeException('write failed'); + } + + return $step; + } + ); + + (new FlowStepHistory(steps: $steps, logger: $this->createMock(LoggerInterface::class)))->record( + run: $this->aRun(), + entries: [ + ['transition' => 'first', 'status' => 'completed'], + ['transition' => 'second', 'status' => 'completed'], + ] + ); + + $this->assertSame(['first', 'second'], $seen, 'One unwritable row must not cost the rest their history.'); + }//end testAFailedInsertDoesNotStopTheRemainingSteps() + + /** + * A non-array entry is skipped rather than crashing the recorder. + */ + public function testMalformedEntriesAreSkipped(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(0); + + $count = 0; + $steps->method('insert')->willReturnCallback( + function (FlowRunStep $step) use (&$count) { + $count++; + + return $step; + } + ); + + (new FlowStepHistory(steps: $steps))->record( + run: $this->aRun(), + entries: ['not an array', ['transition' => 'real', 'status' => 'completed']] + ); + + $this->assertSame(1, $count); + }//end testMalformedEntriesAreSkipped() + + /** + * A stopped step's REASON lands in the error column. + * + * A thrown step and a deliberately stopped one are each something a person + * needs to read back, and they arrive under different keys. + */ + public function testAStopReasonIsRecordedAsTheStepsError(): void { + $steps = $this->createMock(FlowRunStepMapper::class); + $steps->method('highestSequence')->willReturn(0); + + $errors = []; + $steps->method('insert')->willReturnCallback( + function (FlowRunStep $step) use (&$errors) { + $errors[] = $step->getError(); + + return $step; + } + ); + + (new FlowStepHistory(steps: $steps))->record( + run: $this->aRun(), + entries: [ + ['transition' => 'a', 'status' => 'failed', 'error' => 'it threw'], + ['transition' => 'b', 'status' => 'stopped', 'reason' => 'author stopped it'], + ] + ); + + $this->assertSame(['it threw', 'author stopped it'], $errors); + }//end testAStopReasonIsRecordedAsTheStepsError() +}//end class From ab4658353de1c0568238c58be60ed7c51adb3a9e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 28 Aug 2026 23:11:25 +0200 Subject: [PATCH 04/42] fix(flow): a shipped flow must belong to a tenant, or it imports into invisibility (#3005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flow): attribute every write in a run to the run, node and step A flow run recorded exactly one object — `FlowRun.subjectUuid`, the thing that TRIGGERED it. Everything the run went on to touch was attributable to nothing, so neither "which node last touched this case" nor "what did this run change" could be answered. `FlowRunStep` already said what happened; it did not say what it happened to. The engine now establishes an ambient frame around each hop and the audit builder stamps it onto every row. Ambient rather than a parameter because the point is to catch writes made by code that has never heard of flows — a leaf app a node calls into, a cascade, a lifecycle hook. A node cannot report what it did not know it did. The pop is in a `finally`, and that is the whole safety property: every other exit from a hop is a `return` inside a catch — a stop, a suspension, a terminally-failed step. A frame left standing attributes LATER writes to a finished run, across runs, since one worker advances several. It produces no error and no wrong-looking row. FlowEngineAttributionTest asserts the leak direction; deleting the `finally` turns 5 of its 8 tests red. Step numbers are `base + index-in-log`, not a dispatch counter: a PINNED step logs an entry without ever reaching the dispatcher, so a counter would silently desynchronise from the FlowRunStep rows it has to line up with. ADR-003 Rule 4 — the three fields join the canonical JSON, so re-pointing a row at a different run breaks verification instead of going unnoticed. That makes it a seed migration (v1 → v2) rather than a column addition. The repair step verifies the OLD chain against a FROZEN v1 canonicaliser and records that verdict before re-sealing: verifying with the current canonicaliser would include the new keys, report every pre-existing row broken whether tampered with or not, and the re-chain would bless it either way — a check that cannot tell an intact chain from a compromised one is not a check. It refuses to start if it cannot store the verdict, because a re-chain with no account of what it replaced has no remedy. Also closes a pre-existing gap found on the way: `FlowRunController::show()` was unscoped while `index()` has been scoped since shared-credentials-and-flows D7 — and a run's serialisation carries its log, which records the subject data the flow touched. Both now resolve through one predicate in the mapper rather than two copies that drift. Refs: openspec/changes/flow-object-attribution * refactor(flow): one assignee rule, reachable from outside the controller `refuseUnlessAssignee()` guarded the HTTP resume endpoint, which was the whole story while HTTP was the only way to answer a step. It is not: a leaf app whose own object completes a task resumes the run IN-PROCESS through `FlowRunService::signal()`, which never passes the controller. Left where it was, every such caller re-implements the rule. Re-implementing it is the failure mode. Two copies of one access rule do not stay identical, and a divergence here does not throw — it lets the wrong person answer somebody else's question, correctly formatted, HTTP 200. The GROUP branch is the half a hand-written copy forgets, and forgetting it refuses the step's own intended audience while still reading as "the guard works". So the rule moves to FlowRunAssignee and the controller delegates. Its 24 existing tests pass unchanged, which is the point — behaviour is identical, only its reachability changed. The old private copy is deleted rather than left beside the new one. The new tests cover the three directions that pass while broken: the group branch, the deliberately-OPEN unassigned case (webhook and child-run signals record no assignee and must keep working), and the fail-closed anonymous case. Mutating `mayAnswer()` to return true kills 8 tests across both suites. Also adds `FlowNodeResumeState::nodeId()`. A node is handed its own slot but was never told its own name, which is fine until it must hand its identity to something outside the run — a task record that has to resume this exact node. A run holds one awaiting slot per node, so "resume this run" is not an answer. * refactor(audit): flow attribution gets its own home, and the gates pass phpmd caught a real regression rather than a style nit: adding the stamp and the query took AuditTrailMapper from clean to 27 non-accessor methods against a threshold of 25. Checked against origin/development rather than assumed — the base was clean, so this was mine. Both halves now live on AuditFlowAttribution. They belong together because they are one fact read from two ends: the stamp decides what a row claims, the query trusts that claim, and keeping them apart is how the column set they agree on drifts. The mapper is back under its ceiling and no longer has to know what a flow is. Other gate findings, each fixed at the cause: - FlowEngine::run() NPath 226/200. The 26 came from the `finally` that makes the attribution pop unconditional, and that is a correctness guarantee, not a convenience: every other exit from a hop is a `return` inside a catch, so a pop on the success path leaks the frame into a LATER run advanced by the same worker. Suppressed with that reasoning written down, rather than restructuring a walk to win a number. - AuditCanonicalV1's static access is suppressed with its reason: it is deliberately frozen, and presenting it as an injectable collaborator would imply it can be swapped or updated — the one thing it must never be. - `array_values()` after `usort()` was a no-op that read as a safeguard. - `@template-extends QBMapper`, matching FlowRunMapper's convention, rather than casting the return type. - Three phpcs errors of mine (two missing @param, one comment), and the @spec tags I had put on member variables, which that standard does not allow there. Gates now clean on every changed file: phpcs, phpmd, psalm, phpstan. 725 flow, controller and audit tests pass. * fix(ci): cover the new code, and stop tipping FlowRunService over its ceilings CI caught three things. **phpmd, twice, and both were mine.** An inline FQN in Application.php that wanted a `use`; and FlowRunService at 1047 lines / complexity 51 against thresholds of 1000 / 50. The base was at ~998 lines, so any addition trips it — my eight lines were simply the ones that did. Rather than trim a comment until the number passed, the step-history concern moved out whole. `FlowStepHistory` now owns both the NUMBERING and the RECORDING of a run's steps, and they belong together for a reason: attribution has to PREDICT a step's number before the walk, while the step row is written after it, and the two must arrive at the same value or an attributed audit row and its step row describe different steps. One class, one arithmetic — and `testRecordedSequencesContinueFromTheSameBaseAttributionUsed` asserts both ends in a single test, because checking either alone passes while they disagree. **The coverage ratchet was right too** (-2.49%). FlowStepHistory (10 tests) and AuditFlowAttribution (6) are now covered. The stamper's tests are all about the abnormal paths, because the normal one is a single line: a row written outside any run must carry NO attribution, an unresolvable context must still let the row be written, and something that is not a run context must not be trusted to be one. While writing them, `TestCase::run()` is final — the same trap the existing FlowEngineTest documents in a comment. Helper renamed. Gates: phpcs, phpmd, psalm, phpstan clean on every changed file. * test(repair): cover the one step in this change that cannot be undone The v1 → v2 migration had no tests, and it is the riskiest thing here: a re-chain recomputes every hash from current content, so afterwards an intact chain and a tampered one look identical, and the v1 hashes that could have told them apart are gone. So these test the ORDER and the REFUSALS rather than the happy path: - the verdict is stored BEFORE anything is re-sealed, and names the seed it moved from and to; - a verdict that cannot be stored means NO re-chain at all — the one refusal worth blocking an upgrade over, since a re-chain with no account of what it replaced has no remedy; - a second run is a no-op, because a v2 chain checked against the v1 form would report a false break and overwrite the real verdict with a meaningless one; - a re-seal that throws does not leave the step marked done, or the next `occ maintenance:repair` skips a half-sealed table. Worth noting how the first run failed: my IAppConfig double used an arrow function, which captures by VALUE, so the read-back always saw an empty store — and the step refused, exactly as designed. The double was wrong; the refusal it tripped was right, which is a reasonable way to learn the guard works. * fix(flow): a shipped flow must belong to a tenant, or it imports into invisibility Found by running the e2e for the first time, against a real instance. Every flow READ is organisation-scoped (`FlowService::findAll()` refuses outright when no tenant resolves). `SchemaFlowImportListener` never set one, so a flow declared through `x-openregister-flows` was stored with `organisation` NULL and returned by NOTHING: absent from the flows list, therefore impossible to open, therefore impossible to ADOPT — while sitting perfectly intact in the table. Measured on a clean instance: the declared `Case behandeling` flow was present in `oc_openregister_flows` with 18 nodes and invisible to `/api/flows`, next to two seeded flows that carried an organisation and listed fine. Backfilling the column made it appear immediately, with `enabled=false` and `owner=NULL` intact. Pre-existing: the importer has always done this. Nothing had noticed because this is the first shipped flow declaration in the fleet — the two example flows are seeded through a different path that sets the tenant. The resolver is container-based and returns null rather than throwing, so a declared flow still imports where OrganisationService is unavailable; it just stays unlisted, and now says so in a warning instead of silently. * fix(phpcs): document the container parameter I added phpcs caught what my local run had not: I ran the file through phpmd, psalm and phpstan but not phpcs before pushing. One missing @param. * test(flow): cover the organisation stamp on an imported flow The coverage ratchet was right to fail #3005: the fix added fifteen statements and no test, so the code that decides whether a shipped flow is ever VISIBLE was the only part of the change nothing exercised. Six tests, one per branch of activeOrganisation() plus the re-import case. The last one is the one worth keeping: the stamp sits in the CREATE branch beside enabled/owner, so an upgrade running as a different tenant cannot move an already-adopted flow out from under the organisation using it. Mutation-checked: removing the setOrganisation() call turns testAnImportedFlowIsStampedWithTheActiveOrganisation red. --- lib/Listener/SchemaFlowImportListener.php | 54 +++++++ .../Listener/SchemaFlowImportListenerTest.php | 149 +++++++++++++++++- 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/lib/Listener/SchemaFlowImportListener.php b/lib/Listener/SchemaFlowImportListener.php index c6716ff129..d2af3e3235 100644 --- a/lib/Listener/SchemaFlowImportListener.php +++ b/lib/Listener/SchemaFlowImportListener.php @@ -43,6 +43,7 @@ use InvalidArgumentException; use OCA\OpenRegister\Db\Flow; use OCA\OpenRegister\Db\FlowMapper; +use Psr\Container\ContainerInterface; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Event\SchemaCreatedEvent; use OCA\OpenRegister\Event\SchemaUpdatedEvent; @@ -71,10 +72,16 @@ class SchemaFlowImportListener implements IEventListener { * * @param FlowMapper $flows The flow store. * @param LoggerInterface $logger Records what was imported, and what could not be. + * @param ContainerInterface|null $container Resolves the organisation a declared flow + * belongs to. Nullable and LAST so adding it + * shifts no positional caller; absent, the + * flow still imports but stays unlisted, + * which is logged rather than silent. */ public function __construct( private readonly FlowMapper $flows, private readonly LoggerInterface $logger, + private readonly ?ContainerInterface $container = null, ) { }//end __construct() @@ -192,6 +199,17 @@ private function upsert(array $declaration, string $schemaSlug): void { // Inert until adopted. See the class docblock. $flow->setEnabled(false); $flow->setOwner(null); + + // 🔴 AND IT MUST BELONG TO A TENANT, or it is imported into + // invisibility. Every flow READ is organisation-scoped + // (`FlowService::findAll()`), so a flow stored with no organisation + // is returned by nothing: it does not appear in the flows list, so + // it cannot be opened, so it can never be adopted — while sitting + // perfectly intact in the table. Measured 2026-08-28: the first + // shipped `x-openregister-flows` declaration imported with + // organisation NULL and was invisible to the API that lists it, + // next to two seeded flows that carried one and showed up fine. + $flow->setOrganisation($this->activeOrganisation()); } $flow->setDescription(($declaration['description'] ?? null)); @@ -217,6 +235,42 @@ private function upsert(array $declaration, string $schemaSlug): void { }//end upsert() + /** + * The organisation a newly imported flow belongs to. + * + * Resolved through the container rather than injected so this listener + * stays constructible where OrganisationService is not registered — a + * declared flow should still import on such an instance, even though a + * null organisation leaves it unlisted. + * + * @return string|null The organisation uuid, or null when unresolvable. + * + * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md + */ + private function activeOrganisation(): ?string { + if ($this->container === null) { + return null; + } + + try { + $uuid = $this->container + ->get('OCA\OpenRegister\Service\OrganisationService') + ->getActiveOrganisation()?->getUuid(); + } catch (Throwable $e) { + $this->logger->warning( + message: '[SchemaFlowImport] Could not resolve an organisation for a declared flow; ' + . 'it will import but stay unlisted: ' . $e->getMessage() + ); + return null; + } + + if ($uuid === null || (string)$uuid === '') { + return null; + } + + return (string)$uuid; + }//end activeOrganisation() + /** * Mint a v4 uuid. * diff --git a/tests/Unit/Listener/SchemaFlowImportListenerTest.php b/tests/Unit/Listener/SchemaFlowImportListenerTest.php index fd18ad904f..28e0232151 100644 --- a/tests/Unit/Listener/SchemaFlowImportListenerTest.php +++ b/tests/Unit/Listener/SchemaFlowImportListenerTest.php @@ -15,6 +15,7 @@ use OCA\OpenRegister\Event\SchemaCreatedEvent; use OCA\OpenRegister\Listener\SchemaFlowImportListener; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; /** * `x-openregister-flows` materialises into the flow store on schema save. @@ -39,7 +40,7 @@ class SchemaFlowImportListenerTest extends TestCase { * * @return SchemaFlowImportListener The listener. */ - private function listener(array $existing = []): SchemaFlowImportListener { + private function listener(array $existing = [], ?ContainerInterface $container = null): SchemaFlowImportListener { $this->inserted = []; $this->updated = []; @@ -54,7 +55,52 @@ private function listener(array $existing = []): SchemaFlowImportListener { return $f; }); - return new SchemaFlowImportListener($mapper, new \Psr\Log\NullLogger()); + return new SchemaFlowImportListener($mapper, new \Psr\Log\NullLogger(), $container); + } + + /** + * A container whose OrganisationService answers with $uuid. + * + * @param string|null $uuid The active organisation's uuid, or null for + * "there is an OrganisationService but no active + * organisation". + * @param boolean $blows Whether resolving it throws, which models an + * instance where the service is not registered. + * + * @return ContainerInterface The container. + */ + private function container(?string $uuid, bool $blows = false): ContainerInterface { + $container = $this->createMock(ContainerInterface::class); + + if ($blows === true) { + $container->method('get')->willThrowException(new \RuntimeException('not registered')); + return $container; + } + + $organisation = null; + if ($uuid !== null) { + $organisation = new class($uuid) { + public function __construct(private string $uuid) { + } + + public function getUuid(): string { + return $this->uuid; + } + }; + } + + $service = new class($organisation) { + public function __construct(private ?object $organisation) { + } + + public function getActiveOrganisation(): ?object { + return $this->organisation; + } + }; + + $container->method('get')->willReturn($service); + + return $container; } /** @@ -174,4 +220,103 @@ public function testAMalformedDeclarationIsSkippedNotFatal(): void { $this->assertCount(1, $this->inserted); $this->assertSame('Good', $this->inserted[0]->getName()); } + + /** + * 🔴 AN IMPORTED FLOW MUST BELONG TO A TENANT. + * + * Every flow READ is organisation-scoped, so a flow stored with a null + * organisation is returned by nothing: it never appears in the flows list, + * so it can never be opened, so it can never be adopted — while sitting + * perfectly intact in the table. Measured 2026-08-28 against a live + * instance: the first shipped `x-openregister-flows` declaration was + * invisible to `/api/flows`, next to two seeded flows that carried an + * organisation and listed fine. + */ + public function testAnImportedFlowIsStampedWithTheActiveOrganisation(): void { + $listener = $this->listener([], $this->container('org-1')); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertSame('org-1', $this->inserted[0]->getOrganisation()); + } + + /** + * With no container the flow still imports. + * + * The listener must stay constructible on an instance where + * OrganisationService is not registered: refusing the import would turn a + * missing optional service into a failed schema save. + */ + public function testWithNoContainerTheFlowStillImportsWithoutAnOrganisation(): void { + $listener = $this->listener(); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertCount(1, $this->inserted); + $this->assertNull($this->inserted[0]->getOrganisation()); + } + + /** + * A container that cannot resolve the service is not fatal either. + */ + public function testAnUnresolvableOrganisationServiceIsNotFatal(): void { + $listener = $this->listener([], $this->container(null, blows: true)); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertCount(1, $this->inserted); + $this->assertNull($this->inserted[0]->getOrganisation()); + } + + /** + * A registered service with NO active organisation resolves to null rather + * than to the empty string, which would be a real-looking tenant that owns + * nothing. + */ + public function testNoActiveOrganisationResolvesToNullNotAnEmptyString(): void { + $listener = $this->listener([], $this->container(null)); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertNull($this->inserted[0]->getOrganisation()); + } + + /** + * An empty uuid is treated as no organisation for the same reason. + */ + public function testAnEmptyUuidResolvesToNull(): void { + $listener = $this->listener([], $this->container('')); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertNull($this->inserted[0]->getOrganisation()); + } + + /** + * 🔴 A RE-IMPORT MUST NOT RE-STAMP THE ORGANISATION. + * + * The stamp sits in the create branch beside `enabled`/`owner` precisely so + * an upgrade running as a different tenant cannot move an already-adopted + * flow out from under the organisation using it. + */ + public function testAReimportDoesNotMoveAnExistingFlowToAnotherOrganisation(): void { + $existing = new Flow(); + $existing->setUuid('u1'); + $existing->setApp('openregister'); + $existing->setName('Triage'); + $existing->setTriggerSchema('case'); + $existing->setOrganisation('org-owner'); + + $listener = $this->listener([$existing], $this->container('org-upgrader')); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertSame('org-owner', $this->updated[0]->getOrganisation()); + } } From c8c304e8f2788d59b1e568299da2e59cfd06ec6d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 28 Aug 2026 23:49:19 +0200 Subject: [PATCH 05/42] fix(flow): a session-less import must fall back to the DEFAULT organisation (#3007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My own fix for the invisible-flow defect was half a fix, and its e2e proved it — in CI, where the previous run could not. A schema import runs during install and during `occ maintenance:repair`. There is no user session there, so `getActiveOrganisation()` returns null, the flow is stored with organisation NULL, and every flow read being organisation-scoped it is invisible in /api/flows all over again. The dev stack I verified on happened to HAVE an active organisation, so it went green; the CI runner does not, and dossiq's case-flow e2e failed on exactly the assertion written to catch this. `getOrganisationForNewEntity()` is the call every ordinary object save already makes, and it exists precisely to fall back to the default organisation for callers with no session. The test double now answers NULL from `getActiveOrganisation()`, so the regression cannot pass: reverting the call turns two tests red. --- lib/Listener/SchemaFlowImportListener.php | 11 ++- .../Listener/SchemaFlowImportListenerTest.php | 69 ++++++++++++++----- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/lib/Listener/SchemaFlowImportListener.php b/lib/Listener/SchemaFlowImportListener.php index d2af3e3235..2b9e12e394 100644 --- a/lib/Listener/SchemaFlowImportListener.php +++ b/lib/Listener/SchemaFlowImportListener.php @@ -253,9 +253,18 @@ private function activeOrganisation(): ?string { } try { + // 🔴 `getOrganisationForNewEntity()`, NOT `getActiveOrganisation()`. + // A schema import runs during install and during + // `occ maintenance:repair` — with NO user session, so there is no + // ACTIVE organisation to find and the flow imported ownerless and + // invisible all over again. This is the same call every ordinary + // object save makes, and it falls back to the DEFAULT organisation + // exactly for callers with no session. Measured 2026-08-28: the fix + // that resolved the active organisation passed on a dev stack that + // happened to have one, and failed in CI, which does not. $uuid = $this->container ->get('OCA\OpenRegister\Service\OrganisationService') - ->getActiveOrganisation()?->getUuid(); + ->getOrganisationForNewEntity(); } catch (Throwable $e) { $this->logger->warning( message: '[SchemaFlowImport] Could not resolve an organisation for a declared flow; ' diff --git a/tests/Unit/Listener/SchemaFlowImportListenerTest.php b/tests/Unit/Listener/SchemaFlowImportListenerTest.php index 28e0232151..83f517114c 100644 --- a/tests/Unit/Listener/SchemaFlowImportListenerTest.php +++ b/tests/Unit/Listener/SchemaFlowImportListenerTest.php @@ -61,9 +61,9 @@ private function listener(array $existing = [], ?ContainerInterface $container = /** * A container whose OrganisationService answers with $uuid. * - * @param string|null $uuid The active organisation's uuid, or null for - * "there is an OrganisationService but no active - * organisation". + * @param string|null $uuid What `getOrganisationForNewEntity()` resolves + * to, or null for an instance that can resolve + * no organisation at all. * @param boolean $blows Whether resolving it throws, which models an * instance where the service is not registered. * @@ -77,24 +77,30 @@ private function container(?string $uuid, bool $blows = false): ContainerInterfa return $container; } - $organisation = null; - if ($uuid !== null) { - $organisation = new class($uuid) { - public function __construct(private string $uuid) { - } - - public function getUuid(): string { - return $this->uuid; - } - }; - } + $service = new class($uuid) { + public function __construct(private ?string $uuid) { + } - $service = new class($organisation) { - public function __construct(private ?object $organisation) { + /** + * The call the listener MUST make. + * + * Not `getActiveOrganisation()`: this one falls back to the DEFAULT + * organisation when there is no session, which is the situation a + * schema import actually runs in. + */ + public function getOrganisationForNewEntity(): ?string { + return $this->uuid; } + /** + * Present, and deliberately answering NOTHING. + * + * A session-less import is exactly where this returns null, so a + * listener that reached for it would stamp no organisation — and + * every test here would still pass if this returned a real value. + */ public function getActiveOrganisation(): ?object { - return $this->organisation; + return null; } }; @@ -232,7 +238,7 @@ public function testAMalformedDeclarationIsSkippedNotFatal(): void { * invisible to `/api/flows`, next to two seeded flows that carried an * organisation and listed fine. */ - public function testAnImportedFlowIsStampedWithTheActiveOrganisation(): void { + public function testAnImportedFlowIsStampedWithAnOrganisation(): void { $listener = $this->listener([], $this->container('org-1')); $this->fire($listener, $this->schema([ ['name' => 'Triage', 'nodes' => [], 'edges' => []], @@ -319,4 +325,29 @@ public function testAReimportDoesNotMoveAnExistingFlowToAnotherOrganisation(): v $this->assertSame('org-owner', $this->updated[0]->getOrganisation()); } -} + + /** + * 🔴 IT MUST ASK FOR "AN ORGANISATION FOR A NEW ENTITY", NOT THE ACTIVE ONE. + * + * A schema import runs during install and during `occ maintenance:repair`, + * with no user session — so there IS no active organisation, and a listener + * that asked for one stamped null and the flow was invisible again. The + * double above answers null from `getActiveOrganisation()` precisely so that + * regression cannot pass. + * + * Measured 2026-08-28: the active-organisation version went green on a dev + * stack that happened to have one, and failed in CI, which does not. + */ + public function testItResolvesTheOrganisationTheWayEveryOtherNewEntityDoes(): void { + $listener = $this->listener([], $this->container('org-default')); + $this->fire($listener, $this->schema([ + ['name' => 'Triage', 'nodes' => [], 'edges' => []], + ])); + + $this->assertSame( + 'org-default', + $this->inserted[0]->getOrganisation(), + 'the default organisation is what a session-less import must fall back to' + ); + }//end testItResolvesTheOrganisationTheWayEveryOtherNewEntityDoes() +}//end class From bb7a45f92f9f892aaa7f09db4d4a1e439db02bc8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 29 Aug 2026 03:32:07 +0200 Subject: [PATCH 06/42] feat(setup): a wizard that offers the demo data this app already ships (#2982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(setup): a wizard that offers the demo data this app already ships This app ships lib/Settings/*_mock_register.json - a dataset generated from its own schemas, conformant by construction, validated by the generator's --check - and had no way for an operator to reach it. There was no setup wizard at all. welcome -> demo-data -> done. Nothing app-specific is invented: the only action is the demo-data import the descriptor already supports. A wizard that asked questions the app does not act on would be worse than none, which is why there are no configuration steps here yet. completed is TRUE and the demo-data step is optional, so setup never gates the app. skip-demo-data records its outcome just as installing does: since nextcloud-vue 2.21 an OUTSTANDING OPTIONAL step opens the wizard over every page (nextcloud-vue#806), so a step that can never be marked done is a dialog that never closes - the defect buildiq was failing 37 E2E specs on. Verified: manifest validates against schema 2.26.0, gate-100 PASS, routes.php and both PHP files parse. The template was checked on launchpad against phpcs, phpstan, psalm and phpmd - all clean. * fix(setup): declare the endpoints' auth, and translate the wizard's strings Two gate findings on the previous push. gate-5 route-auth — status() and runAction() carried no auth attribute. The docblock said 'admin-only by Nextcloud's default for an un-attributed method', which is true and is not a declaration: the gate exists because a missing attribute silently makes an endpoint unreachable, and a comment cannot be checked by middleware. Both now carry #[AuthorizedAdminSetting(Application::APP_ID)], placed DIRECTLY above the declaration - gate-5 walks upward from the method and a long docblock between attribute and declaration costs the attribute its visibility, which the gate documents as a false FAIL it had to repair. gate-102 manifest-l10n-coverage — the wizard's title and body strings had no l10n/nl.json key, so a Dutch user would read them in English. Added, and the browser catalogue rebuilt where the app ships one: nl.json alone is not enough, because the browser reads nl.js. The catalogue edit is insertions only, proven against the same change applied structurally - an earlier attempt on another app re-serialised the whole file (410 lines) before being reverted. * fix(setup): authorize against the admin settings class, and test what it guards `AuthorizedAdminSetting` takes a `class-string`, not an app id. Passing `Application::APP_ID` is a plain string, so phpstan rejected it — and the apps where this shipped green (larpinq, shillinq) already pass their admin settings class. Match them: `OpenRegisterAdmin::class`, which implements `IDelegatedSettings`. gate-47 was right to fail this too. The change adds an admin-authorized endpoint pair with no test alongside it, and a mocked unit test cannot show that middleware admitting a real session. The e2e spec issues both calls from inside the logged-in admin page, and asserts the install response NAMES how much landed — the one assertion that separates a real import from one that wrote nothing, which is the defect this programme already shipped once. * test(setup): cover the demo-data controller and service The coverage ratchet was right: this change adds ~364 lines of PHP with no unit test behind them, so the coverage of the files it touches fell 46.48%. The two assertions worth naming: - a FAILED install must leave the step UNDECIDED. Recording the decision in the catch block would close the step for an operator who asked for demo data and received none — the wizard would never offer it again and nothing would have been imported. - the object count comes from the FILE, not the importer's reply, so the number reported is the number ASKED FOR. An object whose schema does not resolve is skipped rather than errored, and that discrepancy must stay visible. Both were verified by mutation: reversing each behaviour fails exactly the test that claims to guard it. --------- Co-authored-by: Conduction Release Bot --- appinfo/routes.php | 3 + l10n/nl.js | 5 + l10n/nl.json | 5 + lib/Controller/SetupController.php | 180 +++++++++++++++++ lib/Service/DemoDataService.php | 185 ++++++++++++++++++ src/manifest.json | 25 +++ tests/Unit/Controller/SetupControllerTest.php | 115 +++++++++++ tests/Unit/Service/DemoDataServiceTest.php | 134 +++++++++++++ .../demo-data-setup-step.spec.ts | 161 +++++++++++++++ 9 files changed, 813 insertions(+) create mode 100644 lib/Controller/SetupController.php create mode 100644 lib/Service/DemoDataService.php create mode 100644 tests/Unit/Controller/SetupControllerTest.php create mode 100644 tests/Unit/Service/DemoDataServiceTest.php create mode 100644 tests/e2e/spec-coverage/demo-data-setup-step.spec.ts diff --git a/appinfo/routes.php b/appinfo/routes.php index f416bd9aab..02463af309 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -16,6 +16,9 @@ // Federation (cross-instance OCM sharing) — token-scoped serving endpoints. // #[PublicPage]: the caller is a remote instance authenticated by the // bearer share token in the URL, not a local session. + // First-time setup wizard (ADR-042) - the standard CnSetupWizard contract. + ['name' => 'setup#status', 'url' => '/api/setup/status', 'verb' => 'GET'], + ['name' => 'setup#runAction', 'url' => '/api/setup/action/{actionId}', 'verb' => 'POST', 'requirements' => ['actionId' => '[a-z0-9\\-]+']], ['name' => 'federation#objects', 'url' => '/api/federation/{shareToken}/objects', 'verb' => 'GET', 'requirements' => ['shareToken' => '[^/]+']], ['name' => 'federation#object', 'url' => '/api/federation/{shareToken}/objects/{id}', 'verb' => 'GET', 'requirements' => ['shareToken' => '[^/]+', 'id' => '[^/]+']], ['name' => 'federation#meta', 'url' => '/api/federation/{shareToken}/meta', 'verb' => 'GET', 'requirements' => ['shareToken' => '[^/]+']], diff --git a/l10n/nl.js b/l10n/nl.js index 69bf81f0dc..ab33875a00 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -3,6 +3,11 @@ OC.L10N.register( { "3": "3", "30": "30", + "Welcome": "Welkom", + "A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.", + "Demo data (optional)": "Demovoorbeelddata (optioneel)", + "Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install.": "Laad een kleine voorbeeldset zodat de lijsten, detailpagina's en dashboards meteen een werkend product laten zien. De data is duidelijk voorbeelddata, veilig om meerdere keren uit te voeren en achteraf te verwijderen. Sla dit over op een productie-installatie.", + "All set": "Klaar", "%n entries have no hash yet": "%n regels hebben nog geen hash", "%n entry has no hash yet": "%n regel heeft nog geen hash", "%s asks to act on your behalf": "%s vraagt om namens u te handelen", diff --git a/l10n/nl.json b/l10n/nl.json index 52e78b60be..e2c8a7b4d8 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,10 @@ { "translations": { + "Welcome": "Welkom", + "A short setup to get this app ready. Nothing here is required; you can close it and come back later.": "Een korte installatie om deze app klaar te zetten. Niets hiervan is verplicht; je kunt dit sluiten en later terugkomen.", + "Demo data (optional)": "Demovoorbeelddata (optioneel)", + "Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install.": "Laad een kleine voorbeeldset zodat de lijsten, detailpagina's en dashboards meteen een werkend product laten zien. De data is duidelijk voorbeelddata, veilig om meerdere keren uit te voeren en achteraf te verwijderen. Sla dit over op een productie-installatie.", + "All set": "Klaar", "%n entries have no hash yet": "%n regels hebben nog geen hash", "%n entry has no hash yet": "%n regel heeft nog geen hash", "%s asks to act on your behalf": "%s vraagt om namens u te handelen", diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php new file mode 100644 index 0000000000..65db0bd995 --- /dev/null +++ b/lib/Controller/SetupController.php @@ -0,0 +1,180 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +use OCA\OpenRegister\AppInfo\Application; +use OCA\OpenRegister\Settings\OpenRegisterAdmin; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IAppConfig; +use OCP\IRequest; +use Psr\Log\LoggerInterface; +use OCA\OpenRegister\Service\DemoDataService; + +/** + * First-time setup wizard endpoints. + * + * @spec exclude First-time-setup action dispatch; ADR-042 contract, no per-app behavioural spec. + */ +class SetupController extends Controller { + /** + * Setup contract version; matches manifest.setup.version. + * + * @var integer + */ + private const SETUP_VERSION = 1; + + /** + * App-config key recording that the demo-data step was DEALT WITH. + * + * Not "objects exist": an operator who declines has finished the step, and + * re-offering the import on every visit would make "no thanks" impossible to + * express. Since @conduction/nextcloud-vue 2.21 that also matters visually — + * an OUTSTANDING OPTIONAL step opens the wizard over every page + * (nextcloud-vue#806), so a step that can never be marked done is a dialog + * that never closes. + * + * @var string + */ + private const DEMO_DECIDED_KEY = 'demo_data_decided'; + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param IAppConfig $appConfig Records the demo-data decision. + * @param LoggerInterface $logger Records a failed import. + * @param DemoDataService $demoDataService Imports the shipped demo dataset. + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + private readonly DemoDataService $demoDataService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + + }//end __construct() + + /** + * Report per-step setup status for the wizard. + * + * `completed` is deliberately TRUE: this app declares no REQUIRED step, so + * setup must never gate the app. The demo-data step is reported so the wizard + * can stop asking once it has an answer. + * + * @return JSONResponse The status document. + * + * @spec exclude Setup status document; ADR-042 contract, no per-app behavioural spec. + */ + #[AuthorizedAdminSetting(OpenRegisterAdmin::class)] + public function status(): JSONResponse { + $demoDecided = $this->appConfig->getValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, '') !== ''; + + return new JSONResponse( + data: [ + 'version' => self::SETUP_VERSION, + 'completed' => true, + 'steps' => [ + 'demo-data' => ['done' => $demoDecided], + ], + ] + ); + + }//end status() + + /** + * Run a privileged server-side setup action. + * + * Admin-only by Nextcloud's default for an un-attributed method. + * + * @param string $actionId One of `install-demo-data` | `skip-demo-data`. + * + * @return JSONResponse `{ success, message }`. + * + * @spec exclude Setup action dispatch; ADR-042 contract, no per-app behavioural spec. + */ + #[AuthorizedAdminSetting(OpenRegisterAdmin::class)] + public function runAction(string $actionId): JSONResponse { + if ($actionId === 'install-demo-data') { + return $this->installDemoData(); + } + + // DECLINING IS AN ANSWER — see DEMO_DECIDED_KEY. + if ($actionId === 'skip-demo-data') { + $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped'); + + return new JSONResponse(data: ['success' => true, 'message' => 'Demo data skipped.']); + } + + return new JSONResponse( + data: ['success' => false, 'message' => 'Unknown setup action: ' . $actionId], + statusCode: Http::STATUS_NOT_FOUND, + ); + + }//end runAction() + + /** + * Import the shipped demo dataset. + * + * Reports the FAILURE rather than a quiet success: an operator who asked for + * demo data and got none must be told, which is why DemoDataService::install() + * throws instead of returning an empty result. + * + * @return JSONResponse `{ success, message }`. + */ + private function installDemoData(): JSONResponse { + try { + $imported = $this->demoDataService->install(); + } catch (\Throwable $e) { + $this->logger->error( + 'Setup install-demo-data failed: ' . $e->getMessage(), + ['app' => Application::APP_ID, 'exception' => $e] + ); + + return new JSONResponse( + data: ['success' => false, 'message' => 'Could not import the demo data: ' . $e->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR, + ); + } + + $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'installed'); + + return new JSONResponse( + data: [ + 'success' => true, + 'message' => 'Imported ' . $imported['objects'] . ' demo object(s).', + ] + ); + + }//end installDemoData() +}//end class diff --git a/lib/Service/DemoDataService.php b/lib/Service/DemoDataService.php new file mode 100644 index 0000000000..a4a622ef43 --- /dev/null +++ b/lib/Service/DemoDataService.php @@ -0,0 +1,185 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service; + +use OCA\OpenRegister\AppInfo\Application; +use OCP\App\IAppManager; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Imports the shipped demo dataset on request. + * + * @spec exclude Demo-data import; ADR-111 rule 1, no per-app behavioural spec. + */ +class DemoDataService { + /** + * App-relative path to the generated mock descriptor. + * + * @var string + */ + private const DESCRIPTOR = '/lib/Settings/openregister_mock_register.json'; + + /** + * Configuration identity for the demo import. + * + * 🔴 ITS OWN NAMESPACE, not the app id. Sharing the app's identity would make + * the demo import and the real configuration import share one version gate, so + * installing demo data could mask a pending configuration update — or be + * masked by one. + * + * @var string + */ + private const CONFIG_APP_ID = Application::APP_ID . '.demo'; + + /** + * Constructor. + * + * @param IAppManager $appManager Resolves this app's path and version. + * @param ContainerInterface $container Resolves OpenRegister's importer. + * @param LoggerInterface $logger Records what was imported. + * + * @return void + */ + public function __construct( + private readonly IAppManager $appManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether this app ships a demo dataset at all. + * + * @return boolean True when the descriptor is present on disk. + * + * @spec exclude Demo-data availability probe; ADR-111 rule 1 has no per-app behavioural spec. + */ + public function isAvailable(): bool { + return is_file($this->descriptorPath()) === true; + }//end isAvailable() + + /** + * Import the demo dataset. + * + * 🔴 THROWS RATHER THAN RETURNING A QUIET FAILURE. The caller reports the + * outcome to an operator who just asked for this, so "nothing happened" must + * not be presentable as success. + * + * @return array{objects: integer, registers: integer, schemas: integer} What was imported. + * + * @throws RuntimeException When the descriptor is missing, unreadable, or OpenRegister is absent. + * + * @spec exclude Demo-data import; ADR-111 rule 1 has no per-app behavioural spec. + */ + public function install(): array { + $path = $this->descriptorPath(); + if (is_file($path) === false) { + throw new RuntimeException('No demo dataset ships with this app (' . self::DESCRIPTOR . ' not found).'); + } + + $raw = file_get_contents($path); + if ($raw === false) { + throw new RuntimeException('The demo dataset could not be read: ' . $path); + } + + $data = json_decode($raw, true); + if (is_array($data) === false) { + throw new RuntimeException('The demo dataset is not valid JSON: ' . $path); + } + + // Counted from the FILE, not the importer's reply, so the number reported + // is the number ASKED FOR. An object whose schema does not resolve is + // SKIPPED rather than errored, so a discrepancy here is a real condition + // an operator should be able to see. + $objects = 0; + $components = ($data['components'] ?? []); + if (is_array($components) === true && is_array(($components['objects'] ?? null)) === true) { + $objects = count($components['objects']); + } + + $result = $this->configurationService()->importFromApp( + appId: self::CONFIG_APP_ID, + data: $data, + version: $this->appManager->getAppVersion(Application::APP_ID), + force: true + ); + + $imported = [ + 'objects' => $objects, + 'registers' => count((array)($result['registers'] ?? [])), + 'schemas' => count((array)($result['schemas'] ?? [])), + ]; + + $this->logger->info( + '[DemoDataService] imported demo data: ' + . $imported['objects'] . ' object(s), ' + . $imported['registers'] . ' register(s), ' + . $imported['schemas'] . ' schema(s).', + ['app' => Application::APP_ID] + ); + + return $imported; + }//end install() + + /** + * Absolute path to the shipped descriptor. + * + * @return string The path. + */ + private function descriptorPath(): string { + return $this->appManager->getAppPath(Application::APP_ID) . self::DESCRIPTOR; + }//end descriptorPath() + + /** + * OpenRegister's configuration importer. + * + * 🔴 A CROSS-APP CLASS IS A RUNTIME LOOKUP. OpenRegister may not be installed, + * and asking the container for a class from a missing app raises something the + * caller cannot act on. Check first and say which app is missing. + * + * 🔴 THE RETURN TYPE IS `object`, NOT THE CLASS, AND THAT IS THE POINT. Naming + * a class from an OPTIONAL app in a native return type makes PHP resolve it + * whenever this method returns, so on an instance without OpenRegister the + * failure is a TypeError about a class nobody mentioned instead of the + * RuntimeException above that names the missing app. + * + * @return object The importer — an OCA\OpenRegister\Service\ConfigurationService. + * + * @psalm-return \OCA\OpenRegister\Service\ConfigurationService + * + * @throws RuntimeException When OpenRegister is not installed. + */ + private function configurationService(): object { + if (in_array('openregister', $this->appManager->getInstalledApps(), true) === false) { + throw new RuntimeException('Demo data needs OpenRegister, which is not installed.'); + } + + return $this->container->get('OCA\OpenRegister\Service\ConfigurationService'); + }//end configurationService() +}//end class diff --git a/src/manifest.json b/src/manifest.json index 82a8876fb4..e9e94809e1 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,5 +1,30 @@ { "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", + "setup": { + "version": 1, + "completionConfigKey": "setup_completed_version", + "steps": [ + { + "id": "welcome", + "type": "info", + "title": "Welcome", + "body": "A short setup to get this app ready. Nothing here is required; you can close it and come back later." + }, + { + "id": "demo-data", + "type": "run-action", + "action": "install-demo-data", + "title": "Demo data (optional)", + "required": false, + "body": "Load a small example dataset so the lists, detail pages and dashboards show a working product straight away. The data is obviously sample data, it is safe to run more than once, and it can be removed afterwards. Skip this on a production install." + }, + { + "id": "done", + "type": "summary", + "title": "All set" + } + ] + }, "version": "1.1.0", "nav": { "includePersonalSettings": false diff --git a/tests/Unit/Controller/SetupControllerTest.php b/tests/Unit/Controller/SetupControllerTest.php new file mode 100644 index 0000000000..1344b420e2 --- /dev/null +++ b/tests/Unit/Controller/SetupControllerTest.php @@ -0,0 +1,115 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->demoData = $this->createMock(DemoDataService::class); + + $this->controller = new SetupController( + $this->createMock(IRequest::class), + $this->appConfig, + $this->logger, + $this->demoData + ); + } + + public function testStatusReportsTheDemoDataStep(): void { + $this->appConfig->method('getValueString')->willReturn(''); + + $data = $this->controller->status()->getData(); + + // Absence is the defect this guards: a step the wizard is never told + // about cannot be offered and cannot be completed. + $this->assertArrayHasKey('demo-data', $data['steps']); + $this->assertFalse($data['steps']['demo-data']['done']); + // This app declares no REQUIRED step, so setup must never gate the app. + $this->assertTrue($data['completed']); + $this->assertSame(1, $data['version']); + } + + public function testStatusReportsTheStepDoneOnceDecided(): void { + $this->appConfig->method('getValueString')->willReturn('skipped'); + + $data = $this->controller->status()->getData(); + + $this->assertTrue($data['steps']['demo-data']['done']); + } + + public function testSkippingIsAnAnswerAndIsRecorded(): void { + // Declining must be persisted, otherwise the wizard re-offers the import + // on every visit and "no thanks" is impossible to express. + $this->appConfig->expects($this->once()) + ->method('setValueString') + ->with('openregister', 'demo_data_decided', 'skipped'); + + $response = $this->controller->runAction('skip-demo-data'); + + $this->assertTrue($response->getData()['success']); + } + + public function testUnknownActionIs404(): void { + $response = $this->controller->runAction('not-an-action'); + + $this->assertSame(404, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + } + + public function testInstallReportsHowMuchLanded(): void { + $this->demoData->method('install') + ->willReturn(['objects' => 30, 'registers' => 1, 'schemas' => 4]); + + $this->appConfig->expects($this->once()) + ->method('setValueString') + ->with('openregister', 'demo_data_decided', 'installed'); + + $data = $this->controller->runAction('install-demo-data')->getData(); + + $this->assertTrue($data['success']); + // A success message that names no count cannot be told apart from an + // import that wrote nothing — the defect this programme already shipped. + $this->assertStringContainsString('30', $data['message']); + } + + public function testAFailedInstallIsReportedAndLeavesTheStepUNDECIDED(): void { + $this->demoData->method('install') + ->willThrowException(new RuntimeException('OpenRegister is not installed.')); + + // 🔴 THE POINT OF THIS TEST. Recording the decision here would close the + // step for an operator who asked for demo data and received none: the + // wizard would never offer it again, and nothing would have been + // imported. + $this->appConfig->expects($this->never())->method('setValueString'); + $this->logger->expects($this->once())->method('error'); + + $response = $this->controller->runAction('install-demo-data'); + + $this->assertSame(500, $response->getStatus()); + $this->assertFalse($response->getData()['success']); + $this->assertStringContainsString('OpenRegister is not installed.', $response->getData()['message']); + } +} diff --git a/tests/Unit/Service/DemoDataServiceTest.php b/tests/Unit/Service/DemoDataServiceTest.php new file mode 100644 index 0000000000..b71ee1b41c --- /dev/null +++ b/tests/Unit/Service/DemoDataServiceTest.php @@ -0,0 +1,134 @@ +appPath = sys_get_temp_dir() . '/or-demo-' . uniqid(); + mkdir($this->appPath . '/lib/Settings', 0777, true); + + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppPath')->willReturn($this->appPath); + $this->appManager->method('getAppVersion')->willReturn('1.2.3'); + $this->appManager->method('getInstalledApps')->willReturn(['openregister']); + + $this->container = $this->createMock(ContainerInterface::class); + } + + protected function tearDown(): void { + $file = $this->descriptor(); + if (is_file($file) === true) { + unlink($file); + } + + @rmdir($this->appPath . '/lib/Settings'); + @rmdir($this->appPath . '/lib'); + @rmdir($this->appPath); + } + + private function descriptor(): string { + return $this->appPath . '/lib/Settings/openregister_mock_register.json'; + } + + private function service(): DemoDataService { + return new DemoDataService( + $this->appManager, + $this->container, + $this->createMock(LoggerInterface::class) + ); + } + + public function testIsAvailableIsFalseWithoutADescriptor(): void { + $this->assertFalse($this->service()->isAvailable()); + } + + public function testIsAvailableIsTrueWithADescriptor(): void { + file_put_contents($this->descriptor(), '{}'); + + $this->assertTrue($this->service()->isAvailable()); + } + + public function testInstallThrowsWhenNoDatasetShips(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/No demo dataset/'); + + $this->service()->install(); + } + + public function testInstallThrowsOnInvalidJson(): void { + file_put_contents($this->descriptor(), 'not json at all'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/not valid JSON/'); + + $this->service()->install(); + } + + public function testInstallNamesTheMissingAppWhenOpenRegisterIsAbsent(): void { + file_put_contents($this->descriptor(), '{"components":{"objects":[]}}'); + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppPath')->willReturn($this->appPath); + $this->appManager->method('getInstalledApps')->willReturn([]); + + // 🔴 The message must NAME the missing app. Asking the container for a + // class from an app that is not installed otherwise surfaces as an error + // about a class the operator never mentioned. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/OpenRegister/'); + + $this->service()->install(); + } + + public function testInstallCountsTheObjectsInTheFileNotTheImportersReply(): void { + file_put_contents( + $this->descriptor(), + json_encode(['components' => ['objects' => [['a' => 1], ['b' => 2], ['c' => 3]]]]) + ); + + // 🔴 THE PARAMETER NAMES ARE THE CONTRACT. install() calls this with + // named arguments, so a fake whose parameters are named differently + // fails at the call site rather than validating anything. + $importer = new class { + public array $seen = []; + + public function importFromApp(string $appId, array $data, string $version, bool $force): array { + $this->seen = ['appId' => $appId, 'version' => $version, 'force' => $force]; + + // Deliberately reports FEWER than the file holds: an object whose + // schema does not resolve is skipped, and the operator is told + // what was ASKED FOR so the discrepancy stays visible. + return ['registers' => [1], 'schemas' => [1, 1]]; + } + }; + $this->container->method('get')->willReturn($importer); + + $result = $this->service()->install(); + + $this->assertSame(3, $result['objects']); + $this->assertSame(1, $result['registers']); + $this->assertSame(2, $result['schemas']); + + // Its own configuration namespace, so a demo import cannot mask — or be + // masked by — a pending real configuration update. + $this->assertSame('openregister.demo', $importer->seen['appId']); + $this->assertTrue($importer->seen['force']); + } +} diff --git a/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts b/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts new file mode 100644 index 0000000000..541750bbc6 --- /dev/null +++ b/tests/e2e/spec-coverage/demo-data-setup-step.spec.ts @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: 2026 Open Register Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * ADR-111 — the demo-data setup step, exercised against a running instance. + * + * WHY THIS EXISTS. The programme that added demo data to this fleet shipped a + * defect that every unit test passed: the import printed `register "…" + * imported.` and seeded ZERO of the descriptor's objects. The unit tests could + * not see it — they mock the import service, so they validate the CALL and + * never its effect. + * + * So the assertion that matters here is not "the endpoint answers 200". It is + * that the response NAMES WHAT LANDED. A success message that cannot be told + * apart from an import that wrote nothing is exactly what let that defect + * through. + * + * WHY THE API AND NOT A CLICK-THROUGH. `CnAppRoot` opens the optional wizard + * only while an optional step is outstanding, and the CI seed deliberately + * settles those so the wizard stops covering the app in every test. The + * observable surface for this capability is therefore the contract the wizard + * calls — `GET /api/setup/status` and `POST /api/setup/action/{id}` — issued + * from inside the authenticated admin page so every call carries the real + * session and `OC.requestToken` through Nextcloud's `AuthorizedAdminSetting` + * middleware. A unit test with a mocked IAppConfig cannot show that middleware + * admitting the request; this can — and that middleware is precisely what the + * attribute on SetupController configures. + * + * WHAT THIS DELIBERATELY DOES NOT ASSERT. That the demo-data step is FIRST + * (ADR-111 rule 4) is a property of the manifest, which the app bundles rather + * than serves, so it is not observable from here. Gate 100 + * (`setup-demo-data-first`) checks it statically on every change. Claiming to + * prove it here would be asserting something this vantage point cannot see. + * + * @spec exclude ADR-042/ADR-111 setup contract; no per-app behavioural spec. + */ +import { test, expect, type Page } from '@playwright/test' +import * as path from 'path' + +const STORAGE_STATE = path.resolve(__dirname, '../.auth/admin.json') + +const BASE = '/apps/openregister' + +/** One authenticated JSON call issued from inside the logged-in admin page. */ +async function api( + page: Page, + method: string, + apiPath: string, +): Promise<{ status: number; json: any }> { + return await page.evaluate( + async ({ method, apiPath }) => { + const res = await fetch(apiPath, { + method, + headers: { + 'Content-Type': 'application/json', + // eslint-disable-next-line no-undef + requesttoken: (window as any).OC?.requestToken || '', + 'OCS-APIREQUEST': 'true', + }, + }) + let json: any = null + try { + json = await res.json() + } catch { + json = null + } + return { status: res.status, json } + }, + { method, apiPath }, + ) +} + +test.describe.configure({ mode: 'serial' }) + +test.describe('ADR-111 demo data', () => { + // The setup contract lives behind the admin middleware, so these calls need + // the real logged-in session `globalSetup` captured — not the suite's + // default Basic-auth header, which does not produce an `OC.requestToken`. + test.use({ storageState: STORAGE_STATE }) + + test.beforeEach(async ({ page }) => { + await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => (window as any).OC?.requestToken, null, { + timeout: 15000, + }) + }) + + test('setup status reports the demo-data step, so the wizard can offer it', async ({ + page, + }) => { + const res = await api(page, 'GET', `${BASE}/api/setup/status`) + + expect(res.status, 'setup/status must answer an authenticated admin').toBe( + 200, + ) + + // A step the endpoint never MENTIONS resolves to `done: false` forever — + // no operator action can clear it, and CnAppRoot then covers the app with + // the wizard in every fresh browser context. Absence is the defect here, + // not "not done". + expect( + Object.keys(res.json?.steps ?? {}), + 'setup/status must report a demo-data step', + ).toContain('demo-data') + }) + + test('installing the demo data reports HOW MUCH landed, not just success', async ({ + page, + }) => { + // 🔴 A REAL IMPORT, NOT A STUB. Measured on this fleet: the install arm + // took 42.8s on dossiq and 49.6s on shillinq, and exceeded the 30s + // default on one run. The operation is legitimately slow, and the + // assertion is worth its cost: it is the only check that the install + // WROTE something. + test.slow() + + const res = await api( + page, + 'POST', + `${BASE}/api/setup/action/install-demo-data`, + ) + + expect(res.status, 'the action must pass the admin middleware').toBe(200) + expect( + res.json?.success, + `install failed: ${JSON.stringify(res.json)}`, + ).toBe(true) + + // 🔴 THE COUNTS ARE THE ASSERTION. "Demo data installed" with no numbers + // is indistinguishable from an import that wrote nothing — the exact + // defect this programme shipped and had to fix. A message carrying a + // positive object count is the only evidence the data reached the + // instance. + const message = String(res.json?.message ?? '') + const numbers = (message.match(/\d+/g) ?? []).map(Number) + + expect( + numbers.some((n) => n > 0), + `the install message must name a non-zero object count; got: "${message}"`, + ).toBe(true) + }) + + test('re-installing is safe, because the step promises it is', async ({ + page, + }) => { + // The step body tells the operator it is "safe to run more than once". + // That sentence is a contract; this asserts the server keeps it rather + // than erroring or reporting failure on a second pass. + const again = await api( + page, + 'POST', + `${BASE}/api/setup/action/install-demo-data`, + ) + + expect(again.status).toBe(200) + expect( + again.json?.success, + `a second install must not fail: ${JSON.stringify(again.json)}`, + ).toBe(true) + }) +}) From 6fcaf04bf3c2cd098ab4452d523c7ba99b0f5a06 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 29 Aug 2026 04:18:55 +0200 Subject: [PATCH 07/42] fix(e2e): settle the demo-data decision so the wizard stops masking clicks (#3011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-111 setup step is OPTIONAL, and CnAppRoot opens the non-gating wizard as a full modal mask while any optional non-info step is reported not-done — in every fresh browser context, so once per spec. Merging the setup wizard therefore turned this app's whole E2E suite red without touching a single spec: the call log reads "locator resolved to