Release: merge development into beta - #3204
Conversation
The 2.0.6-beta.20260831102212 release bumped the version on beta. Without this, development stays behind beta and the next development -> beta promotion conflicts on the version file. Version files resolve to development's side, which is the higher line, so this never moves a version backwards.
Dependabot cannot propose composer updates for this app. Its updater fails
with:
Your requirements could not be resolved to an installable set of packages.
- edgedesign/phpqa[v1.27.0, ..., v1.27.2] require ext-xsl * ->
it is missing from your system.
config.platform pins php 8.3 so composer resolves against a known PHP version,
but says nothing about extensions. edgedesign/phpqa requires ext-xsl and the
resolving environment does not have it, so the resolve fails before any bump
can be computed.
CI is unaffected, which is why this went unnoticed: composer install replays
the committed lock and never re-resolves, so the pipeline stays green while
dependabot -- which does re-resolve -- fails every time. The shared quality.yml
installs no xsl extension anywhere and never invokes phpqa.
Declaring ext-xsl beside the php pin makes resolution assume exactly what the
committed lockfile already assumes. Verified in a clean composer:2 container
without --ignore-platform-reqs: the same file fails without this line and
resolves (103 installs, lock written) with it.
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
The 2.0.7 release bumped the version on main. Without this, development stays behind main and the next development -> main promotion conflicts on the version file. Version files resolve to development's side, which is the higher line, so this never moves a version backwards.
….0.7 chore(release): sync main back into development
….0.6-beta.20260831102212 chore(release): sync beta back into development
Levels this app with the fleet. 2.27.2 adds two fixes the earlier 2.27.0 pin does
not carry:
* headerless is no longer chromeless — a flat KPI card in a borderless wrapper
had no card, border or background at all;
* a stat `variant` paints from the `-text` tokens rather than the fill tokens,
which failed WCAG AA at 1.08:1 as a foreground colour. The KPI colour
cleanup converted hardcoded values to `variant`, so this is what makes those
conversions contrast-safe.
Lockfile only, and npm pruned nothing.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ⏭️ | ||||
| phpcs | ⏭️ | ||||
| phpmd | ⏭️ | ||||
| psalm | ⏭️ | ||||
| phpstan | ⏭️ | ||||
| phpmetrics | ⏭️ | ||||
| eslint | ⏭️ | ||||
| stylelint | ⏭️ | ||||
| build | ⏭️ | ||||
| composer | ⏭️ | ⏭️ | |||
| npm | ⏭️ | ⏭️ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ⏭️ | ||||
| REUSE | ⏭️ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-31 11:40 UTC
Download the full PDF report from the workflow artifacts.
Bumps [zod](https://github.com/colinhacks/zod) from 4.4.3 to 4.5.2. - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](colinhacks/zod@v4.4.3...v4.5.2) --- updated-dependencies: - dependency-name: zod dependency-version: 4.5.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [marked](https://github.com/markedjs/marked) from 12.0.2 to 18.0.11. - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](markedjs/marked@v12.0.2...v18.0.11) --- updated-dependencies: - dependency-name: marked dependency-version: 18.0.11 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ⏭️ | ||||
| phpcs | ⏭️ | ||||
| phpmd | ⏭️ | ||||
| psalm | ⏭️ | ||||
| phpstan | ⏭️ | ||||
| phpmetrics | ⏭️ | ||||
| eslint | ⏭️ | ||||
| stylelint | ⏭️ | ||||
| build | ⏭️ | ||||
| composer | ⏭️ | ⏭️ | |||
| npm | ⏭️ | ⏭️ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ⏭️ | ||||
| REUSE | ⏭️ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-31 12:14 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-31 12:46 UTC
Download the full PDF report from the workflow artifacts.
…0260831125107 chore(sync): carry beta back into development
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-31 13:15 UTC
Download the full PDF report from the workflow artifacts.
#3220) The override read: "apexcharts": "License is MIT - license-checker misreads logo URL as custom license" That was true once and is not true now. apexcharts stopped being MIT at 6.0.0: 4.7.0 and 5.0.0 are MIT, 6.0.0 and 7.0.0 are SEE LICENSE IN LICENSE. This app declares ^7.0.0 and locks 7.0.0, so the checker flagging it is CORRECT and the override was suppressing a true finding rather than correcting a false one -- the licence gate was reporting green on a dependency that is not open source. Nothing about the dependency changes here. apexcharts 7 stays. What changes is that the file now says what is actually true: - 6.0.0+ ship a dual-license: free under a Community License for organisations below USD 2M annual revenue, paid above it, and sublicensing under different terms is not permitted. - Conduction confirmed 2026-08-31 that it is below that threshold and so qualifies under the Community License. - The override is therefore a deliberate acceptance, dated and attributed, not a claim that the checker is wrong. The note also records the redistribution question, because the Community License binds the organisation USING the software rather than only the one shipping it, and this app is distributed through the Nextcloud App Store. An installing tenant above USD 2M would need its own commercial licence. That is flagged for a decision, not decided here. For reference: dossiq is on apexcharts 4.7.0 (MIT) with no override, and its bump to 7.0.0 was closed today on these grounds (dossiq#1527). apexcharts 5.x is the last MIT release if the fleet ever needs to return to one. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-31 13:48 UTC
Download the full PDF report from the workflow artifacts.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-31 14:34 UTC
Download the full PDF report from the workflow artifacts.
The 2.0.9 release bumped the version on main. Without this, development stays behind main and the next development -> main promotion conflicts on the version file. Version files resolve to development's side, which is the higher line, so this never moves a version backwards.
…0260831154312 chore(sync): carry beta back into development
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-31 16:06 UTC
Download the full PDF report from the workflow artifacts.
Dependabot cannot update these packages. Its npm updater fails with: npm error code EOVERRIDE npm error Override for <pkg> conflicts with direct dependency The package is listed BOTH as a direct dependency and as a literal overrides entry pinned to the same range. Dependabot bumps the direct dependency and does not move the override, npm rejects the mismatched pair, and the update is abandoned -- so these packages can never be updated, security advisories included. npm's self-reference is the idiom for exactly this: $<name> tells the override to follow whatever the direct dependency resolves to, instead of a range that has to be kept in lockstep by hand. This repo already uses it (see the vue entry under @vue/test-utils). Transitive consumers still get pinned to one version -- the direct dependency's -- which is what the override was there to do. What changes is that the pin tracks the dependency instead of duplicating it. Verified in a clean container: bumping the direct dependency with the literal override reproduces the EOVERRIDE above; with the self-reference the same bump resolves. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-31 16:45 UTC
Download the full PDF report from the workflow artifacts.
…SetupHandler (#3233) * fix(db): bind catch(Exception) to a class that exists, and drop dead SetupHandler Two findings that psalm-baseline.xml had been carrying as accepted noise. Neither is noise. 187 -> 184. lib/Db/Register.php caught `Exception` with no import, inside namespace OCA\OpenRegister\Db. PHP resolves an unqualified catch type against the CURRENT namespace, so that block was bound to OCA\OpenRegister\Db\Exception, which does not exist, and it could never catch anything. Verified rather than reasoned about: namespace Demo\Sub; try { throw new \RuntimeException("boom"); } catch (Exception $e) { ... } // no import -> NOT CAUGHT -> RuntimeException: boom Adding `use Exception;` binds it to the global class the code plainly meant. Checked the rest of lib/: ObjectsController and Repair/RenameDutchColumns also catch a bare `Exception`, but both import OCP\DB\Exception, so their catches bind to a real class and are deliberate. Psalm flagged exactly the one file that was broken. lib/Service/SettingsService.php declared a property, a constructor parameter and two docblocks for SetupHandler, a class that exists nowhere in the tree. It was written and never read, so it was dead API surface that would fatal the moment anything tried to supply it. Removed, along with the `setupHandler: null` argument in Application.php and the matching positional null in the two tests that build the service positionally. Verified with the repo own vendor/bin/psalm 5.26.1 on PHP 8.3: errors with the baseline emptied went 187 -> 184, and the regenerated baseline is green. PHPUnit was NOT run here: it needs the Nextcloud server bootstrap, which only exists inside a server checkout. * fix(tests): realign the five positional constructor calls I missed Removing the SetupHandler parameter shifted every later POSITIONAL argument, and I checked for that by reading the first line after each `new SettingsService(` and concluding the file used named arguments. It uses both. Five calls further down SettingsServiceTest.php are positional, and CI caught it as 13 TypeErrors, all the same one: Argument #14 ($appName) must be of type string, MockObject_IAppContainer given which is the shift saying exactly what it was. Verified by position this time rather than by sampling: with the parameter gone, appName is slot 14, and all seven positional call sites across the three test files now carry a string literal there. --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-31 17:06 UTC
Download the full PDF report from the workflow artifacts.
….0.9 chore(release): sync main back into development
…etter (#3236) Two more entries the Psalm baseline was carrying. Both are real. 184 -> 180. PollLinkService had a fallback that could never run: $pollId = (int)$this->db->lastInsertId("oc_polls_polls_id_seq"); if ($pollId === 0) { // Fallback for drivers without sequence support. $pollId = (int)$this->db->lastInsertId(); } OCP\IDBConnection::lastInsertId(string $table): int takes a REQUIRED argument, so the no-arg call raises ArgumentCountError on exactly the drivers the fallback was written for. The surrounding catch (Throwable) turned that into "Failed to create poll", which blames the insert rather than the id retrieval. A zero now falls through to the explicit throw that was already there and says what actually happened. Checked the other four lastInsertId call sites. MagicMapper and DedupCollidedSchemasCommand pass a table. DbalObjectSourceProvider calls it with no argument, but on a DBAL Connection rather than IDBConnection, where the parameter is optional. Psalm flagged only the one that was wrong. CaseToken declared getToken TWICE: @method string|null getToken() @method void getToken(?string $token) <- copy-paste of the setter @method void setToken(?string $token) Psalm binds to the second, which is why two correct $row->getToken() calls read as "Too few arguments". Removed; setToken on the next line was already right. Swept lib/Db for the same slip: this was the only one of them. Verified with the repo own vendor/bin/psalm 5.26.1 on PHP 8.3: all four TooFewArguments are gone, errors with the baseline emptied went 184 -> 180, regenerated baseline is green. PHPUnit was NOT run here, it needs the Nextcloud server bootstrap that only exists inside a server checkout. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-31 17:13 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-31 17:28 UTC
Download the full PDF report from the workflow artifacts.
…reader (#3419) `saveObject()` resolved a register/schema from its arguments, set them as the service's current context, and never put back what it found. `find()` has restored its context in a `finally` since BUG-OBJ-13. Everything else did not. That is what openregister#3408 cost: `ImportCredentialBrokerRegister` saved two example objects through `saveObject(register: credential-broker, schema: brokeredcredential)`, and four repair steps later a migration whose own read was unscoped inherited that pair and copied two `brokeredcredential` examples into the flow table as flows. Deterministically, on every install, with no error anywhere. That was fixed at its call site; this is the same fix at the source. The contract is unchanged for callers who mean to anchor the service: `setRegister()`/`setSchema()` still persist, because that fluent pattern is how a dozen controllers scope a read. An entry point that takes `register:` / `schema:` ARGUMENTS is scoping itself for one operation, so it now restores in a `finally`: saveObject, saveObjects, saveObjectsStreaming, patchObject, deleteObject and findSilent. Also fixes the last two callers that passed `register`/`schema` at the top level of a findAll config, where `prepareFindAllConfig()` reads nothing, so the filters were inert and the read ran against leftover context: VocabularyController and VocabularyImportService. Their tests now assert the arguments handed to findAll(), because a mock that answers any question cannot report a wrong one.
`nc-organisation` exists to retire the leaf-app `organization` schemas. A schema slug is global per organisation, so opencatalogi, stackiq and others each declaring their own meant `SchemaMapper::find()` returned whichever row it reached first. opencatalogi migrated (opencatalogi#1411). Stackiq could not, and measuring why is what produced this change: its setup walkthrough says "Click New and save an organisation" and advances on `object-created`. Migrating that onto a read-only schema would retire a working flow rather than move it. Every other app with an `organization` schema has the same shape, because an app that only ever READ organisations would not have declared a schema for them. So the read-only projection could not do the job it was built for. `OrganisationObjectSourceProvider` now implements `WritableObjectSourceProvider`. The dispatch it plugs into already existed and already gated correctly: `SaveObject::delegateObjectSourceWrite()` delegates only when the schema annotation carries `readOnly: false` AND the provider implements the interface. CREATE GOES THROUGH `OrganisationService::createOrganisation()`, not through the mapper. That is what makes the write safe rather than a second, thinner path to a tenant: slug generation, owner assignment, admin-user membership, the admin-group RBAC grant and slug-collision recovery all still happen. The provider applies the remaining identity fields afterwards. UPDATE REQUIRES OWNERSHIP. Membership is enough to read the projection; writing needs `isOrganisationAdmin()`, which is the instance admin or the organisation's owner, so the projection and the rest of the app cannot disagree about who administers an organisation. A denied write answers exactly as an absent one does, and the difference is logged. It does NOT follow a merge chain although the read does: following one would silently edit the survivor while the caller believes it is editing the record it addressed. DELETE REFUSES. An organisation is the tenant boundary; deleting one through the object API would orphan every object scoped to it, from a caller that thinks it is removing a reference record. Merging is the operation that retires an organisation. THE PART THAT WOULD HAVE SHIPPED INERT. `SeedDirectoryVirtualSchemas::ensureSchema()` returns the moment the schema is found, so setting `readOnly: false` only on the create branch would have reached fresh installs and no existing instance, while `delegateObjectSourceWrite()` reads exactly that annotation. The provider would implement the interface, the seed would report success, and every write would still be refused by a message naming the provider rather than the annotation. The seed now reconciles the flag on an existing schema too, touching only the `readOnly` key, and a failed reconcile logs at ERROR with the consequence rather than degrading into `run()`'s generic warning. VERIFIED AGAINST A LIVE INSTANCE, because a passing seed test proves the code and not the migration. On an instance whose `nc-organisation` was already seeded read-only: `occ maintenance:repair` flipped it and left the other eight virtual schemas alone; `POST /api/objects/2/38` produced a real organisation with a generated slug and an assigned owner; `PUT` wrote a projected field through. A delete leaves it standing, though for the wrong reason: the controller resolves the uuid through `MagicMapper` before the dispatch and 404s, so `remove()` is never reached. That is a pre-existing gap shared with every read-only projection, recorded as task 5.2, and the reason the delete refusal is tested at the provider. Also waives gate-57 on `purgeExpiredObjectsRaw`, which has failed on development since it landed earlier today. It is declared on `ObjectServiceInterface`, so its callers are other apps and no in-repo call site can exist. The waiver says explicitly not to satisfy the gate with a route: the method bypasses soft-delete and the audit trail by design. 18,925 tests green (9 new). phpcs, psalm and phpstan clean, phpmd clean on every file this touches, and all 75 applicable hydra gates pass. phpmd's three pre-existing complexity findings in MagicSearchHandler and LeafScriptListener are untouched: refactoring a hot SQL builder inside an organisation-projection change is not a trade worth making.
…r exist (#3423) OpenCatalogi retired its CMS (opencatalogi#1422). The `page` and `menu` schemas, both controllers and their 8 routes went with it, because pages and menus are Portaliq's now. This script was one of the four consumers that removal was confirmed against, and it was the one left standing: `step_verify_opencatalogi` called `/api/menus` and `/api/pages/home` and logged an error when they did not answer. They answer 404 by design, so the step reported two failures on a healthy instance. The checks are removed rather than repointed. This step verifies OPENCATALOGI's configuration; asserting against Portaliq's `/api/content/{pages,menus}` here would fail for reasons this script cannot explain, and Portaliq's own suite owns that surface. The publication-register and settings-import checks either side are untouched. `bash -n` clean, and all 75 applicable hydra gates pass. A repo-wide sweep for `page_schema`, `menu_schema` and the two route prefixes finds nothing else.
`onError: stop` — the default — ended the run as `stopped` with no `error`, which is byte-for-byte what a deliberate Stop node returns. A run wrecked by a broken step and a run an author ended on purpose were therefore the same row, and the only trace of the difference lived in the last entry of the JSON `log`, which no query reaches: `WHERE status = 'stopped'` returns a healthy guard branch and a wreck side by side and nothing says which is which. Measured on dossiq's shipped `Case behandeling` flow against the demo caseload: nine runs died on `status_not_found_on_case_type` and every one of them read as a clean end. A step failure now ends the run as `failed`, carrying the step's message on the `error` column. Both were already in the vocabulary and used elsewhere (FlowRun::STATUS_FAILED, ranked most-severe in FlowRunCommit::SEVERITY); this path simply was not reaching for them, so there is no migration and no new state for the UI to learn. `dead_letter` keeps its own status and gains the message. Fixed in BOTH walks. The single-stream walk and the persisted stream walk each decide this for themselves, and leaving one behind would make which walk happened to run decide whether a wreck is queryable. A second consequence falls out: `SubFlowNode::itemsFrom()` documents `stopped` as a SUCCESS terminal state — it is what an End node does — and accepted it. A sub-flow whose step failed was therefore handing its stale items back to the parent as though it had finished. It now raises, and reaches the parent as the step failure it is. Proven red first: testAFailedStepIsDistinguishableFromACleanStop runs BOTH a clean stop and a step failure in one test and asserts they differ, because two separate tests each asserting their own status both pass on the broken code — which is how this shipped. Three existing tests encoded the defect and are updated. Verified locally (CI is bottlenecked): phpunit 18926 tests 0 failures, phpcs, phpstan, psalm and phpmd all exit 0 on the changed file.
…en-set feat(credential-broker): OAuth2 token-set kind with refresh, and the connect flow with relay
…t all (#3428) * fix(deleted): refuse to purge a live object, and an archival record at all `DELETE /api/deleted/{uuid}` guarded with `getDeleted() === null`, meaning "only purge what is already in the trash". `ObjectEntity::$deleted` defaults to `[]`, and the row hydrator skips NULL columns rather than calling their setter, so a live object answers `[]` and the guard never fired. The endpoint permanently destroyed live records, including on schemas declaring `x-openregister-archival` that the normal delete path refuses with 403 SCHEMA_ARCHIVAL_IMMUTABLE. Reproduced on a dev instance: a live `dossiq/case` row survived `DELETE /api/objects/dossiq/case/<uuid>` with 403, then vanished from the magic table entirely on `DELETE /api/deleted/<uuid>`. - `ObjectEntity::isSoftDeleted()` is now the one honest answer, and every guard that meant "already in the trash" asks it. - `Schema::hasArchivalAnnotation()` is the single definition of the archival rule; `ObjectService::deleteObject()` and the purge routes both read it, so the two delete doors cannot drift apart. Purge refuses with `operation: "purge"` and fails closed on an unresolvable schema. - `occ openregister:objects:purge <uuid>... [--force] [--apply]` is the sanctioned administrative path that can still destroy an archival record. Dry-run by default; `--force` is required for an archival or a live row. - Same null-vs-[] shape fixed in `RetentionService` and `DestructionService`, where `softDeleted` / `alreadySoftDeleted` were unconditionally true. - `RbacGroupCollector::RESERVED_PRINCIPALS` gains `authenticated`. Three RBAC resolvers short-circuit it before any IGroupManager lookup, so it is a virtual principal, not a group. Leaving it out provisioned an empty Nextcloud group of that name and made `occ openregister:declared-groups` report the instance's broadest working grant as granting nobody anything. Three existing tests asserted the defect as correct behaviour, two of them with a comment explaining the `[]`-not-null mechanism. They now pin the contract. `ArchivalDeleteGateTest` reimplemented the gate condition locally and so agreed with itself; it now calls the production predicate. * chore(purge): add the @SPEC tags gate-16 requires on the new command
…nd honour archival immutability (#3430) * fix(bulk): make bulk delete actually delete, account for every row, and honour archival immutability `POST /api/bulk/{register}/{schema}/delete` deleted nothing. On a rig it answered `success: true, requested_count: 1, deleted_count: 0, skipped_count: 0` for both an archival row and a non-archival control, leaving both in place — an endpoint that neither acts nor says why, whose own accounting does not add up. The row was lost in `PermissionHandler::filterUuidsForPermissions()`, the only permission gate on this route. It resolved its candidates with `MagicMapper::findAll(ids: $uuids, includeDeleted: true)`, and that method returns `[]` — after one log warning and nothing else — whenever it is called without a Register AND a Schema entity, because it has no magic table to read. This handler has neither. So the filter answered `[]` for every input, `ObjectService::deleteObjects()` ran zero loop iterations, and the three counts collapsed to a shape that named no failure. The lookup that belongs here is the cross-table one that `batchResolveDeleteScopes()` already runs over the very same UUIDs a few lines later. With the loop executing, two more things were wrong. EVERY REQUESTED UUID NOW LANDS IN EXACTLY ONE OUTCOME BUCKET. UUIDs the permission filter removed were dropped silently, and so was any UUID whose delete handler answered `false` rather than throwing — the loop's `if ($result === true)` had no else. `requested = deleted + skipped` now holds, `skipped_reasons` says why each row survived, and `success` reports the shortfall the way the bulk save path already does: a row this endpoint refused is a row the caller otherwise believes it deleted. ARCHIVAL IMMUTABILITY REACHES THE THIRD DOOR. The loop calls the delete handler directly, so it never passed `ObjectService::deleteObject()`'s gate: a schema declaring `x-openregister-archival` — refused with 403 by `DELETE /api/objects/...` and, since #3428, by the purge route — was destroyed here without a word. Both delete paths now go through one `rejectIfArchivalImmutable()`, which asks the same `Schema::hasArchivalAnnotation()` #3428 made the single definition. The bulk gate reads the ROW's own schema, not the route's: a bulk delete spans magic tables, so gating on the controller's scope would check somebody else's annotation, and a row whose schema cannot be resolved is refused rather than deleted. Verified on a throwaway rig (Nextcloud 32, own compose project, torn down with `down -v`): a permitted row is genuinely gone (404, absent from the list, present in the trash); an archival row submitted through a non-archival route is refused by name with `SCHEMA_ARCHIVAL_IMMUTABLE` and still answers 200; a nonexistent UUID is reported rather than dropped; and `requested = deleted + skipped` holds in every case. Tests call the production predicate rather than a copy of its condition, so removing the gate fails them instead of agreeing with them. Both carry a negative control: an unresolvable UUID must not survive the filter, and the same row on a schema without the annotation must still be deleted. * docs(bulk): the bulk delete loop never hard-deletes, so stop claiming it does The docblock said a second delete of an already-trashed row escalates to a hard delete. It does not: the loop calls DeleteObject::deleteObject() without `permanent: true`, and that flag is the only thing that destroys a row. The claim was unfalsifiable for as long as the loop ran zero iterations; on the rig the second delete answers `deleted_count: 1` and the row is still in the magic table with its tombstone. Destroying a row for good is the purge route or the occ command, both of which refuse an archival record since #3428. * fix(bulk): a uuid named twice is one deletion, so count it once `requested = deleted + skipped` is what makes this response readable, and a repeated entry left it unsatisfiable: the service resolves each uuid once (the cross-table lookup runs array_unique on its input), so counting the raw list as requested_count counted a row the loop only ever saw one of.
* fix(schemas): close the fourth delete door on archival schemas `Schema::hasArchivalAnnotation()` is the single definition of archival immutability (#3428). Three deletion paths read it. `SchemaDeletionService` never mentioned it, and it backs the two routes that delete the most: `POST /api/bulk/{r}/{s}/delete-objects` (and its legacy `/delete-schema`) and `DELETE /api/schemas/{id}?deleteObjects=true`. Reproduced on a throwaway rig before changing anything. On an archival schema with three rows, `delete-objects` with `hardDelete: true` answered 200 `success: true`, took the magic table from 3 rows to 0, and left the trash empty: the records were destroyed, not tombstoned. The cascade did the same and additionally dropped the table and deleted the schema. The service audits every object into the hash-chained trail before dropping it, which is a design for deliberate destruction. That is why the answer is refuse rather than permit-with-audit: an audit entry records that a record was destroyed, it is not the record, and a retention obligation is discharged by still holding the row. `deleteObjectsBySchema()` refuses outright and takes no override parameter, because both of its callers are HTTP routes and an override they could pass through would put archival destruction back on the network. `cascadeDeleteSchema()` refuses unless the caller passes an explicit `archivalOverride`; the HTTP cascade never does, and `occ openregister:schemas:prune-retired` does only when the operator typed `--force-archival`. That flag is deliberately separate from `--force`, which means "this schema still owns objects" and is passed by operators who have said nothing about retained records. The refusal surfaces as 403 with the existing structured body instead of a 500. Also closes a silent destruction path found while sweeping: `MagicMapper::bulkUpsert()` recovered from a failed write by calling `ensureTableForRegisterSchema(force: true)`, which DROPS the table before recreating it. The `does not exist` message test that selects that recovery is wider than the missing table it names, since PostgreSQL prints the same substring for a missing column, type, operator or constraint. It now checks the live connection first and re-throws when the table is there. * style(prune): drop em-dashes from the operator-facing output The Conduction voice bans em-dashes in user-facing strings, and `occ openregister:schemas:prune-retired` prints five of them: four inherited, plus the archival SKIP line added in the previous commit. Replaced with a colon, a period or a comma. Wording is otherwise unchanged, and the archival SKIP still names `x-openregister-archival` and `--force-archival`.
…g objects (#3434) `POST /api/configurations/import` answered HTTP 500 with Nextcloud's HTML error page for any register descriptor that ships seed objects. `ConfigurationsController::import()` linked the result with $objectIds = array_map(static fn ($obj) => $obj->getId(), $result['objects']); and `$result['objects']` is not uniformly entities. `ImportHandler` appends an `ObjectEntity` at two sites and a BARE ID at two others (`$result['objects'][] = $existingObject->getId()`). On the id path that is a method call on an int: a `TypeError`. A `TypeError` is an `Error`, not an `Exception`, so the method's `catch (Exception $e)` did not see it either. The endpoint documents a JSON 400 for a failed import; what a caller got was a 500 and an HTML page, with nothing to distinguish a malformed descriptor from a crash in the handler. HOW IT WAS FOUND, which is the part worth keeping. The fleet's schema-slug collision inventory had been measured three times from descriptors and been wrong three times — reading the components key instead of the `slug` field, double-counting two working clones of one app, and merging fragments onto a monolith that declares no schemas. So it was re-measured by importing all eighteen apps' registers through this endpoint and reading the schema rows back. Seventeen imported. stackiq's returned 500, and stackiq's register is the one that ships seed objects. `idsOf()` now accepts an entity or a bare id and drops anything that is neither, so the stored id list never holds a hole. The catch becomes `Throwable`, so a bug in this method answers in JSON like every other failure. Verified live: the same descriptor that returned 500 returns `200 Import successful` and links its register. NOT FIXED HERE: `ImportHandler`'s return shape is still inconsistent. Normalising it there is the better fix and a wider one, since other callers read the same array and would have to move together. The controller no longer depends on which of the two it gets, which is what unblocks the import. The mixed-list test is the one that matters: entities-only and ids-only both passed before this change. 19,098 tests green (6 new). phpcs, phpstan clean and all 75 applicable hydra gates pass. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(gdpr): retention wins over erasure, and over the cascade
Two rulings from openregister#3432, which reported both rather than guessing.
RETENTION WINS OVER ERASURE: refuse, record, report.
An erasure request that reaches a record on a schema declaring
`x-openregister-archival` does not lift the obligation holding it. Art-17(3)(b)
stands down for processing the law requires, and for a Dutch government record
the Archiefwet is that law. Those rows are refused, the refusal is recorded, and
it travels back with the rest of the answer so the officer replying to the data
subject can name what stayed and why. The refusal is per row: every non-archival
match in the same request is still erased.
Four erasure paths reach such records, and none of them asked:
* DsarService::eraseObjectsForSubject (art-17 vergetelheid)
* DataSubjectRequestService::erase (consumable art-17)
* AvgRetentionService::erasePastRetention (AVG bewaartermijn sweep)
* RetentionSweepService::runSweep (DSAR case sweep) -- the sibling, not in the
reported list. Worse than blind: its delete hands `_retentionSweep: true` to
ObjectService, the flag whose whole purpose is to wave a row past the
archival gate, so an archival case was hard-deleted with no refusal anywhere
in the chain.
DataSubjectRequestService looked covered and was not. Its `retentionGuard()`
docblock claims "immutable archival status", but `validateNotImmutable()` reads
the OBJECT's `retention.archiefstatus`; `x-openregister-archival` is declared on
the SCHEMA. A record on an archival schema not yet stamped `vernietigd` or
`overgebracht` went straight through.
A RETAINED RECORD STAYS LIVE EVEN WHEN ITS PARENT GOES.
ReferentialIntegrityService batch-cascades with `hardDelete: false`, so an
archival child of a non-archival parent was tombstoned by a delete aimed at
somebody else. The cascade skips it, names it in the return value, and the
parent delete still proceeds: a retained child refuses the cascade, it does not
block the delete that reached it. `applyDeletionActions()` returns
`{retained: [...]}` instead of void, and DeleteObject stops counting a retained
target as a cascade casualty -- the old count was the number of rows the
analysis WANTED to delete, which would have told a bulk caller that a record it
can still see was deleted.
ONE PREDICATE, FIVE CONSUMERS, NO SIXTH RULE.
`Schema::hasArchivalAnnotation()` (openregister#3428) stays the single
definition. New `ArchivalRetentionGuard` is the fifth consumer of it beside the
four HTTP delete doors; it asks the question and does not restate the condition.
It fails closed, reporting an unresolvable schema under its own ground so a
handler can tell "the law holds this" from "we could not tell", and it reads the
schema unscoped because every caller is a cron with no session or an officer
sweeping across tenants -- a scoped miss plus fail-closed would silently stop a
whole sweep.
A TEST ASSERTED THE OLD BEHAVIOUR AS CORRECT.
`DataSubjectRequestServiceTest::testEraseRespectsLegalHold` pinned
`complete === true` for a run that had refused one of its two records: the same
shape 4be2adc found in a controller test asserting `success === true` for a
batch that had refused a row. Inverted deliberately. An erasure is complete when
the data is gone, so `held` and `withheld` now count against it alongside
`failed`.
* fix(gdpr): tag the erase surface against the spec that still resolves
The nearest sibling, DataSubjectRequestController::erase(), points at
openspec/changes/gdpr-data-subject-rights/tasks.md, which was archived to
openspec/changes/archive/2026-06-23-.../ and no longer exists. Copying it would
have cleared gate-16 with a dangling path, which is exactly what SpecTag cannot
see: it checks a tag EXISTS, never that it RESOLVES.
…uration store (#3438) The validate job fails on untouched development with "/store must have required property 'schema'". The manifest is correct; the validator was reading a stale schema. package.json asked for ^2.36.0 but package-lock.json pinned 2.36.0, and 2.36.0 still declares the store block as required: ["schema"]. From 2.36.1 that is an anyOf: an objects store names schema, a configuration store names types. OpenRegister ships a configuration store (types, localRegister), which only the newer schema accepts. So this is a lockfile bump, not a manifest change. Adding a store.schema value would be inventing a product contract to satisfy a stale validator.
…t it destroys (#3440) Two defects in a DESTRUCTIVE command, both found by running it on a live instance rather than by reading it. IT HANDLED ONE ROW PER (application, slug). `findByApplicationAndSlug()` caps at one, which is correct where the pair is unique. It is not unique in practice, and this command exists because it is not: the import unions schema ids and never removes one, so a descriptor edit leaves the old row behind and a later import can add a second under the same pair. Measured: opencatalogi owned `document` at ids 39 AND 40. The command took 40, printed `Pruned=1, skipped=0`, and left 39 answering every lookup. An operator would reasonably read that as done. `SchemaMapper::findAllByApplicationAndSlug()` returns every row, oldest first, and the command loops over all of them. ITS SAFETY GUARD FAILED OPEN. The dry run said `0 object(s)`; the apply reported `objects removed=2`. The guard that refuses to prune a schema still holding data was counting a different set from the one the delete destroys, in two independent ways: 1. The count ran through `MagicSearchHandler`, which applies RBAC and multi-tenancy by default. `occ` has no session, so the CLI reads as Anonymous, and rows nobody anonymous may see counted as ZERO. 2. It iterated only the registers that REFERENCE the schema. A schema referenced by none counted zero BY CONSTRUCTION — and that is exactly the state a half-pruned schema is left in. Either path let an operator read "0 objects, safe to delete" and then destroy rows. The guard now asks the deletion service what a cascade WOULD remove, over the same table set the delete enumerates, with `_rbac` and `_multitenancy` off and `_includeDeleted` on — a soft-deleted row is still a row the table drop destroys, and counting only live rows tells the operator nothing about the recovery they are giving up. A table that outlives its register counts as at least one, because the delete drops it without reading it. And when the two numbers still disagree, the command says so instead of leaving the operator to infer it: the guard was wrong about what it was protecting, and that is worth a line of output. The command's own `MagicMapper` dependency is gone with its count. 19,116 tests green (2 new, both for the cases the existing suite structurally could not see: two rows under one slug, and a guard/delete count mismatch). phpcs, psalm and phpstan clean, and all 75 applicable hydra gates pass. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… that fail every push (#3443) * fix(quality): the five phpmd findings that have been failing development `PHP Quality (phpmd)` is red on development, and has been long enough that nobody reads it. Five findings, four of them complexity: LeafScriptListener::leafAppsFor() was over both the cyclomatic and the NPath threshold because one loop body held four separate reasons a descriptor contributes nothing. Each reason is now a `return null` in bundleAppFor(); the rule itself is unchanged, and its ten tests still pass. The `$m` in currentAppId() is $matches. MagicSearchHandler::buildMetadataOperatorConditionsSql() had 486 paths through it, from three independent operator families sharing one method. They are three methods now, emitted in the same order, so the SQL is byte-for-byte what it was. ConfigurationsController coupled fourteen types. Two go: the import assembled a Configuration by hand and then inserted it, though the mapper's createFromArray() does both, and two of the setters wrote created/updated that insert() stamps again anyway. The export filename asks date() for the same string a DateTime instance was built to format. A test now pins which fields the import carries across. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(l10n): correct a schema baseline that never matched its own tree `Frontend Check (check:schema-l10n)` reports "22 schema string(s) added with no catalogue key". No schema string was added. The baseline was written as 601 in the same commit that added the register descriptors (5c9fb7a), and that commit's own tree already measures 623 — checked by archiving the tree at that sha and running the script against it. The recorded number never described the tree it shipped with. The cost of leaving it is that the ratchet is dead: at 623 against 601 the leg fails on every push, so a genuinely new uncovered string is invisible inside a red that everyone has learned to ignore. At 623 the next one fails the build, which is the whole point of a ratchet. This does NOT translate anything. 623 schema strings still render in English inside an otherwise Dutch form, and each is 36 translations because every required locale owes a value for every English key. That is real debt and it is now correctly stated rather than understated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…are Run button (#3442) * feat(setup): ask which example data to load, as cards, instead of a bare Run button The demo-data step was a single run-action with a Run button: no name for what it would import, no count, and no way to say no. Declining was literally unsayable — this app implemented `skip-demo-data` and no manifest step could reach it, so the step stayed outstanding and CnAppRoot reopened the wizard over every page until the operator imported data they did not want. It is a choice step followed by a load step now. The choice renders as cards (`display: cards`), and its options come from the status endpoint (`optionsSource: datasets`) rather than from a copy in the manifest, so a card promises the count the file about to be imported actually carries. Declining is one of the cards. DemoDataService gains listChoices() and shippedObjectCount(); the controller stores the pick under `demo_dataset`, refuses a dataset nothing answers to rather than storing it, and keeps `install-demo-data` working as an alias for the runbooks that post it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * l10n(setup): the eight card strings in all 36 required locales The choice step's cards are prose, and openregister holds every one of its 36 required locales at key-for-key parity. Eight new English strings are therefore 288 translations, not eight: without them `test:l10n:parity` goes red, and with an English value copied into each locale it would go green while the wizard rendered English inside a Dutch session. Each value is a translation attempt. The check tolerates identical values, so it cannot tell the difference, which is exactly why they are written rather than generated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(l10n): give the fourteen connected-accounts strings their catalogue keys `test:l10n` has been red on development since OAuth2ConnectionsSection landed: fourteen t() calls with no key in l10n/en.js. A t() call with no key renders its English literal, so the connected-accounts panel showed English inside every translated session, and the CI leg failed for everyone who pushed after it. Fourteen English keys mean 504 translations, because every one of the 36 required locales owes a value for every English key. They are written rather than copied: parity tolerates a value identical to the English, so copying would have gone green while the panel stayed English. Found while adding the card step's own strings, which is when the leg first had to be run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…3444) * docs(openspec): specify run-scoped object locking * feat(locking): a lock records whether a run or a person holds it, and refuses writes The write guard never fired: SaveObject read the holder from `userId` and ObjectEntity::lock() has always written `user`, so $lockOwner was invariably null and the !== null test short-circuited every time. The unit test agreed with the bug, hand-writing a payload shape lock() has never produced. Ownership was also keyed on the user alone, so two flow runs under one runAs could not conflict: the second took the extend branch and was handed the object. Adds `kind` and `runUuid` to the lock payload, additive inside the existing _locked JSON column so no migration and no back-fill are needed and a record with no `kind` reads as the user lock it is. One predicate, ObjectEntity::isLockedBySomeoneElse(), now owns the comparison and every guard calls it. * feat(flow): lock and unlock nodes, and three layers that always release Two nodes. `openregister.lock-object` takes a run-scoped lock and, when another run holds the object, parks the run with a non-null resumeAt and retries on the heartbeat until its wait budget expires, then fails naming the holder. The budget is stamped once in the node's own resume slot, so a retry does not restart it. `openregister.unlock-object` releases early. Release does not depend on a node running. A FlowRunTerminalEvent listener releases every lock a run holds on all four terminal statuses, a sweep in FlowRunWorker collects locks whose run is terminal or gone, and the lock TTL remains the backstop. Splits RunLockRegistry and AdvisoryLockStore out of LockHandler: an advisory pre-creation lock and a run-held object lock are different things, and the handler had grown past the complexity threshold. * fix(locking): an engine break needs no session, and name the mapper's arguments The terminal-event listener and the cron sweep both run as nobody, so requiring a session user on the break path would mean the release layers built for crashed runs could never fire. * fix(locking): the sweep read nothing, two ways Found on the rig, not by a mock. The orphan query composed `run_uuid NOT IN (sub-select)` and an expiry comparison as one orX() over a createFunction(). PostgreSQL rejects that with "argument of OR must be type boolean, not type record", and both the registry's catch and the worker's swallowed it: the sweep logged a warning and released nothing, every tick. Now two plain queries merged in PHP. The release read also passed includeDeleted: false. A soft-deleted object can still hold a live lock and is exactly the one nobody watches, so its lock would have been stranded. Every scoping filter on that read is now off and a test pins each one: _rbac and _multitenancy were already off because both release layers run sessionless, which is the same shape as the prune-retired miscount in #3440. * fix(locking): declare the throw the contention path actually takes ObjectEntity::lock() throws the global Exception, not LockedException, so lock()'s broad catch is the arm that fires on contention. Declaring only the narrow one on the extracted helper made PHPStan read the live catch as dead code. Also drops a psalm baseline entry the extraction made stale. * chore(locking): tag the node and registry methods gate-16 asked for Also picks up hydra-gates v1.15.0, which is the version that ran these gates locally. * chore(flow): say why the worker carries ten collaborators phpmd's ExcessiveParameterList fired on the sweep's dependency, the tenth. Splitting the worker would buy a second cron job and a second ordering to reason about.
… says so (#3445) `E2E Tests (Playwright)` has been red on development since #3438 took nextcloud-vue ^2.36.4. That release deleted CnSuggestFeatureModal: team decision 2026-09-04, the forge is where the conversation happens. The header CTA became an anchor to the forge's feature-request issue form, and three tests kept asking for a button. All three failed the same way, on a page that renders perfectly well: `getByRole('button', {name: /Suggest feature/})` matches nothing, because an <a href> has role link. One of them then drove a modal that no longer exists at all. So: - feature-pages asserts the link, through a new expectLink helper beside the button one. - The four-state modal-validation test is replaced by what the control now promises: it is a link, its href is a feature-request issue form, it opens in a new tab with rel=noopener, and the app POSTs nothing of its own. The no-POST assertion is kept, because that is the part worth keeping: nothing is submitted from inside the app. - The features_roadmap_enabled guard test probes the link. The spec said otherwise, so the spec moves too. Three requirements are retired: the modal, its success toast and its error handling. They are named in the replacement rather than deleted quietly, so a reader learns what happened instead of wondering. The widget action-menu requirement keeps its item and says plainly that what the item DOES is unsettled: the helper still ships, the modal it opened does not, and nobody has taken that decision yet. POST /api/github/issues now has no caller in the UI. The endpoint and its guards are untouched here. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…3448) The demo-data step is a choice followed by a load step now, and the CI seed settles the optional steps by posting `skip-demo-data` — which records `demo_dataset = none`. The load action then correctly imports nothing and answers "No example data was loaded", so `installing the demo data reports HOW MUCH landed` asserted against a decision it never made. It picks the shipped dataset first now, and it reads the id from `/api/setup/status` rather than writing a literal: the choice step reads its options from exactly that list, so a hardcoded id can pass while the list an operator sees is empty. The `api()` helper gained a body, which it needed to post the choice at all. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s nobody (#3451) Nextcloud reads an app's migration directory only when appinfo/info.xml's <version> is greater than the installed_version it recorded. Equal versions mean `occ upgrade` answers "No upgrade required.", exits 0, and opens no migration file at all. Nothing is logged, nothing fails, and the feature that needed the table is absent with no error anywhere. Measured on a throwaway NC 34.0.3 rig running openregister 2.0.15-unstable.20260905134511 from its release tarball: a migration added with <version> left alone did not run and was not recorded; changing nothing but <version> and re-running `occ upgrade` ran it. The code was byte-identical across the two runs. This is not hypothetical. development carried four migrations added since <version> last moved on 2026-09-03, one of them the run-lock table #3444 depends on, and an instance updated to that code got none of them. scripts/check-migration-version-bump.php fails when a branch adds a file under lib/Migration/ without moving <version> past its value at the merge base. It runs in Merge Hygiene on every push and PR, in composer check:strict, and as a warning from .githooks/pre-commit. Run against the real history it reds on exactly those four files. It exits 2, not 0, when it cannot resolve the base ref: a check that cannot see the base has no verdict, and a silent pass is the failure this removes. occ migrations:status cannot be used for this and is documented as such rather than worked around. It is Nextcloud's command and three of its five counting fields are wrong: Pending Migrations reads None for this app always, because core filters the list on \$migration->name() and SimpleMigrationStep::name() returns '' for all 204 of ours; New Migrations and Executed Unavailable both call array_keys() on a list and so diff version strings against 0..n. The only honest pair is Executed against Available, which is what read 204 of 205 on the rig while the line below it said nothing was pending. Refs #3444
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…in the editor (#3454) * fix(locking): a parked run keeps its locks, and the lock nodes exist in the editor Three defects in the run-scoped locking that #3444 shipped, all three found by walking a live instance and none by the suite. A RUN LOST EVERY LOCK THE MOMENT IT PARKED. The dispatch predicate really is `isTerminal()` on the persisted row, and the row really did say `completed` — transiently, in the middle of a pass that went on to store `suspended`. `FlowEngine::fireOnStream()` computes `enabledAfter` by asking `FlowStreamWalk::workRemains()` for the transitions enabled on the marking it has just advanced, but that method answers from the walk's in-memory stream picture, which `commitFiring()` only re-reads AFTER the commit. So it compared the NEW enabled transitions against the OLD places and answered "no work remains" at every ordinary mid-flow firing. `applyDerivedStatus()` then took its "nothing enabled, nothing parked, nothing terminal" arm and wrote `completed`, `FlowRunMapper::update()` announced terminality, and the lock listener released the run's locks — every firing, of every run, not just ones that lock. The park path had the mirror bug: `workRemains()` was evaluated before `park()` marked the stream parked, so a parking run was derived `queued` with no wake time until `finalize()` corrected it. `workRemains()` now takes what the caller's commit is about to change: `produced`, the places the firing takes, and `settling`, a stream whose place stops counting because it is parking. Over-counting is the safe direction here — it yields `queued`, which the next pass corrects — and under-counting is what produced a false terminal. A RUN-KIND LOCK REFUSED THE RUN THAT HELD IT. `SaveObject` asked the guard "which user", never "which run", so a flow that locked a case was turned away by its own lock at its next write. The run identity now reaches the guard through the ambient `FlowRunContext`, for the same reason attribution does: the write is routinely several calls deep inside code that has never heard of flows. Absent, it reads as a person, which is the fail-closed answer. The D-6 sweep is re-done over the GUARD's callers, not just `lockObject()`'s: `SaveObject`, `RevertHandler` and `ObjectsController::update` now pass the caller's run; the three post-save auto-unlock tests in the controller deliberately do not, and say so — they decide a RELEASE, and a run's lock must outlive every write the run makes. `LockObjectNode` and `UnlockObjectNode` already passed theirs. The same predicate had the opposite hole: a user lock did not refuse a run executing as its holder, so a run passing over a person's locked object took the extend branch, rewrote the payload as its own run lock, and destroyed the person's lock when it ended. A run and the person it runs as are different holders in both directions. BOTH NODES WERE INVISIBLE IN THE EDITOR. `core/img/actions/lock.svg` and `unlock.svg` do not exist in NC 33 or 34, `imagePath()` throws for an image the server does not ship, and `palette()` caught that with everything else — so the catalogue held 25 nodes rather than 27 and neither node could be added to a flow at all. The icons are now app-owned, and the silent skip is loud: an icon is resolved on its own, an unresolvable one is an ERROR naming the node, and the node is served with the app icon instead of being deleted from the catalogue. A node that survives with the wrong picture beats a node that does not exist. Tests, each proven red first, driving the real engine rather than a fake — the existing coverage mocked `FlowRunMapper::update()` and restated `workRemains()` in a fake, so both agreed with the bug: - a suspended run announces nothing and keeps its locks, and each of `FlowRun::TERMINAL` releases them (iterated from the constant) - the holding run writes to its own locked object; another run and a person are refused - a person's lock survives a run passing over the object, payload byte-identical - every registered node's icon resolves, as a sweep over all 27 rather than a check of these two, plus the palette behaviour that stops the next one vanishing * fix(locking): trace the two icon methods to the palette requirement gate-16 counts a changed method with no `@spec` as an untraceable change, and these two are exactly the methods the palette requirement is about. * test(locking): say why each terminal leg rebuilds the harness
…rs the lock (#3456) Three defects found while validating a live demo instance, none of them by the suite. 1. A repair step or cron job saving an object with a bound folder could not run at all. `FolderManagementHandler::assertFolderIsAccessible()` default-denies when there is no `IUser`, and a system operation has no user by definition, so the two were indistinguishable. `runAsSystem()` did not help: it flips a depth counter, it does not set a session user. Every one of these callers catches and logs, so `occ upgrade` still reported success while the work silently did not happen. The scope in this repo is four write paths: RematerialiseCalculationsCommand, DsarDpiaDetectionJob, VocabularyImportService (reached from both SeedVocabularyRegister on upgrade and ImportSkosCsvCommand), and ImportHandler when its fallback admin cannot read the bound folder. `SystemOperationContext::isActive()` is now recognised here as it already is in MultiTenancyTrait and PermissionHandler. It resolves the app's own principal and runs the SAME checks against that principal's mount. The guard is not skipped and not widened: an anonymous request has no principal to resolve and still lands on the default-deny. 2. A locked object refused PUT with 423 naming the holder and let PATCH and POST-patch reach validation first, so they answered 400 for a malformed payload and a bare 500 for a valid one. Two doors to the same object, two different answers, and the wrong one sends the caller off to fix their payload. All three now go through one `lockRefusalResponse()`, placed before the merge and before validation, over the single predicate `ObjectEntity::isLockedBySomeoneElse()` with run identity arriving ambiently through `FlowRunContext::currentRunUuid()` (openregister#3454). The refusal itself is built once by `LockedException::forObject()`, which the service-layer guard now throws too, so a lock taken between the pre-read and the save answers 423 rather than 500. 3. The folder-binding window is not a race and not intended. Object folders are created under `Open Registers/` in whichever home the CREATING identity had, because `getOpenRegisterUserFolder()` resolves the session user; the compensating share back to everyone else is still a TODO that shares nothing. So a stored binding sits in exactly one user's mount, and re-validating it on every save asked every later editor to prove they were the creator. That is the reported 403: a non-admin cannot write a case somebody else created. Re-validation of a binding the app itself wrote now asks the question that fits it — is this a folder OpenRegister manages? — through `assertManagedFolderIsAccessible()`. The caller-supplied `@self.folder` gate is untouched and still default-denies. A binding pointing outside `Open Registers/` is still refused, which is exactly the planted cross-tenant binding the re-validation was added for. The managed test runs first so an ACCEPTED save no longer stamps a false `folder_access_denied` row into the audit trail. Tests were proven red by neutralising each guard in turn, and each keeps a control that stays green so a blanket exemption cannot pass as a fix.
global-setup suppressed the first-visit walkthrough and left the NON-GATING first-time-setup wizard (ADR-042) armed. Its `modal-mask` subtree intercepts every click on the app behind it. It splits a suite rather than failing it, which is why it survived: specs that navigate by URL pass, specs that click do not, so the suite reads as a half-broken app rather than one dialog nobody closed. The tell is precise once you read the call log. Playwright reports the target as "visible, enabled and stable" and then times out anyway, naming `data-testid-modal="cn-wizard-dialog"` as the interceptor. A summary line cannot show you that; only the log can. dossiq already had this fix and a docblock explaining it. Three apps did not, and between them they held 124 of the 130 failures in tonight's fleet sweep. Confirmed on decidiq first: 44 `cn-wizard-dialog` intercepts before, zero after. The dismissal key is per manifest `setup.version`, so a generous range is seeded rather than one value; a version bump would otherwise silently re-arm it.
…roven red first (#3460) The capability shipped validated only by a manual rig walkthrough. The walkthrough never parked a run, so nobody noticed that its first version released every lock a run held the moment the run suspended — the one moment a lock is for. Three earlier tests asserted that behaviour as correct. WHERE THE COVERAGE LANDS, AND WHY IT IS NOT SPLIT ACROSS REPOS All of it is here. dossiq has the larger suite and owns the case a demo locks, but its shipped case flow uses no lock step — so a "case-flow journey" there would either restate the engine assertions this file makes, or require adding a lock step to a shipped flow, which is a product change wearing a test's name. openregister owns the lock, the nodes, the write guard and the palette; that is where the behaviour is, so that is where it is asserted. Within this repo it is split by what the assertion needs: - tests/e2e/api-direct/flow-object-locking.spec.ts — the engine and API legs. Needs `occ` for the worker, so it cannot join the CI floor, and sits beside flow-user-task / flow-schedule / delegation-parking, which are the same shape. - tests/e2e/ci/flow-lock-nodes.spec.ts — the palette. Hermetic, non-mutating, no occ, no skips, so `ci/*.spec.ts` admits it and CI actually runs it. THE ASSERTIONS ARE MADE THROUGH THINGS THAT CAN ONLY ANSWER ONE WAY `@self.locked` is read for `kind` and `runUuid`, not for non-null: an earlier round of tests asserted `locked !== null` and passed against a user lock a run had silently stolen. A refusal is asserted as 423 with `lockedByRun` equal to the run uuid, not as "some 4xx" — a 400 from validation and a 500 from an untyped service exception are both "not 200" and neither is a lock. A run that should bounce is asserted to reach `failed` with the HOLDER's uuid in its error. EACH LEG WAS PROVEN RED, by neutralising the thing it tests and restoring it: - parked run keeps its lock — dropped `produced:` from `FlowEngine`'s `enabledAfter`, restoring the false mid-pass terminal. Red: "the parked run holds NO lock". - a second run bounces — made the spent wait budget proceed without the lock instead of failing. Red: run `completed`, expected `failed`. - a person is refused, the runAs included — keyed run-lock ownership on the user again. Red: the runAs user's PUT answered 200 with the run's lock intact. - a person's lock survives a run — removed the branch that makes a run and the person it runs as different holders. Red: run `completed`, lock destroyed. - both nodes in the palette — restored `palette()` resolving the icon inline, with the icons pointed back at `core/img/actions/lock.svg`. Catalogue went 27 → 25 and both tests failed, the editor one naming the palette. That last proof is why the palette file is TWO tests. As one, the run stopped at the catalogue assertion and the editor half never executed: a test whose second half cannot be shown to fire has not been proven, only its first half has. RERUNNABLE ON ONE RIG, WHICH TOOK TWO CORRECTIONS THE RIG FOUND Fixtures are namespaced and torn down: runs stopped, then every lock BROKEN as the administrator, then `occ openregister:objects:purge --force --apply`, then the register and schema. Releasing the locks first is not tidiness — a left lock means the object cannot be written, so the next run meets a state the first never saw, and fails somewhere far from the fixture that caused it. - a flow terminated by `openregister.end` commits as `stopped`, not `completed`, so the flows end on a step with no outgoing edge instead; - `waitSeconds: 1` is racy: the deadline is persisted with `format('c')`, which truncates to whole seconds, so a budget stamped at .900 is already spent 200 ms later and the run fails on its FIRST attempt instead of parking. The worker is driven with `occ background-job:execute <id> --force-execute`, and each wait sleeps to the run's own `resumeAt` first — a pass fired before a run is due does nothing, and a loop of those fabricates a convincing "wedged forever" that belongs to the test, not the run. The nine covered scenarios in the change spec now carry `@e2e` references, and the two whose exclusions claimed "engine-internal, covered by the rig walk" no longer need them. Verified locally on a throwaway rig (own compose project, port 8749, NC 32, torn down with `down -v`): both suites run TWICE with identical results — 4 passed / 0 failed and 2 passed / 0 failed each time, all 6 per-test outcomes matching — and the instance holds no leftover flow, register, schema or user afterwards. prettier --check and eslint both exit 0.
…3462) The flow editor's run view needs two things the server did not offer, and both were found by trying to build the tab rather than by reading the code. A RUN-ANCHORED TASK READ. `task#index` had no `runUuid` at all, and defaults to `scope=assigned`, so a client-side filter would have answered 'what did this run ask ME' — an empty list for every run whose performer is a group, which reads as a run that asked nobody. `runUuid` therefore ANCHORS rather than filters: it replaces the scope narrowing and lifts the external exclusion (a run that asked a resident through the portal did ask somebody), exactly as the `objectUuid` anchor already does. Visibility is the one thing it does not relax — a non-admin is still held to a sanctioned relationship, and for a run's own tasks that relationship is `requester`, which the engine stamps with the run's acting identity. An empty `?runUuid=` is read as absent. A query string carries it as '', never as null, and '' applied as a real predicate matches no row: a tab that forgot to fill the uuid in would have reported the run as having asked nobody instead of reporting that it asked nothing of the server. AN ADDRESS FOR A RUN. `/apps/openregister/flow-runs/{uuid}`, on the same terms as the task deep link beside it. A run gets no screen of its own — the flow editor already shows the replay, the steps, the objects and the log — but without an address, 'open this run in a new tab' cannot be a link, and what is not a link cannot be middle-clicked, bookmarked or pasted into a ticket. The page resolves the run to its flow and hands over, with a real failure state rather than a silent bounce to the dashboard, because a dead link that lands you somewhere plausible is the one that cannot be diagnosed. Verified: the five new mapper tests were each seen RED first, by neutering the anchor and then the filter in turn; PHPCS, Psalm and PHPStan clean on the changed files; both l10n gates green with all 36 locales at parity, the two new strings derived from the sibling task strings so each locale keeps its own established terms for a run and a flow. Exercised live on :8080: anchored read returns the run's group-assigned task that `scope=assigned` hides, the unanchored read is unchanged at 7 rows, and the empty anchor falls back to unanchored rather than to zero.
Asking somebody in a flow has three separate problems, and ADR-032 is
explicit that a spec mixing surfaces burns a cycle without shipping. So
three changes, each sized to one surface, rather than one that touches the
engine, the run model and every node in the fleet at once.
WHAT THE MEASUREMENT FOUND. On the demo instance, 24 flows carry 30
`dossiq.askPerson` nodes whose assignees are role names typed into a text
box: Beleidsadviseur, juridische-dienst, Afdelingshoofd, Griffier,
burgemeester. `FlowRunAssignee::mayAnswer()` accepts an answer only when the
uid EQUALS the assignee string or the answerer is in a group named exactly
that string. 3 of the 30 resolved. 27 created a task nobody on the instance
could answer, suspended their run, and reported nothing wrong. The same role
appears under two spellings in one instance, which is what a text box
produces.
flow-typed-principals — a performer becomes {type, id}; resolvers are
contributed the way flow nodes already are, so decidiq owns what a position
is and hermiq owns what a function is; an unknown type is refused when the
step is SAVED and an empty resolution when the task is CREATED, because
those are two different wrongnesses found by two different people. An agent
becomes a performer of the same node, so its answer is a task row a person
can take over.
flow-run-subjects-and-answers — `outcomeBagFor()` gains `answers`. The
portal node has done this since it was written (PortalTaskNode:450) and the
user-task bag omitted it, so every value a performer typed into a step form
was validated, stored, and then discarded. Plus a run gains a DECLARED set
of subjects addressed by role, distinct from the audit-derived objects it
wrote, so a step can say attachTo: case and mean it.
flow-node-taxonomy — the live catalog serves 64 step types as one flat list
and `IFlowNode` has no notion of a kind. Two axes: a BPMN element kind,
which flow-bpmn-interchange needs anyway and currently asks nobody for, and
a palette category. Both DEFAULTED, because 43 of the 64 nodes live in repos
this cannot touch — an honest 'other' gets fixed, a guessed 'serviceTask'
written into a BPMN export does not.
All three validate. Two INFO notices are pre-existing: flow-tasks and
flow-user-task-node have no canonical spec because their changes were never
archived.
A user-task step could declare a form, the performer could fill it in, the values were validated against the subject schema and written to `task.responses` — and no later step could ever read them. `FlowTaskBridge::outcomeBagFor()` had no `answers` key. `PortalTaskNode::placeOutcome()` has placed one since it was written. Two nodes doing nearly the same job, four lines apart, and only one shipped the last hop. That asymmetry WAS the bug. Why nothing caught it: the feature is tested at every step except the one that matters. The declaration is refused if it names a field the schema lacks, the form is rendered, the values are validated on completion, the row is stored — each has a test. None asserts that a FOLLOWING STEP can see the value. Storing is not reaching, and only reaching was missing. So the key moves to the bridge, where both callers share it and cannot drift apart again, and the portal node's own line goes. Always present, sometimes empty. A missing key would make a downstream `answers.reason` fail one way when the performer skipped the form and another way when the step declared none, which teaches every author to write two guards for one question. Both new tests were seen RED first, and they assert on the ITEM LEAVING THE NODE rather than on the task row — asserting the row again would have reproduced the blind spot that hid this. 1135 flow unit tests green; PHPCS, PHPStan and Psalm clean on both files. Implements task 1 of openspec/changes/flow-run-subjects-and-answers, which sequences this first and on its own.
…rings in them (#3467) `check:l10n-js` has been red on development since #3462, reporting all 37 browser catalogues as stale. The generator that check runs, scripts/build-l10n-js.js, landed in #3428 the day before, and the .js files committed since predate it, so they are written in the older transifex layout the generator no longer emits. Rebuilding alone would have been wrong. #3462 added two strings No such run The run does not exist, or it is not yours to see. Deleting a flow deletes its runs. straight into the 37 .js catalogues, translated, without ever adding them to the .json sources the generator reads. A plain `npm run l10n:build` therefore deleted 37 languages' worth of two real translations, and the UI would have fallen back to English wherever they appear. So the two strings are harvested out of the committed .js files into the matching .json, appended in the order those files already use, and only then is everything rebuilt. Checked, because a catalogue rebuild is exactly the kind of change whose diff is too large to read: - a no-op json round trip reproduces every source file byte for byte before anything is written, so adding two strings cannot reformat 37 catalogues as a side effect. The json diff is +3 -1 lines per file. - every key in every old .js is present in the new one with an identical value. Across all 37 locales: nothing lost, nothing altered. - `node scripts/build-l10n-js.js --check` exits 0, as do test:l10n, test:l10n:parity and check:schema-l10n. - scripts/check-l10n.js reports 788 issues both before and after. That is pre-existing dynamic-`t()` debt in dashboard and settings views, and it is not one of the checks CI runs. A first count said 2,907 keys had been lost per file. That was my own regex assuming four-space indentation, not the data: the generator emits eight. The real delta was two. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The api-test-coverage job has failed on every development push since the migration-version-bump gate landed in #3451: eight for eight, always the same six cases of MigrationVersionBumpCheckTest. Those cases build a small repository each and run the gate over it, because a test that inspects this repository can only report whatever today's branch happens to look like. The nextcloud image the unit suite runs inside ships no git, so `git init` returned 127, no fixture was ever created, and the gate then reported /tmp/or-migration-gate-256a137d7fd3 is not a git repository which names the gate rather than the absent binary. Confirmed by running `git --version` in the container: command not found. With git installed the class passes 8 of 8. Two changes, because the missing binary and the misleading message are different defects: Install git in the container, in the same shape and place as the existing composer step, and prove it with `git --version` as the user the suite runs as. apt rather than copying the runner's binary, since git is not a single file like the composer phar. Make the fixture helper assert its exit code. It discarded both the output and the status, so it could not tell "the tool is absent" from "the tool disagreed" and reported the second whatever happened. It now fails with the command, the code and the output. Verified both ways: with git on PATH the class passes 8 of 8 with 74 assertions, up from 13, so the new checks do run; with git removed from PATH it fails with fixture setup failed: git init -q -b development (exit 127) sh: 1: git: not found Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…has no baseline (#3471) Hydra Gates has been red on development since #3462, on two findings that are the same new file: [gate-16] spec-coverage: 1 changed method missing @SPEC [gate-26] visual-coverage: 1 new page component missing a visual baseline Both are src/views/flows/FlowRunDetail.vue. gate-16: `resolve()` now carries @SPEC pointing at the flow-runs-subject-scope spec, which is where the deep link this page implements is written down: "the run uuid in the row is the deep link to it". The path was opened and read rather than guessed. A tag the gate accepts but that resolves to nothing clears the finding without covering anything, and that is worth stating because the gate cannot tell the difference. gate-26: an @visual exclude with its reason, rather than a baseline. This page has no screen. It is a resolver: the spinner is replaced by the flow editor as soon as the run resolves, and the failure state is one NcEmptyContent whose pixels say nothing the flow editor's own baselines do not already cover. WHAT IS STILL MISSING, PLAINLY. The behaviour here is worth a test and does not have one: that a cold load of /flow-runs/{uuid} reaches the flow rather than the manifest's catch-all, that Back does not bounce because the handover replaces rather than pushes, and that an absent run says so at its own address. I wrote that spec and could not run it: this workstation's instance carries 30-odd apps and the shared global setup times out logging in at 30s, twice, before any test starts. Rather than ship a playwright spec whose only evidence is that it reads correctly, the exclude says what is not covered and the comment in the file says what to write. The gap is named, not hidden. Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.