Skip to content

Release: merge development into beta - #3204

Merged
rubenvdlinde merged 178 commits into
betafrom
development
Sep 6, 2026
Merged

rubenvdlinde merged 178 commits into
betafrom
development

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

github-actions Bot and others added 6 commits August 31, 2026 10:24
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.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ bcd3452

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.

dependabot Bot added 2 commits August 31, 2026 13:55
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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ 972d1ea

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.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ 556155b

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.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ 33fd1cd

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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ fca2265

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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ d630b0c

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.

github-actions Bot and others added 3 commits August 31, 2026 15:01
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
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ 0ae5e0a

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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ 5ddb1ca

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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ 8edc871

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.

rubenvdlinde and others added 2 commits August 31, 2026 19:07
….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>
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ 6dac523

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.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/openregister @ c54b65a

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.

rubenvdlinde and others added 29 commits September 4, 2026 18:07
…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>
@rubenvdlinde
rubenvdlinde merged commit 17df32f into beta Sep 6, 2026
58 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants