Skip to content

Release: merge beta into main - #3106

Merged
rubenvdlinde merged 70 commits into
mainfrom
beta
Aug 30, 2026
Merged

Release: merge beta into main#3106
rubenvdlinde merged 70 commits into
mainfrom
beta

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Stable release: beta holds 64 commit(s) main does not.

Merged with --merge, never --squash. Squashing a promotion rewrites the carried commits into one beta does not contain, so the branches diverge again immediately and main's own commits read as reverted.

A failing … / release check on this pull request is the App Store publish step, not a quality gate. Eight fleet apps cannot publish today: seven have no signing key, and thematiq's certificate carries its old app id (Nextcloud issues one certificate per id, CN = the id). The GitHub release and tag are still created. Every other check must be green for this to merge.

Conduction Release Bot and others added 30 commits August 28, 2026 15:09
… one

openregister ships a flows surface (the `flows` index at /flows, in the
main menu) and its tour never mentioned it, so the automation was
discoverable only to someone who already knew it was there.

Found by widening gate 70's flows-page predicate, which matched only
`type: "flows"` — a type exactly one fleet app declares. openregister ships
its surface as an ordinary `type: "index"` page, so the gate reported NOT
APPLICABLE: it read as covered while covering nothing.

The step is `optional` with `allowManualNext`: showing where flows are
edited must not become "author an automation before you may finish the
tour".

It targets `flows` — the ROUTE, lowercase, as the menu entry declares it.
`data-cn-route` carries `item.route`, and that is what
CnWalkthrough.resolveTarget() looks up. The menu entry's ID here is `Flows`
with a capital F; targeting that would look right in review and resolve to
nothing at runtime, and an optional step whose target is absent is SKIPPED
silently. (The neighbouring `open-tables` step has exactly that shape — it
targets the menu id `Tables` while advancing on route `tables` — which is
worth a separate look.)

All 36 required locales are translated, not just en/nl: this repo enforces
full parity, and `test:l10n` fails on any missing or empty value.

Verified: gate-70 0 findings, gate-96 rc=0, l10n parity OK across 36
locales, check:l10n-js rc=0.
feat(walkthrough): show where flows live, without making anyone build one
… page) (#2978)

* docs: add a local demo environment

Adds `openregister-compose.yaml` and a setup page describing it.

The compose brings up Postgres and Nextcloud, installs openregister (required),
thematiq and integriq (optional) and openregister from release tarballs, and enables
them in dependency order. Nothing is bind-mounted: Nextcloud installs an app by
deleting its directory and extracting an archive over it, so pointing that at a
checkout deletes the working tree — measured on a development machine on
2026-08-27, where an app-store update fired on a container restart and removed
every top-level file including .git.

Release tarballs rather than a clone for a second reason: a tarball is a
complete app carrying vendor/ and the built js/, and an app with no vendor/
does not fail loudly — it warns once and keeps loading, so it looks installed
while every service needing a dependency is absent.

The openregister dependency is not declared in appinfo/info.xml — no app in the
fleet declares an <app> dependency — so the compose encodes what the manifest
does not.

Verified: docker compose config parses and interpolates; the same generated
file was booted end to end for portaliq, which produced 17 registers, 86
schemas and 13 magic tables for its own register, with the portal content API
returning a real site rather than an empty shell.

* docs: make the demo verification command actually pass

The verification curl was unauthenticated, and a Nextcloud app page requires a
login, so it printed 401 on a healthy demo while the page described it as a
pass. Measured on two booted demos: 401 without credentials, 200 with them.
The command now carries them and says a bare 401 is expected.

* test(e2e): check a demo environment the way its docs say to

Validates a booted demo against the steps its own documentation tells the
reader to run. Lives outside tests/e2e/ because the root config sets
testDir: './tests/e2e' and this suite needs an already-booted demo that CI
does not have -- collected there it would fail, and gated with a skip it
would look identical in CI to a suite that ran and found nothing.

None of the assertions is a status code, because two measurements taken while
writing it show why:

  - the Nextcloud LOGIN page is served with HTTP 200, and basic auth does not
    authenticate a browser navigation (only API routes), so a status-only test
    passes while sitting on the login screen;
  - an unauthenticated app URL answers 401 on a healthy demo -- the request the
    demo documentation used to describe as a pass.

Shown to fail, not just to pass: pointed at an app that is not installed, the
two reachability tests fail; portal assertions forced on against a demo with
no portal fail both. Green against two independently booted demos, portaliq
(6 passed) and shillinq (4 passed, 2 correctly skipped).

* fix(test): keep the demo e2e suite out of jest, and format it

Adding tests/demo-e2e/ broke two frontend jobs, both my doing.

jest collected the Playwright spec. Its ignore list named <rootDir>/tests/e2e/
specifically -- the Playwright suite -- and the demo suite deliberately sits
OUTSIDE that directory, because playwright.config.ts sets testDir: './tests/e2e'
and a spec placed there is collected by CI, which has no booted demo to point
at. Solving the one collector walked straight into the other: two runners with
opposite exclusions, so the file has to be named in both.

Verified by listing tests rather than by reading the pattern: with the ignore
jest collects 0 files under tests/demo-e2e/, without it 1.

The two .ts files were also not in the repo's prettier style, which
`prettier --check "**/*.{js,ts,vue,css,scss}"` covers. Formatted with the
repo's own config; prettier --check is now clean on both.

* style: format the demo e2e files with the repo's own prettier config

The first attempt formatted copies in /tmp, which resolves a different prettier
config and a different .prettierignore than the repo does -- so it reported the
files clean while `prettier --check` at the repo root, which is what CI runs,
still rejected them. It also made the repo's own jest.config.js look unclean,
which it is not.

Formatted in place. `prettier --check "**/*.{js,ts,vue,css,scss}"` -- CI's
exact command, run from the repository root -- is now clean across the whole
tree.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
… step (#2979)

* feat(flow): attribute every write in a run to the run, node and step

A flow run recorded exactly one object — `FlowRun.subjectUuid`, the thing that
TRIGGERED it. Everything the run went on to touch was attributable to nothing,
so neither "which node last touched this case" nor "what did this run change"
could be answered. `FlowRunStep` already said what happened; it did not say what
it happened to.

The engine now establishes an ambient frame around each hop and the audit
builder stamps it onto every row. Ambient rather than a parameter because the
point is to catch writes made by code that has never heard of flows — a leaf app
a node calls into, a cascade, a lifecycle hook. A node cannot report what it did
not know it did.

The pop is in a `finally`, and that is the whole safety property: every other
exit from a hop is a `return` inside a catch — a stop, a suspension, a
terminally-failed step. A frame left standing attributes LATER writes to a
finished run, across runs, since one worker advances several. It produces no
error and no wrong-looking row. FlowEngineAttributionTest asserts the leak
direction; deleting the `finally` turns 5 of its 8 tests red.

Step numbers are `base + index-in-log`, not a dispatch counter: a PINNED step
logs an entry without ever reaching the dispatcher, so a counter would silently
desynchronise from the FlowRunStep rows it has to line up with.

ADR-003 Rule 4 — the three fields join the canonical JSON, so re-pointing a row
at a different run breaks verification instead of going unnoticed. That makes it
a seed migration (v1 → v2) rather than a column addition. The repair step
verifies the OLD chain against a FROZEN v1 canonicaliser and records that
verdict before re-sealing: verifying with the current canonicaliser would
include the new keys, report every pre-existing row broken whether tampered with
or not, and the re-chain would bless it either way — a check that cannot tell an
intact chain from a compromised one is not a check. It refuses to start if it
cannot store the verdict, because a re-chain with no account of what it replaced
has no remedy.

Also closes a pre-existing gap found on the way: `FlowRunController::show()` was
unscoped while `index()` has been scoped since shared-credentials-and-flows D7 —
and a run's serialisation carries its log, which records the subject data the
flow touched. Both now resolve through one predicate in the mapper rather than
two copies that drift.

Refs: openspec/changes/flow-object-attribution

* refactor(flow): one assignee rule, reachable from outside the controller

`refuseUnlessAssignee()` guarded the HTTP resume endpoint, which was the whole
story while HTTP was the only way to answer a step. It is not: a leaf app whose
own object completes a task resumes the run IN-PROCESS through
`FlowRunService::signal()`, which never passes the controller. Left where it
was, every such caller re-implements the rule.

Re-implementing it is the failure mode. Two copies of one access rule do not
stay identical, and a divergence here does not throw — it lets the wrong person
answer somebody else's question, correctly formatted, HTTP 200. The GROUP branch
is the half a hand-written copy forgets, and forgetting it refuses the step's own
intended audience while still reading as "the guard works".

So the rule moves to FlowRunAssignee and the controller delegates. Its 24
existing tests pass unchanged, which is the point — behaviour is identical, only
its reachability changed. The old private copy is deleted rather than left
beside the new one.

The new tests cover the three directions that pass while broken: the group
branch, the deliberately-OPEN unassigned case (webhook and child-run signals
record no assignee and must keep working), and the fail-closed anonymous case.
Mutating `mayAnswer()` to return true kills 8 tests across both suites.

Also adds `FlowNodeResumeState::nodeId()`. A node is handed its own slot but was
never told its own name, which is fine until it must hand its identity to
something outside the run — a task record that has to resume this exact node.
A run holds one awaiting slot per node, so "resume this run" is not an answer.

* refactor(audit): flow attribution gets its own home, and the gates pass

phpmd caught a real regression rather than a style nit: adding the stamp and the
query took AuditTrailMapper from clean to 27 non-accessor methods against a
threshold of 25. Checked against origin/development rather than assumed — the
base was clean, so this was mine.

Both halves now live on AuditFlowAttribution. They belong together because they
are one fact read from two ends: the stamp decides what a row claims, the query
trusts that claim, and keeping them apart is how the column set they agree on
drifts. The mapper is back under its ceiling and no longer has to know what a
flow is.

Other gate findings, each fixed at the cause:

- FlowEngine::run() NPath 226/200. The 26 came from the `finally` that makes the
  attribution pop unconditional, and that is a correctness guarantee, not a
  convenience: every other exit from a hop is a `return` inside a catch, so a
  pop on the success path leaks the frame into a LATER run advanced by the same
  worker. Suppressed with that reasoning written down, rather than restructuring
  a walk to win a number.
- AuditCanonicalV1's static access is suppressed with its reason: it is
  deliberately frozen, and presenting it as an injectable collaborator would
  imply it can be swapped or updated — the one thing it must never be.
- `array_values()` after `usort()` was a no-op that read as a safeguard.
- `@template-extends QBMapper<AuditTrail>`, matching FlowRunMapper's convention,
  rather than casting the return type.
- Three phpcs errors of mine (two missing @PARAM, one comment), and the @SPEC
  tags I had put on member variables, which that standard does not allow there.

Gates now clean on every changed file: phpcs, phpmd, psalm, phpstan. 725 flow,
controller and audit tests pass.

* fix(ci): cover the new code, and stop tipping FlowRunService over its ceilings

CI caught three things.

**phpmd, twice, and both were mine.** An inline FQN in Application.php that
wanted a `use`; and FlowRunService at 1047 lines / complexity 51 against
thresholds of 1000 / 50. The base was at ~998 lines, so any addition trips it —
my eight lines were simply the ones that did.

Rather than trim a comment until the number passed, the step-history concern
moved out whole. `FlowStepHistory` now owns both the NUMBERING and the
RECORDING of a run's steps, and they belong together for a reason: attribution
has to PREDICT a step's number before the walk, while the step row is written
after it, and the two must arrive at the same value or an attributed audit row
and its step row describe different steps. One class, one arithmetic — and
`testRecordedSequencesContinueFromTheSameBaseAttributionUsed` asserts both ends
in a single test, because checking either alone passes while they disagree.

**The coverage ratchet was right too** (-2.49%). FlowStepHistory (10 tests) and
AuditFlowAttribution (6) are now covered. The stamper's tests are all about the
abnormal paths, because the normal one is a single line: a row written outside
any run must carry NO attribution, an unresolvable context must still let the
row be written, and something that is not a run context must not be trusted to
be one.

While writing them, `TestCase::run()` is final — the same trap the existing
FlowEngineTest documents in a comment. Helper renamed.

Gates: phpcs, phpmd, psalm, phpstan clean on every changed file.

* test(repair): cover the one step in this change that cannot be undone

The v1 → v2 migration had no tests, and it is the riskiest thing here: a
re-chain recomputes every hash from current content, so afterwards an intact
chain and a tampered one look identical, and the v1 hashes that could have told
them apart are gone.

So these test the ORDER and the REFUSALS rather than the happy path:

- the verdict is stored BEFORE anything is re-sealed, and names the seed it
  moved from and to;
- a verdict that cannot be stored means NO re-chain at all — the one refusal
  worth blocking an upgrade over, since a re-chain with no account of what it
  replaced has no remedy;
- a second run is a no-op, because a v2 chain checked against the v1 form would
  report a false break and overwrite the real verdict with a meaningless one;
- a re-seal that throws does not leave the step marked done, or the next
  `occ maintenance:repair` skips a half-sealed table.

Worth noting how the first run failed: my IAppConfig double used an arrow
function, which captures by VALUE, so the read-back always saw an empty store —
and the step refused, exactly as designed. The double was wrong; the refusal it
tripped was right, which is a reasonable way to learn the guard works.
… invisibility (#3005)

* feat(flow): attribute every write in a run to the run, node and step

A flow run recorded exactly one object — `FlowRun.subjectUuid`, the thing that
TRIGGERED it. Everything the run went on to touch was attributable to nothing,
so neither "which node last touched this case" nor "what did this run change"
could be answered. `FlowRunStep` already said what happened; it did not say what
it happened to.

The engine now establishes an ambient frame around each hop and the audit
builder stamps it onto every row. Ambient rather than a parameter because the
point is to catch writes made by code that has never heard of flows — a leaf app
a node calls into, a cascade, a lifecycle hook. A node cannot report what it did
not know it did.

The pop is in a `finally`, and that is the whole safety property: every other
exit from a hop is a `return` inside a catch — a stop, a suspension, a
terminally-failed step. A frame left standing attributes LATER writes to a
finished run, across runs, since one worker advances several. It produces no
error and no wrong-looking row. FlowEngineAttributionTest asserts the leak
direction; deleting the `finally` turns 5 of its 8 tests red.

Step numbers are `base + index-in-log`, not a dispatch counter: a PINNED step
logs an entry without ever reaching the dispatcher, so a counter would silently
desynchronise from the FlowRunStep rows it has to line up with.

ADR-003 Rule 4 — the three fields join the canonical JSON, so re-pointing a row
at a different run breaks verification instead of going unnoticed. That makes it
a seed migration (v1 → v2) rather than a column addition. The repair step
verifies the OLD chain against a FROZEN v1 canonicaliser and records that
verdict before re-sealing: verifying with the current canonicaliser would
include the new keys, report every pre-existing row broken whether tampered with
or not, and the re-chain would bless it either way — a check that cannot tell an
intact chain from a compromised one is not a check. It refuses to start if it
cannot store the verdict, because a re-chain with no account of what it replaced
has no remedy.

Also closes a pre-existing gap found on the way: `FlowRunController::show()` was
unscoped while `index()` has been scoped since shared-credentials-and-flows D7 —
and a run's serialisation carries its log, which records the subject data the
flow touched. Both now resolve through one predicate in the mapper rather than
two copies that drift.

Refs: openspec/changes/flow-object-attribution

* refactor(flow): one assignee rule, reachable from outside the controller

`refuseUnlessAssignee()` guarded the HTTP resume endpoint, which was the whole
story while HTTP was the only way to answer a step. It is not: a leaf app whose
own object completes a task resumes the run IN-PROCESS through
`FlowRunService::signal()`, which never passes the controller. Left where it
was, every such caller re-implements the rule.

Re-implementing it is the failure mode. Two copies of one access rule do not
stay identical, and a divergence here does not throw — it lets the wrong person
answer somebody else's question, correctly formatted, HTTP 200. The GROUP branch
is the half a hand-written copy forgets, and forgetting it refuses the step's own
intended audience while still reading as "the guard works".

So the rule moves to FlowRunAssignee and the controller delegates. Its 24
existing tests pass unchanged, which is the point — behaviour is identical, only
its reachability changed. The old private copy is deleted rather than left
beside the new one.

The new tests cover the three directions that pass while broken: the group
branch, the deliberately-OPEN unassigned case (webhook and child-run signals
record no assignee and must keep working), and the fail-closed anonymous case.
Mutating `mayAnswer()` to return true kills 8 tests across both suites.

Also adds `FlowNodeResumeState::nodeId()`. A node is handed its own slot but was
never told its own name, which is fine until it must hand its identity to
something outside the run — a task record that has to resume this exact node.
A run holds one awaiting slot per node, so "resume this run" is not an answer.

* refactor(audit): flow attribution gets its own home, and the gates pass

phpmd caught a real regression rather than a style nit: adding the stamp and the
query took AuditTrailMapper from clean to 27 non-accessor methods against a
threshold of 25. Checked against origin/development rather than assumed — the
base was clean, so this was mine.

Both halves now live on AuditFlowAttribution. They belong together because they
are one fact read from two ends: the stamp decides what a row claims, the query
trusts that claim, and keeping them apart is how the column set they agree on
drifts. The mapper is back under its ceiling and no longer has to know what a
flow is.

Other gate findings, each fixed at the cause:

- FlowEngine::run() NPath 226/200. The 26 came from the `finally` that makes the
  attribution pop unconditional, and that is a correctness guarantee, not a
  convenience: every other exit from a hop is a `return` inside a catch, so a
  pop on the success path leaks the frame into a LATER run advanced by the same
  worker. Suppressed with that reasoning written down, rather than restructuring
  a walk to win a number.
- AuditCanonicalV1's static access is suppressed with its reason: it is
  deliberately frozen, and presenting it as an injectable collaborator would
  imply it can be swapped or updated — the one thing it must never be.
- `array_values()` after `usort()` was a no-op that read as a safeguard.
- `@template-extends QBMapper<AuditTrail>`, matching FlowRunMapper's convention,
  rather than casting the return type.
- Three phpcs errors of mine (two missing @PARAM, one comment), and the @SPEC
  tags I had put on member variables, which that standard does not allow there.

Gates now clean on every changed file: phpcs, phpmd, psalm, phpstan. 725 flow,
controller and audit tests pass.

* fix(ci): cover the new code, and stop tipping FlowRunService over its ceilings

CI caught three things.

**phpmd, twice, and both were mine.** An inline FQN in Application.php that
wanted a `use`; and FlowRunService at 1047 lines / complexity 51 against
thresholds of 1000 / 50. The base was at ~998 lines, so any addition trips it —
my eight lines were simply the ones that did.

Rather than trim a comment until the number passed, the step-history concern
moved out whole. `FlowStepHistory` now owns both the NUMBERING and the
RECORDING of a run's steps, and they belong together for a reason: attribution
has to PREDICT a step's number before the walk, while the step row is written
after it, and the two must arrive at the same value or an attributed audit row
and its step row describe different steps. One class, one arithmetic — and
`testRecordedSequencesContinueFromTheSameBaseAttributionUsed` asserts both ends
in a single test, because checking either alone passes while they disagree.

**The coverage ratchet was right too** (-2.49%). FlowStepHistory (10 tests) and
AuditFlowAttribution (6) are now covered. The stamper's tests are all about the
abnormal paths, because the normal one is a single line: a row written outside
any run must carry NO attribution, an unresolvable context must still let the
row be written, and something that is not a run context must not be trusted to
be one.

While writing them, `TestCase::run()` is final — the same trap the existing
FlowEngineTest documents in a comment. Helper renamed.

Gates: phpcs, phpmd, psalm, phpstan clean on every changed file.

* test(repair): cover the one step in this change that cannot be undone

The v1 → v2 migration had no tests, and it is the riskiest thing here: a
re-chain recomputes every hash from current content, so afterwards an intact
chain and a tampered one look identical, and the v1 hashes that could have told
them apart are gone.

So these test the ORDER and the REFUSALS rather than the happy path:

- the verdict is stored BEFORE anything is re-sealed, and names the seed it
  moved from and to;
- a verdict that cannot be stored means NO re-chain at all — the one refusal
  worth blocking an upgrade over, since a re-chain with no account of what it
  replaced has no remedy;
- a second run is a no-op, because a v2 chain checked against the v1 form would
  report a false break and overwrite the real verdict with a meaningless one;
- a re-seal that throws does not leave the step marked done, or the next
  `occ maintenance:repair` skips a half-sealed table.

Worth noting how the first run failed: my IAppConfig double used an arrow
function, which captures by VALUE, so the read-back always saw an empty store —
and the step refused, exactly as designed. The double was wrong; the refusal it
tripped was right, which is a reasonable way to learn the guard works.

* fix(flow): a shipped flow must belong to a tenant, or it imports into invisibility

Found by running the e2e for the first time, against a real instance.

Every flow READ is organisation-scoped (`FlowService::findAll()` refuses outright
when no tenant resolves). `SchemaFlowImportListener` never set one, so a flow
declared through `x-openregister-flows` was stored with `organisation` NULL and
returned by NOTHING: absent from the flows list, therefore impossible to open,
therefore impossible to ADOPT — while sitting perfectly intact in the table.

Measured on a clean instance: the declared `Case behandeling` flow was present in
`oc_openregister_flows` with 18 nodes and invisible to `/api/flows`, next to two
seeded flows that carried an organisation and listed fine. Backfilling the column
made it appear immediately, with `enabled=false` and `owner=NULL` intact.

Pre-existing: the importer has always done this. Nothing had noticed because this
is the first shipped flow declaration in the fleet — the two example flows are
seeded through a different path that sets the tenant.

The resolver is container-based and returns null rather than throwing, so a
declared flow still imports where OrganisationService is unavailable; it just
stays unlisted, and now says so in a warning instead of silently.

* fix(phpcs): document the container parameter I added

phpcs caught what my local run had not: I ran the file through phpmd, psalm and
phpstan but not phpcs before pushing. One missing @PARAM.

* test(flow): cover the organisation stamp on an imported flow

The coverage ratchet was right to fail #3005: the fix added fifteen
statements and no test, so the code that decides whether a shipped flow
is ever VISIBLE was the only part of the change nothing exercised.

Six tests, one per branch of activeOrganisation() plus the re-import
case. The last one is the one worth keeping: the stamp sits in the
CREATE branch beside enabled/owner, so an upgrade running as a different
tenant cannot move an already-adopted flow out from under the
organisation using it.

Mutation-checked: removing the setOrganisation() call turns
testAnImportedFlowIsStampedWithTheActiveOrganisation red.
…sation (#3007)

My own fix for the invisible-flow defect was half a fix, and its e2e
proved it — in CI, where the previous run could not.

A schema import runs during install and during `occ maintenance:repair`.
There is no user session there, so `getActiveOrganisation()` returns
null, the flow is stored with organisation NULL, and every flow read
being organisation-scoped it is invisible in /api/flows all over again.
The dev stack I verified on happened to HAVE an active organisation, so
it went green; the CI runner does not, and dossiq's case-flow e2e failed
on exactly the assertion written to catch this.

`getOrganisationForNewEntity()` is the call every ordinary object save
already makes, and it exists precisely to fall back to the default
organisation for callers with no session.

The test double now answers NULL from `getActiveOrganisation()`, so the
regression cannot pass: reverting the call turns two tests red.
#2982)

* feat(setup): a wizard that offers the demo data this app already ships

This app ships lib/Settings/*_mock_register.json - a dataset generated from its
own schemas, conformant by construction, validated by the generator's --check -
and had no way for an operator to reach it. There was no setup wizard at all.

welcome -> demo-data -> done. Nothing app-specific is invented: the only action
is the demo-data import the descriptor already supports. A wizard that asked
questions the app does not act on would be worse than none, which is why there
are no configuration steps here yet.

completed is TRUE and the demo-data step is optional, so setup never gates the
app. skip-demo-data records its outcome just as installing does: since
nextcloud-vue 2.21 an OUTSTANDING OPTIONAL step opens the wizard over every
page (nextcloud-vue#806), so a step that can never be marked done is a dialog
that never closes - the defect buildiq was failing 37 E2E specs on.

Verified: manifest validates against schema 2.26.0, gate-100 PASS, routes.php
and both PHP files parse. The template was checked on launchpad against phpcs,
phpstan, psalm and phpmd - all clean.

* fix(setup): declare the endpoints' auth, and translate the wizard's strings

Two gate findings on the previous push.

gate-5 route-auth — status() and runAction() carried no auth attribute. The
docblock said 'admin-only by Nextcloud's default for an un-attributed method',
which is true and is not a declaration: the gate exists because a missing
attribute silently makes an endpoint unreachable, and a comment cannot be
checked by middleware. Both now carry
#[AuthorizedAdminSetting(Application::APP_ID)], placed DIRECTLY above the
declaration - gate-5 walks upward from the method and a long docblock between
attribute and declaration costs the attribute its visibility, which the gate
documents as a false FAIL it had to repair.

gate-102 manifest-l10n-coverage — the wizard's title and body strings had no
l10n/nl.json key, so a Dutch user would read them in English. Added, and the
browser catalogue rebuilt where the app ships one: nl.json alone is not enough,
because the browser reads nl.js.

The catalogue edit is insertions only, proven against the same change applied
structurally - an earlier attempt on another app re-serialised the whole file
(410 lines) before being reverted.

* fix(setup): authorize against the admin settings class, and test what it guards

`AuthorizedAdminSetting` takes a `class-string<IDelegatedSettings>`, not an
app id. Passing `Application::APP_ID` is a plain string, so phpstan rejected
it — and the apps where this shipped green (larpinq, shillinq) already pass
their admin settings class. Match them: `OpenRegisterAdmin::class`, which
implements `IDelegatedSettings`.

gate-47 was right to fail this too. The change adds an admin-authorized
endpoint pair with no test alongside it, and a mocked unit test cannot show
that middleware admitting a real session. The e2e spec issues both calls from
inside the logged-in admin page, and asserts the install response NAMES how
much landed — the one assertion that separates a real import from one that
wrote nothing, which is the defect this programme already shipped once.

* test(setup): cover the demo-data controller and service

The coverage ratchet was right: this change adds ~364 lines of PHP with no
unit test behind them, so the coverage of the files it touches fell 46.48%.

The two assertions worth naming:

- a FAILED install must leave the step UNDECIDED. Recording the decision in
  the catch block would close the step for an operator who asked for demo
  data and received none — the wizard would never offer it again and nothing
  would have been imported.
- the object count comes from the FILE, not the importer's reply, so the
  number reported is the number ASKED FOR. An object whose schema does not
  resolve is skipped rather than errored, and that discrepancy must stay
  visible.

Both were verified by mutation: reversing each behaviour fails exactly the
test that claims to guard it.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…licks (#3011)

The ADR-111 setup step is OPTIONAL, and CnAppRoot opens the non-gating wizard
as a full modal mask while any optional non-info step is reported not-done —
in every fresh browser context, so once per spec. Merging the setup wizard
therefore turned this app's whole E2E suite red without touching a single
spec: the call log reads "locator resolved to <button ...> - attempting click
action" with <ol class="cn-wizard-dialog__progress"> named as the interceptor.
The element was found; the click never landed.

SKIPPED rather than installed, because recording the DECISION is what closes
the wizard. Installing would push the app's demo dataset into every list the
suite asserts on, which changes what the other specs measure.
`demo-data-setup-step.spec.ts` exercises the install deliberately, in
isolation.

Uses the workflow's own exported credentials rather than this script's
internals, and is tolerant of a non-200: an app whose wizard has no demo-data
step answers 400, and that is not a seeding failure.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
)

* perf(ci): pilot the changed-path filter and the reduced PR matrix

* style(phpmd): drop the else in parseExpression

`PHP Quality (phpmd)` was red on one violation:

    lib/Service/Aggregation/MetricExpressionEvaluator.php:180  ElseExpression
    The method parseExpression uses an else expression. Else clauses are
    basically not necessary and you can simplify the code by not using them.

The if/else assigned $value on both arms, so a ternary says the same thing with
no branch to object to. Arithmetic is unchanged — the two arms are the same
expressions in the same order.

php -l clean; no other else clause remains in the file.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…ts panel (#3014)

The panel that shows which objects a run touched has existed in nc-vue
since #834 and reached nobody: openregister was locked to 2.21.0, and the
LOCKFILE is what decides, not the range.

The range moves to ^2.22.0 as well, deliberately. Leaving the floor at
^2.21.0 would keep resolving a version WITHOUT the panel perfectly
happily — the app would build, boot and render an empty sidebar, which is
the failure mode this whole change exists to remove. The floor should
encode what the code actually needs.

Resolved with npm 11, which the repo requires for a reason: .npmrc sets
min-release-age=2 and npm 10 does not implement it. 2.22.0 published
today, so the cooldown would be in scope — it resolved forward anyway
because min-release-age-exclude[] covers @conduction/*, which is exactly
what that exclusion is for.

Verified rather than assumed:
  npm ci                      -> exit 0, so lock and manifest agree
  node_modules/.../package.json -> 2.22.0 on disk
  npm run build               -> exit 0
  grep in js/                 -> the built vendor bundle contains
                                 loadRunObjects, runObjects, AND the
                                 path flow-runs/${f}/objects, which is
                                 the endpoint #2979 added on the server

That last one is the point: the two halves now name the same route.
Exactly one package changed in the lockfile.
$data['schemas'] is built purely from schemasMap, which holds only the
schemas the current run processed — the schema pass does
`if ($schema === null) { continue; }` before populating it, so an existing
schema that was skipped or rejected never enters the map. The id list then
arriving at updateFromArray() is missing it, and the update REPLACES the
register's list, unlinking a schema the register still owns.

Nothing fails. The schema keeps its table and rows; the register stops
listing it. Register-scoped reads then return empty collections for data
that is present — indistinguishable from a working-but-empty install.

Measured on a live instance (#2935): 60 of dossiq's schemas were unlinked,
11 of them holding rows. `relink-schemas` recovered them with no
re-import and no data change, which is what identifies the linkage rather
than the objects as the defect.

Union rather than replace. A link this run can prove stays; a link it
merely cannot see is left alone. The failure direction becomes a stale
link, which relink-schemas already reports and repairs; the opposite
direction loses reachable data silently.
…ot-unlink-schemas

fix(import): a register import must not unlink schemas it cannot see
This spec shipped to development with the ADR-111 setup wizard and has never
executed once. The CI job runs `tests/e2e/ci/playwright.config.ts`, an explicit
allow-list where nothing runs unless it is named — and this file was not.
Measured on the merge run: `Running 65 tests`, none of them these three.

It could not simply be added, because it WRITES: the install is a real import
that creates this app's eight demo registers. Left behind they would land in
the lists the already-admitted specs assert on — which is exactly why the CI
seed settles the demo-data decision as *skipped* rather than installing it.

So it now takes criterion 2's second branch: an `afterAll` deletes those
registers, resolved by SLUG rather than an id captured mid-run, so an aborted
run still tears its fixtures down. `_limit` and not `limit`, because a bare
control param is read as a property filter by this API and returns zero rows
with HTTP 200 — a teardown that would silently delete nothing.

Verified by discovery rather than by assumption: the CI config lists 65 tests
before this change and 68 after, with the three arms named. 65 is the same
number the failing run reported, so this is the config CI actually uses.

Also fixes the lint this file carried in unmeasured (its import order and a
useless initialiser), and keeps the SPDX header first — `eslint --fix` hoists
the type import above it, which would break REUSE compliance.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
2.22.1 carries the theme app-id fix (nextcloud-vue#840). CnAppRoot calls
useScopedTheme() with no slug, so this app resolved theme tokens, the
token-set catalogue and the contrast check through a hardcoded 'nldesign'
app id. thematiq is renaming to 'thematiq', and every path in that
composable degrades to default styling by design — so once a renamed build
is installed this app would render unthemed with nothing in any log.

The LOCK is what moves here. A caret range alone changes nothing, because
npm ci installs what package-lock.json pins.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
The release workflow could only be triggered by a push. The shared
workflow already expects a dispatch -- its skip guard is written
`github.event_name != 'push' || ...` precisely so that a dispatch is
"deliberately never skipped ... including of a commit this guard would
refuse" -- and 18 of the 21 fleet apps already declare it. This repo was
one of the three that did not.

The per-branch job conditions are unchanged: `github.ref` is the branch
the dispatch runs on, so dispatching on development still selects the
unstable job, on beta the beta job, on main the stable one.
…s own (#3020)

`scripts/l10n/` — 21 register detectors, the gated writer, the status/worklist
reader, the 16-assertion selfcheck, the harvest and audit aids — plus
`docs/l10n-workflow.md` and `docs/l10n-ui-translation.md`.

WHY IT IS SPLIT OUT. This tooling was written inside #2634 alongside a large
translation effort, and it cannot merge until that effort finishes. But the
tooling is what makes the effort possible, and right now it exists on NO branch
of NO app — measured 2026-08-29 across the fleet, `scripts/l10n/apply.js` is
present on zero of decidiq's 458 remote heads and on no other app at all. Four
agents were sent to translate with it and all four correctly stopped: the method
was unsatisfiable because the instruments were not there.

So the instruments land first, and the data follows. Additive only: 60 new
files, no bundle is touched, no CI leg is added or changed, nothing behaves
differently until someone runs a script by hand.

WHAT IT MEASURES, on evidence from using it this week:

  register detectors    found 229 values in Estonian and 62 in Romanian
                        addressing the user in the wrong register — pre-existing,
                        invisible to every other check
  selfcheck             caught 276 Serbian translations written in Cyrillic into
                        a bundle that is 96% Latin
  apply.js's gates      refused patches whose "translations" equalled their key,
                        and refused a plural array with three forms where the
                        locale declares two — which renders BLANK at the counts
                        that select the missing index

None of those are findable by reading a diff.

One limitation worth recording for whoever uses it next: `apply.js`'s model is
"key absent -> translate". It has no path for a key that is PRESENT and holds
another language's text, because gate 7 refuses to overwrite a non-identical
value unless every key is named in `--allow-replace`. De-contaminating a copied
bundle needs a reset mode this tool does not have.

Verified on `development`'s own bundles: batch.js, selfcheck.js (15 assertions),
apply.js and all 21 detectors execute; 22 locale configs load.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.8 to 2.2.9.
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [nextcloud/ocp](https://github.com/nextcloud-deps/ocp) from 34.0.2 to 34.0.3.
- [Commits](nextcloud-deps/ocp@v34.0.2...v34.0.3)

---
updated-dependencies:
- dependency-name: nextcloud/ocp
  dependency-version: 34.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [eslint](https://github.com/eslint/eslint) from 10.8.1 to 10.9.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](eslint/eslint@v10.8.1...v10.9.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  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 [phpmetrics/phpmetrics](https://github.com/phpmetrics/PhpMetrics) from 2.9.1 to 2.11.0.
- [Release notes](https://github.com/phpmetrics/PhpMetrics/releases)
- [Changelog](https://github.com/phpmetrics/PhpMetrics/blob/master/CHANGELOG.md)
- [Commits](phpmetrics/PhpMetrics@v2.9.1...v2.11.0)

---
updated-dependencies:
- dependency-name: phpmetrics/phpmetrics
  dependency-version: 2.11.0
  dependency-type: direct:development
  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 [twig/twig](https://github.com/twigphp/Twig) from 3.27.1 to 3.28.0.
- [Release notes](https://github.com/twigphp/Twig/releases)
- [Changelog](https://github.com/twigphp/Twig/blob/3.x/CHANGELOG)
- [Commits](twigphp/Twig@v3.27.1...v3.28.0)

---
updated-dependencies:
- dependency-name: twig/twig
  dependency-version: 3.28.0
  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 [webonyx/graphql-php](https://github.com/webonyx/graphql-php) from 15.32.3 to 15.37.2.
- [Release notes](https://github.com/webonyx/graphql-php/releases)
- [Changelog](https://github.com/webonyx/graphql-php/blob/master/CHANGELOG.md)
- [Commits](webonyx/graphql-php@v15.32.3...v15.37.2)

---
updated-dependencies:
- dependency-name: webonyx/graphql-php
  dependency-version: 15.37.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 [dompurify](https://github.com/cure53/DOMPurify) from 3.4.13 to 3.4.14.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](cure53/DOMPurify@3.4.13...3.4.14)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…1.2 (#3002)

Bumps [@nextcloud/browserslist-config](https://github.com/nextcloud-libraries/browserslist-config) from 2.3.0 to 3.1.2.
- [Release notes](https://github.com/nextcloud-libraries/browserslist-config/releases)
- [Changelog](https://github.com/nextcloud-libraries/browserslist-config/blob/main/CHANGELOG.md)
- [Commits](nextcloud-libraries/browserslist-config@v2.3.0...v3.1.2)

---
updated-dependencies:
- dependency-name: "@nextcloud/browserslist-config"
  dependency-version: 3.1.2
  dependency-type: direct:development
  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>
Bumps [apexcharts](https://github.com/apexcharts/apexcharts.js) from 4.7.0 to 7.0.0.
- [Release notes](https://github.com/apexcharts/apexcharts.js/releases)
- [Commits](apexcharts/apexcharts.js@v4.7.0...v7.0.0)

---
updated-dependencies:
- dependency-name: apexcharts
  dependency-version: 7.0.0
  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>
Bumps [web-token/jwt-library](https://github.com/web-token/jwt-library) from 3.4.10 to 4.1.9.
- [Commits](web-token/jwt-library@3.4.10...4.1.9)

---
updated-dependencies:
- dependency-name: web-token/jwt-library
  dependency-version: 4.1.9
  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>
…licks (#3039)

@conduction/nextcloud-vue 2.22.x made the product walkthrough actually open.
A `placement: "center"` welcome step used to be parked in `_pendingAutoTour`
and never shown; the library now correctly starts it on any route. Its
`cn-walkthrough__dim--full` layer is a `role="dialog" aria-modal="true"`
overlay, so every spec that clicks behind it times out, and
`getByRole('dialog').first()` resolves to the dim layer rather than the
modal under test.

The marker is per USER, not per test, so leaving it unseeded also makes the
suite order-dependent: whichever spec runs first wears the tour.

Seeds the same marker dossiq's global-setup already seeds, with a sentinel
above any real app version so the tour composes to an empty step set.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
#3026)

Every browser catalogue in this app has been telling the runtime that its
language pluralises like English.

scripts/build-l10n-js.js ends each generated bundle with

    doc.pluralForm || DEFAULT_PLURAL_FORM

where DEFAULT_PLURAL_FORM is 'nplurals=2; plural=(n != 1);'. Only lb and rm
declared a pluralForm, and both happened to declare that same two-form rule, so
36 of 38 catalogues silently inherited English's rule. Czech, Polish, Russian,
Ukrainian, Belarusian, Slovak and Slovenian need four forms; Irish needs five;
Croatian, Bosnian, Serbian, Lithuanian, Latvian and Romanian need three;
Spanish, French and Italian need the CLDR "many" category. All of them declared
two, so the runtime picked form 0 or 1 for counts that need another form.

The rules come from Nextcloud core (core/l10n/<locale>.js), which is the
authority for the runtime that evaluates them. Core ships no catalogue for bs,
et, lb, lt, mt, pt, rm or sq; those carry the standard CLDR rule for the
language.

Declaring the right rule is only half of it: a rule is a promise about how many
forms every plural array holds, so the arrays had to be filled to match. Two
separate sets needed it, and the second nearly escaped: the "plurals" object in
each catalogue, and a plural that lives in "translations" as an array under a
_singular_::_plural_ key. Verifying only the first reported success while nine
generated bundles still disagreed with their own header.

Belarusian's forms were partly Russian wording in a Belarusian catalogue; they
are consistent Belarusian now. The pre-existing cross-locale duplication between
bs, hr and sr is a different defect and is deliberately untouched here.

Verified on the generated artifacts, not the sources: every plural array in
every l10n/*.js now has exactly as many forms as that file's own nplurals
declares. check-l10n-parity, check-l10n, check:l10n-js and check:schema-l10n all
exit 0.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
…2994)

Bumps [elasticsearch/elasticsearch](https://github.com/elastic/elasticsearch-php) from 8.19.0 to 9.5.0.
- [Release notes](https://github.com/elastic/elasticsearch-php/releases)
- [Changelog](https://github.com/elastic/elasticsearch-php/blob/main/CHANGELOG.md)
- [Commits](elastic/elasticsearch-php@v8.19.0...v9.5.0)

---
updated-dependencies:
- dependency-name: elasticsearch/elasticsearch
  dependency-version: 9.5.0
  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>
Bumps [gridstack](https://github.com/gridstack/gridstack.js) from 12.6.0 to 13.2.0.
- [Release notes](https://github.com/gridstack/gridstack.js/releases)
- [Changelog](https://github.com/gridstack/gridstack.js/blob/master/doc/CHANGES.md)
- [Commits](gridstack/gridstack.js@v12.6.0...v13.2.0)

---
updated-dependencies:
- dependency-name: gridstack
  dependency-version: 13.2.0
  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>
rubenvdlinde and others added 29 commits August 29, 2026 18:52
…ue 2.24.0 (#3056)

* fix(flow): version EVERY flow, not the first hundred

`FlowMapper::findAllFlows()` defaults to `limit: 100`. Both flow repair steps
called it bare, so each silently processed one page and reported success.

Measured on a dev instance immediately after upgrading: **219 flows, 100
versioned, 119 left with no published version** — and a flow with no published
version backs no run. The repair step that exists to stop versioning being a
fleet outage was itself producing one, on 54% of the flows, while printing a
cheerful summary.

The trigger back-fill had the identical bug and it is worse there, because the
symptom is pure silence: a flow past the first page is simply never subscribed
and never fires.

Both now page to exhaustion. Paged rather than passed a large limit on purpose:
a limit big enough to look obviously safe today truncates silently once an
instance grows past it, and the failure would look exactly the same.

Verified by re-running `occ maintenance:repair` against the half-versioned
instance the bug produced:

  Flow versions: published version 1 for 119 flow(s), 100 already versioned
  flows 219 · flow_versions 219 · still unversioned 0

which also demonstrates the idempotency guard doing its job on the 100 that
were already done, and the content store deduplicating 219 flows down to 24
distinct graphs.

* chore(deps): take @conduction/nextcloud-vue 2.24.0 for the flow version lifecycle

2.24.0 carries the editor half of flow definition versioning: the read-only
canvas on a published version, the version badge, and Publish / Create draft /
Deprecate. Without it an author has no way to publish a flow — and since a
draft backs no run, a newly created flow could not be run from the interface at
all.

Exactly one package changed: 2.22.1 -> 2.24.0.

Resolved with npm 11 deliberately: local npm 10.8.2 does not implement
min-release-age, so a lockfile written by it does not reflect the cooldown
policy this repo's .npmrc configures.

* fix(flow): a refusal must reach the client as 409, not 500

Running a flow with no published version escaped `FlowController::run()` as an
unhandled exception and reached the caller as an HTML error page. A 500 reads
as "the server is broken" for what is actually "publish this flow first" — it
sends the author to exactly the wrong place.

`FlowRunController::test()` leaked the same way, and additionally leaked
`FlowDeadEnd`, so pressing Run in the editor on a half-wired graph produced a
stack trace instead of the sentence the engine had carefully written.

**Every unit test passed through both defects**, because they all assert on the
EXCEPTION rather than on the response a caller receives. The e2e caught them.
There is now a controller test for the 409 path, and removing the handler turns
it red.

`FlowService::run()` also gained the `@throws FlowLifecycleRefused` it was
missing — without it PHPStan called the new catch dead, having no way to know
what the method actually throws.

Test fixtures: several single-node flows had no outgoing edge, which is a dead
end, so publishing them was correctly refused. They now mark `exit: true` —
stopping there is deliberate in those fixtures, which is exactly what the flag
is for.

* fix(e2e): publish before running — a draft backs no run

The spec that turned development red asserts the author journey build → save →
run, and asserts 201 on the run. Versioning changed that journey: a flow is
CREATED as a draft, and `flow-definition-versioning` says plainly that "a run
SHALL be queued against the flow's published version. A draft or deprecated
version SHALL NOT back a newly queued run." So from #3047 onward the journey is
build → save → PUBLISH → run, and this spec was asserting a contract the app
deliberately no longer offers.

It surfaced as a 500 because `FlowLifecycleRefused` escaped
`FlowController::run()` unhandled. That half is a real defect and is fixed in
7ed9ab7 — the refusal is now a 409 naming `no-published-version`. But 409 is not
201 either, so the status-code fix alone leaves this spec red, and nothing on the
PR would have said so, because PRs into development SKIP the E2E job.

The opposite shortcut was considered and rejected. Letting
`POST /api/flows/{id}/run` quietly fall back to an unpinned draft test run makes
this spec pass, and `tests/e2e/flow-engine.spec.ts` then asserts the opposite in
two places: that this exact endpoint refuses a draft, and that it refuses a
DEPRECATED flow, both with `no-published-version`. Two checks over one endpoint
that cannot both hold. Silently running a retired process is a worse defect than
the one being fixed, so the endpoint keeps its rule and the spec gains the step.

Nothing is weakened: every existing assertion stands, including 201 on the run.
Publishing goes through the editor's own control, so the new lifecycle UI is on
the CI path too, and it asserts the BADGE rather than the click — a button that
posts and silently fails looks exactly like one that worked.

Also annotates the header's 1-node/0-node table as HISTORY. It records the
dead-end 500 that step 2 fixed, and has already been read once as if it
explained this later, unrelated 500 — sending the reader hunting for something
the run path does per NODE. Node count had nothing to do with it.

* test(flow): cover the paging fix and the refusals it now returns

Nine unit tests over the code this PR changed, each with a positive control
verified by reverting the fix and watching the named test go red.

`BackfillFlowPagingTest` is the one that matters. The mapper double is a REAL
pager — it honours `limit` and `offset` exactly as `FlowMapper::findAllFlows()`
does — because a double that ignored them would let the unpaged implementation
pass too, and the test would be asserting the double rather than the step. Over
a 1,003-flow instance the unpaged version versions 100 and reports success,
which is the defect as measured. Both steps are asserted on their OUTCOME (the
version rows inserted; the flows handed to `rebuild()`), not on the call count:
"it paged" is not the claim, "no flow was left unrunnable" is. A second test
pins the mechanism, so a later implementation that reached the same total with
one enormous page — which truncates silently once an instance outgrows it — is
still visible.

Also covered: the per-flow catch, because an aborted walk turns one broken flow
into a broken instance; the trigger back-fill's refusal to throw, because an
exception there aborts the upgrade carrying the fix; and idempotency, because
`occ maintenance:repair` is expected to be re-runnable.

`FlowRunControllerTest` gains the two refusals `test()` now catches. Both were
escaping as HTML 500s, and every existing unit test passed through that, because
they all assert on the EXCEPTION rather than on the response a caller receives —
the same blind spot the 409 commit describes for `FlowController::run()`. These
assert the response.

Controls: reverting `everyFlow()` to a bare `findAllFlows()` fails 4 of the 7
paging tests at "100 of 1003"; removing the per-flow catch errors the resilience
test; removing either catch in `test()` errors both controller tests with the
exception escaping.

* fix(flow): realign with development's test-run route, and drop the duplicate catch

development independently fixed the 500-on-refusal and went further: the manual
run endpoint now queues as TRIGGER_TEST, so pressing Run in the editor works on
a flow that has never been published. That is the right call — requiring
publication before you may TRY a flow makes publishing a precondition of
testing, which is backwards.

Git merged my catch alongside theirs and produced a duplicate; PHPStan caught
it as an already-caught catch. Theirs stays, because it also carries the
trigger change.

Two e2e specs asserted the OLD behaviour and merged cleanly while becoming
untrue — a textual merge over a semantic conflict:

- 'a draft cannot be run' now asserts the opposite, that a draft CAN be run
  from the editor and that the run is UNPINNED, which is the property that
  actually matters: it walked the draft it was started with.
- 'a deprecated flow backs no new run' asserted a 409 that no longer happens on
  that endpoint. It now asserts the invariant that does still hold — deprecating
  leaves the flow with NO published version, which is what stops a TRIGGERED
  run — rather than asserting something the product does not do.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…rejection (#3068)

`handleValidationException()` formats `$exception->getErrors()` unconditionally.
That getter is typed `?ValidationError` and IS null for every
ValidationException thrown with a message alone — `enforceReadOnlyOnUpdate()`
is one such path and says so in a comment where it throws.

`ErrorFormatter::format(null)` is a TypeError, so the 400 this method exists to
return became a 500 whose body carried none of the reason. Observed on dossiq's
development, in the server log for every case edit:

    Opis\JsonSchema\Errors\ErrorFormatter::format(): Argument #1 ($error) must
    be of type Opis\JsonSchema\Errors\ValidationError, null given, called in
    .../ValidateObject.php on line 2233

The caller saw "server error" where the server had a precise sentence for it —
"Cannot modify readOnly property: X" — which is exactly what an operator, and
an e2e failure, needs to see.

Guarded: format only a real ValidationError, and let the message carry the rest.

Mutation-verified — removing the guard reproduces that TypeError verbatim in
the new test, which otherwise asserts a 400 whose message still names the
violated property.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Each run creates about fifteen flows and deleted none of them, so a shared
instance accumulated them run after run. Measured today: **300 flows, 104 of
them abandoned e2e fixtures.**

That is not merely untidy. The flows index degrades with the row count until
the list-page specs time out — a single spec exceeded 400 seconds — so the
suite was slowly breaking itself, and the failures MOVED between runs rather
than staying put. Left alone it reads as a product bug in the index rather than
as litter, which is the expensive kind of wrong.

`test.afterAll` now deletes what the run created, scoped to this run's own
`RUN_ID` prefix — never a blanket sweep of test-looking names, because a
parallel run's fixtures are not ours to remove. It never fails the suite: a
cleanup error is worth logging but is not a verdict on the code under test.

Proven by measurement rather than assumed: flows 196 before, 196 after,
`cleaned up 19 fixture flow(s)`, zero fixtures left.

Also: the published-badge spec gave its 15s timeout only to the FIRST
assertion, so the sidebar's later renders fell back to the default 5s. Fine in
a 3.3-minute describe, marginal in a 7-minute full run — it failed once there
and passed in isolation, which is exactly that shape. Every assertion in it now
carries the timeout, and the sibling spec waits for the button before clicking.
* fix(calc): a sequence-bearing identifier must survive an update

A materialised calculation containing a `sequence` node was recomputed on
every save. Off the create path there is deliberately no SequenceContext, so
the evaluator resolves the node to null and `concat` renders it as an empty
string — turning a stored "2026-0013" into "2026-" and silently destroying
the assigned running number.

Where that number is also `readOnly: true` (dossiq's `case.identifier`,
a statutory zaaknummer), the corruption is user-visible: the edit form
round-trips the value it was rendered with, readOnly enforcement compares it
against the now-truncated stored value, and every PUT is rejected with
"Cannot modify readOnly property: identifier". No case could be edited at
all — dossiq e2e "editing a case persists the change" fails on exactly this.

Skip re-materialising a sequence-bearing calculation whenever no
SequenceContext is active, leaving the stored value untouched. The same guard
covers the two other paths that persist a recomputation with no context: the
temporal sweep (which also reported a spurious change on every pass) and
`occ openregister:rematerialise-calculations` — the command schemas actually
tell admins to run, which would have rewritten every identifier in a register.

The two read-time paths (RenderObject, ManifestService) already skip
materialised calculations and are unaffected.

* docs(calc): point the inline references at the actual PR
* feat(openspec): rbac-disable-public-inheritance

Add an opt-out for the "logged-in users inherit public group rights"
semantics in OR's RBAC. Schemas and registers gain an optional
inheritFromPublic boolean (default true, backwards-compatible). When
false, authenticated users do NOT qualify for public rules — they
qualify only via their own group memberships. Anonymous users see
no behaviour change.

Cascade: schema → register → IAppConfig
openregister.rbac.inherit_from_public_default → hard-coded true.

Implementation touches both RBAC layers identically:
- PHP-side PermissionHandler::hasPermission inheritance fallback
  (line 229-241)
- SQL-side MagicRbacHandler::processConditionalRule + processSimpleRule
  (and their UNION-mode siblings buildRbacConditionsSql +
  processConditionalRuleSql)

Modified capability: rbac-scopes. Tracks GitHub issue #1439.

* feat(rbac): implement inheritFromPublic flag (#1439)

Adds an opt-out for the implicit "logged-in users inherit public group
rights" semantics. Schemas (and registers, via cascade) gain an optional
inheritFromPublic boolean, default true (preserves pre-change behaviour).

Cascade:
  schema.authorization.inheritFromPublic
  → register.authorization.inheritFromPublic
  → IAppConfig openregister.rbac.inherit_from_public_default
  → hard-coded true
null is treated as "unset" — cascade falls through.

PermissionHandler:
  - new constructor dep IAppConfig
  - new public resolveInheritFromPublic(Schema): bool with per-request cache
  - hasPermission line 229-241 inheritance fallback now gated on the flag

MagicRbacHandler:
  - resolveInheritFromPublic(Schema) helper delegating to PermissionHandler
    via existing container DI
  - applyRbacFilters resolves the flag once at the top, plumbs through
    processAuthorizationRule → processConditionalRule + processSimpleRule
  - same plumbing in the UNION-mode path: buildRbacConditionsSql →
    processAuthorizationRuleSql → processConditionalRuleSql, and the shared
    processSimpleRule
  - the per-object hasPermission method (separate from PermissionHandler's)
    also gated identically

Behaviour:
  - inheritFromPublic = true (default): unchanged from pre-change.
  - inheritFromPublic = false + anonymous user: still qualifies for public.
  - inheritFromPublic = false + authenticated user: does NOT qualify for
    public rules; only own-group / owner / admin grants apply.

Tests:
  - new PermissionHandlerInheritFromPublicTest covers cascade resolution
    (4 levels + null=unset semantics) and the four-state matrix on
    hasPermission, plus owner/admin shortcut invariance.
  - existing PermissionHandlerRbacTest updated for new constructor sig.

Quality:
  - PHPCS clean on touched files (auto-fix + manual passes).
  - PHPStan clean.
  - Psalm clean.
  - openspec validate clean.

Deferred (tracked in tasks.md as not-yet-checked):
  - 3.6, 3.7: SQL-side unit tests (need fixture DB).
  - 6.x: cross-app smoke tests against running stacks.
  - 7.3-7.5: integration tests against running services.
  - 8.1, 8.2: docs extension + worked example.
  - 9.1, 9.4: full unit suite (PHPUnit needs the NC docker bootstrap)
    + manual live-stack smoke.

Closes (partially) #1439.

* feat(rbac): expose inheritFromPublicDefault in settings UI (#1439)

Surfaces the tenant-wide `rbac.inherit_from_public_default` IAppConfig key
through the existing settings payload as `rbac.inheritFromPublicDefault`,
and renders a toggle for it in the RBAC configuration section. Also
updates the rbacOptions store default so the switch hydrates correctly
on first load.

Pairs with the schema-level checkbox in @conduction/nextcloud-vue
(CnSchemaSecurityTab). Backend cascade was added in 3c05b62.

* fix(rbac): wire inheritFromPublic through dedicated endpoint and validator (#1439)

Two gaps surfaced during /opsx:verify against the live stack:

1. Schema::validateAuthorizationRules rejected `inheritFromPublic` because
   it only allowed CRUD action keys. The validator now treats it as an
   optional sibling of the action keys and verifies it is a boolean (or
   null = unset).
2. ConfigurationSettingsHandler::getRbacSettingsOnly /
   updateRbacSettingsOnly handle the dedicated `/api/settings/rbac`
   endpoint that the frontend store actually calls. They now read and
   write the `rbac.inherit_from_public_default` IAppConfig key, matching
   the unified `getSettings` / `updateSettings` paths added earlier.

Verified end-to-end via the four-state matrix on /api/objects: with
inheritFromPublic=true (default) anon and authenticated users both see
public-conditional rows; with false set per-schema, anon still sees
them but authenticated users without explicit group membership do not.

* docs(rbac): mark live-stack smoke checks done in tasks.md (#1439)

Verified manually against the Docker NC stack:

  - /api/settings/rbac round-trip (read + write) for inheritFromPublicDefault
  - schema-level inheritFromPublic accepted by validator and round-trips
    through /api/schemas/{id}
  - four-state matrix on /api/objects: (anon|auth) × (true|false) yields
    counts that match spec — only (auth, false) is denied; the other three
    states see the public-match objects

Tasks 6.1, 6.2, 9.4 set to done. Remaining open items are unit-test
extensions (3.6, 3.7, 7.x), additional docs (5.2, 8.1, 8.2), the broader
suite run (9.1), and the Softwarecatalog smoke (6.3).

* test(rbac): add SQL-side inheritFromPublic matrix tests (#1439)

Adds tests/Unit/Db/MagicMapper/MagicRbacHandlerInheritFromPublicTest.php
covering buildRbacConditionsSql and applyRbacFilters under the four-state
matrix (anon|auth × inheritFromPublic true|false), plus parity checks for
the simple-string `'public'` rule, the `'authenticated'` rule, and admin
bypass. 10 new tests, all green via the in-container PHPUnit runner.

Also fixes a regression in the existing PermissionHandlerRbacTest where
buildHandlerWithRealMatcher() didn't pass the new IAppConfig dependency
into PermissionHandler, causing 10 errors during the full suite run.

Knocks out tasks 3.6, 3.7, 7.1, 7.2, 7.3, 7.4, 7.5, 9.1 in the change's
tasks.md — the SQL-side matrix is now unit-tested and the integration
scenarios are covered by either the cascade unit tests (cascade fall-
through paths) or the live-stack matrix run during /opsx:verify.

* docs(rbac): document inheritFromPublic flag and tenant default (#1439)

Extends docs/Features/access-control.md with:

  - The optional `inheritFromPublic` boolean on the schema authorization
    JSON example
  - A new section "Disabling public-group inheritance for authenticated
    users" covering the cascade (schema → register → IAppConfig → true),
    the four-state matrix, a worked publication-style example, and the
    `'authenticated'` simple-rule alternative
  - The new `inheritFromPublicDefault` field in the RBAC Configuration
    block, including the IAppConfig key and the occ command

Also cross-references the new flag from the `"public"` row of the rule
table in docs/Features/property-authorization.md, since the previous
phrasing ("matches any authenticated user") was unconditional.

Closes tasks 5.2, 8.1, 8.2 in the rbac-disable-public-inheritance change.

* Fix composer vulnerability

* Updated package

* fix(rbac): address PR #1440 review — strict bools, no fail-open, CSRF (#1439)

Addresses the blocker + 3 concerns + 1 minor flagged in WilcoLouwerse's
strict review of PR #1440.

🔴 Blocker — drop the silent fail-open in
MagicRbacHandler::resolveInheritFromPublic. The previous try/catch
returned `true` on any Throwable from the cascade walk, which silently
undid the gate the tenant explicitly opted out of and let this SQL path
diverge from the PHP per-object check (which propagates). Spec invariant
"per-object checks and listing membership cannot drift" now holds even
under failure: the request fails (5xx) instead of leaking rows.

🟡 Concern 1 — strict-boolean check at schema/register cascade levels.
PHP's `(bool) "false"` is `true`, so a register persisted via direct
mapper write / migration / seed JSON could store a string and silently
invert the gate. Both schema (line 716) and register (line 728) now
require literal `true` or `false`; anything else (string, int, etc.) is
treated as "unset" and logged as a warning. Three new cascade tests
pin the strict-equality contract.

🟡 Concern 2 — strict normalization on the API write paths.
`updateRbacSettingsOnly` and `updateSettings` now use `filter_var` with
`FILTER_VALIDATE_BOOLEAN | FILTER_NULL_ON_FAILURE` (matching the docs'
boolean-tolerance claim) and throw on garbage rather than silently
coercing `(bool) "false" === true`. Three new tests pin the
normalize-and-persist contract (real bool, "false" string, garbage
rejection).

🟡 Concern 3 — drop @NoCSRFRequired from updateRbacSettings. This
endpoint is now security-load-bearing (it flips a tenant-wide RBAC
default); CSRF protection on state-mutating admin endpoints is required
by ADR-005. Frontend uses @nextcloud/axios which sends the request
token automatically; no UI change needed.

🟢 Minor — docblock note on resolveInheritFromPublic about transient
schemas (no-cache path) so future readers don't expect cache hits on
in-memory drafts.

Tasks 6.1 (DocuDesk smoke) re-opened — the previous justification
was a settings-endpoint round-trip, not a behavioural exercise of
DocuDesk's consent-fetch endpoint. Honest accounting per reviewer's
note.

Tests: 71 RBAC + 7 settings (was 68 + 4) — all green via the
in-container PHPUnit runner.

* chore(rbac): phpcs auto-fix in PermissionHandler — docblock spacing (#1439)

Five auto-fixable violations the local cached run missed:

  - 2× "Expected 1 blank line after function; 2 found" between the
    `coerceStrictBoolOrLog` helper and its neighbours
  - 3× parameter-type alignment in the helper's @PARAM block

Picked up by composer phpcs (CI scope: lib/) on PR #1441.

* fix(beta): remove the orphaned inheritFromPublicDefault RBAC control

Defect introduced by the development->beta sync (#2636), found by checking
CONTENT rather than files.

Old beta carried `inheritFromPublicDefault` in four places: two backend
(ConfigurationSettingsController, ConfigurationSettingsHandler) and two
frontend (store/settings.js, RbacConfiguration.vue). The sync resolved the
two BACKEND files to development's version — development grafted
`inheritFromPublic` but deliberately never took the `Default` setting (0
references) — while the two FRONTEND files merged cleanly and kept beta's.

The result was a settings UI bound to a backend that no longer exists: a
checkbox on `rbacOptions.inheritFromPublicDefault`, defaulted true in the
store, in the RBAC settings panel — a silent no-op control in a SECURITY
surface, where an admin would believe they had changed an access-control
default and nothing would persist.

This aligns the two frontend files with development, which is what the rest
of the sync did. Beta is now internally consistent: zero references to
`inheritFromPublicDefault` in lib/ or src/, matching development.

Note for the record: the file-level check I ran after the sync reported
"0 files lost" and was true but insufficient — no file disappeared, content
inside resolved files did. Content-level verification is what caught this.

* chore(release): 1.1.6-beta.20260820205738 [skip ci] (#2641)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: Robert Zondervan <robert@conduction.nl>
Co-authored-by: Remko <remko@conduction.nl>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
#3074 carried beta's CONTENT into development but was squashed, so git never
recorded beta as an ancestor. The merge base did not move, both branches still
counted as having changed the version independently, and the promotion PR
(#2976) stayed 'dirty' with development 21 commits behind.

This is the same merge as a real merge commit, so the ancestry is recorded and
development -> beta becomes a clean promotion. Content is already in place from
#3074, so this changes almost nothing on disk; the version conflict resolves to
development's 1.1.8-unstable, the higher of the two, as before.

A sync or promotion between long-lived branches must be merged, never squashed:
a squash copies the files and throws away the relationship that makes the NEXT
merge clean.
…red (#3072)

* fix(edepot): the transfer flow had no way to approve anything

`TransferController::create()` dispatches only a list whose status is
`approved`, and nothing in the fleet could set that status.
`TransferListService::approveTransferList()` was implemented, carried a @SPEC
tag for edepot-transfer, and had zero callers — in this repo or in any sibling
app. `rejectTransferList()` was in the same state. So a transfer list could be
built and then never moved, and the capability the spec requires was
unreachable in practice.

The service was ALREADY imported by the controller. The wiring had been started
and left unfinished, which is why nothing looked obviously missing.

Both decisions now have a route and a controller method. They share one private
`decide()` because they share their entire shape — load, refuse if absent,
refuse if the status forbids it, stamp who decided — and two copies of that
would drift.

A rejection REQUIRES a reason. An archivist refusing a transfer is a
records-management decision somebody has to justify later, so an empty reason
is a 400 rather than a silently empty field.

The service's `InvalidArgumentException` maps to 409, not 500: "this list is
not in review" is a state the caller can see and act on, not a server fault.

Five tests. Stamping a fixed archivist instead of the acting user turns two of
them red.

* fix(audit): remove the two methods that could delete an audit trail

`LogService::deleteLog()` and `deleteLogs()` call
`auditTrailMapper->delete($log)`. The audit-trail specification contains a
requirement titled, in as many words:

    ### Requirement: Audit trail entries MUST NOT be deletable or modifiable

with cryptographic hash chaining, a ten-year minimum retention, and the note
that 56% of analysed government tenders require exactly this. The controller
enforces it: `auditTrail#destroy` and `auditTrail#destroyMultiple` are routed
and return HTTP 405 "Audit trail entries cannot be deleted" unconditionally.

So these were not a capability waiting to be wired. They were the opposite of
one: an implementation of something the specification forbids, sitting
unreferenced next to a hash chain they would silently break. gate-57 read them
as "orphaned write capability" — accurate, but the remedy is removal, not a
route.

Their only @SPEC pointed at a retrofit CHANGE rather than a spec
(`changes/retrofit-2026-05-25-bw2-svc-flat-2`), which is the shape of a legacy
annotation rather than a live requirement.

`clearAll` is untouched: it purges EXPIRED entries via
`AuditTrailMapper::clearAllLogs()`, which is retention, not deletion of
arbitrary history.

Five tests went with them. Tests for a capability the spec forbids were
asserting that the forbidden thing works.

* fix(files,skos): give two specified capabilities a way in

**File description and category had no surface at all.**
`FileMapper::setDescriptionForFile()` and `setCategoryForFile()` are reached
only from `UpdateFileHandler::updateFileMetadata()`, and nothing called that.
Labels DID have a route and worked, which is exactly why the gap was easy to
miss: the feature looked present because a third of it was.

`PUT .../files/{fileId}/metadata` now reaches all three, behind the same
object-level RBAC `updateLabels` uses — descriptive metadata is no less a
mutation for being descriptive. Each field is skipped when absent and cleared
when empty, so a caller can change one without wiping the others; that is the
handler's contract, and `array_key_exists` is what preserves it where `??`
would make an empty description impossible to send.

The controller reaches the handler through FileService, the way it already does
for `updateFile()`, rather than being handed the handler itself.

**SKOS CSV import had no caller.** `VocabularyImportService::importCsvValueList()`
is specified by `skos-concept-registers` and was reachable from nothing. It now
has `occ openregister:vocabulary:import-csv`.

A COMMAND rather than a route, deliberately: the service takes a filesystem
path, which is meaningless across an HTTP boundary — the file is on the server,
not in the caller's request. Exposing it as an endpoint would mean either an
upload surface nobody asked for, or accepting a server-side path from a client,
which is a traversal invitation.

The command prints created/updated/unchanged/deprecated rather than "done",
because an idempotent import's whole point is that the second run changes
nothing, and that is only visible if the numbers are shown.

* fix: catch the exception this controller actually raises, and cover the new endpoint

Two CI failures on #3072, both mine.

**psalm**: `updateMetadata()` caught `NotPermittedException`, which is not
imported in this controller and does not exist in its namespace. I mirrored
`updateLabels()`'s structure without mirroring what it CATCHES — it uses
`\OCA\OpenRegister\Exception\NotAuthorizedException`. phpstan passed it because
the class name resolved to a plausible-looking FQN; psalm asked whether that
class exists. It does not.

**gate-25 contract-coverage**: `files#updateMetadata` was a new public endpoint
with no contract test. `transfer#approve` and `transfer#reject` passed the same
gate because they already had tests — which is the gate working exactly as
intended.

Four tests now cover it, and two of them pin the contract that is easiest to
break: absent means "leave this field alone", empty string means "clear it".
Collapsing those with `??` would make an empty description impossible to send
and would silently wipe fields a caller never mentioned.

Verified against CI's real scoping (`--scope-to-diff`, which is how the
workflow invokes it, and not what a bare local run does): ALL 37 APPLICABLE
GATES PASS.
chore(release): merge beta history into development
* fix(flow): delete a flow's version rows with the flow

`FlowService::delete()` already cascades to trigger rows, runs, steps and
state, each with a comment explaining why an orphan there is landfill. I added
`openregister_flow_versions` and did not extend that cascade, so every deleted
flow left its version rows behind. Measured on the dev instance: **38 orphans**,
unreachable by any read path the app has, since every version read is by flow.

Safe precisely BECAUSE the runs are deleted first. A version row exists so an
in-flight run can resolve the graph it was pinned to — and this method has just
removed every run of this flow, so nothing can still be pinned. Confirmed on
the instance: zero active runs pinned to a version whose flow is already gone.

`openregister_flow_defs` is deliberately NOT touched. It is content-addressed
and SHARED — two flows holding the same graph share one row, and 219 flows
deduplicated to 24 definitions here — so deleting by flow would pull a
definition out from under an unrelated flow's version.

Verified against a live instance rather than reasoned about: create a flow,
publish it (1 version row), delete it, 0 version rows.

* test(flow): cover the version cascade the ratchet caught

The coverage guard failed #3080: 60.10% head against 61.11% base, "this change
adds 5 statements to those files". It was right — I added `deleteByFlow()` and
its call site with no test for either.

Two mapper tests, and one that makes the CASCADE test actually assert the new
cascade member. That last one needed a fix to the harness: its container double
returned the organisation stub for EVERY `get()`, so the version cascade ran,
threw "no such method", and was swallowed by `delete()`'s own try/catch — the
line executed and asserted nothing. The container now answers by class.

Both mutation-checked:

- dropping the `WHERE` from `deleteByFlow()` — which would delete every flow's
  versions and strand every in-flight run on the instance — turns the mapper
  test red;
- removing the cascade line from `delete()` turns the cascade test red.

733 flow tests green.
…#3082)

Supersedes #2992 and #2984. Both dependabot PRs failed 15/14 checks for
one reason that had nothing to do with CSS: `npm ci` could not resolve,
so "Install dependencies" failed and every frontend job — stylelint,
eslint, build, unit tests, check:specs, the npm license and security
legs — died before running. The stylelint step itself was SKIPPED, not
red.

@nextcloud/stylelint-config@3.2.2 peer-requires stylelint ^17.9.1 and
stylelint-config-recommended-scss ^17.0.1, but both were pinned as root
devDependencies at ^15.11.0 / ^13.1.0, and stylelint was additionally
held at ^15.11.0 by an `overrides` entry. Bumping one package alone can
never resolve; the stack has to move together:

  @nextcloud/stylelint-config      ^2.4.0   -> ^3.2.2
  stylelint                        ^15.11.0 -> ^17.9.1  (devDep + overrides)
  stylelint-config-recommended-scss ^13.1.0 -> ^17.0.1
  stylelint-webpack-plugin         ^4.1.1   -> ^5.1.0

stylelint-webpack-plugin@4 peer-caps stylelint at ^15. It is declared but
never imported (no reference in webpack.config.js or anywhere else), so
this is a peer-range fix only, with no build behaviour attached.

With install fixed, stylelint actually ran and reported 233 errors. 213
were a single dead config entry: `indentation: null` in stylelint.config.js.
Stylelint REMOVED that rule in v16, and from v17 a removed rule name is an
"Unknown rule" error even when set to null. The comment already sitting
above it predicted this ("removed in 16, so it is on its way out
regardless"). The entry is deleted, not the enforcement — prettier owns
indentation via the `format` script, whose glob is wider than stylelint's.

The remaining 20 errors were genuinely deprecated CSS, fixed rather than
disabled, and all rendering-neutral:

  14x  word-break: break-word  -> overflow-wrap: break-word
   2x  word-wrap: break-word   -> overflow-wrap: break-word
   1x  word-wrap: normal       -> overflow-wrap: normal
   1x  grid-gap                -> gap
   1x  clip: rect(0 0 0 0)     -> clip-path: inset(50%)
   1x  -webkit-box-align: end  -> deleted

`word-break: break-word` is defined by spec as equivalent to
`overflow-wrap: break-word`, `word-wrap` is the legacy alias of
`overflow-wrap`, and `grid-gap` is an exact alias of `gap`. No site had a
competing overflow-wrap declaration. `clip: rect(0 0 0 0)` is the classic
visually-hidden idiom and `clip-path: inset(50%)` is its modern
equivalent, so the screen-reader-only class keeps behaving identically.

The -webkit-box-align case is the one where stylelint's suggestion is
wrong for this code. It advises rewriting to `align-items`, but the rule
is `display: flex` and already carries `align-items: center` on the line
above; `-webkit-box-align` belongs to the 2009 flexbox draft and only
applies under `display: -webkit-box`, so it is inert here. Following the
suggestion would have set alignment to `end` and visibly moved the
content. Deleting the dead prefixed property is the rendering-neutral fix.

Not adopted: stylelint-config-recommended-vue stays at ^1.6.1, so #2984
is NOT included. @nextcloud/stylelint-config@3.2.2 peer-requires
recommended-vue ^1.6.1, and recommended-vue@2.0.0 requires
postcss-html ^2.0.0. Bumping recommended-vue alone is a hard ERESOLVE;
bumping postcss-html alongside it only "resolves" because npm overrides
the root's ^2.0.0 back down to 1.6.1 to satisfy the @nextcloud peer,
which would leave package.json claiming a version the tree does not use.
#2984 is blocked upstream until @nextcloud/stylelint-config widens that
peer range.

Left alone deliberately: 184 csstools/use-logical WARNINGS, a new rule in
the v3 config flagging physical properties (border-left, margin-left,
text-align: left) that have logical equivalents. They are warnings, CI
runs `npm run stylelint` with no --max-warnings, and the run exits 0.
Converting them is a real RTL behaviour change, not a lint fix, so it
belongs in its own change.

Verified locally: npm ci 0, npx stylelint 0 (0 errors, 184 warnings),
npm test 0 (35 suites, 304 tests), npm run build 0, npm run lint 0,
npm run format 0.
…de existed (#3083)

#3080 made `FlowService::delete()` cascade to `openregister_flow_versions`, so
no NEW orphan can appear. It did nothing about the rows already stranded: any
instance that deleted a flow between the version table landing (#3047) and that
cascade landing kept them, unreachable through any read path because every
version read is keyed by flow.

Measured on the dev instance: 38, and the count grows with every flow anyone
removes.

The existing `BackfillFlowVersions` repair step already runs on install and
upgrade, so the sweep goes there rather than into a new step nobody would think
to invoke. Safe on the same terms as the cascade: `delete()` removes a flow's
RUNS too, so a version row whose flow is gone has no run left that could be
pinned to it.

Verified on the instance rather than reasoned about:

  before: 38 orphans
  occ maintenance:repair
    - Flow versions: removed 38 orphaned version row(s).
  after:  0 orphans · 196 flows · 196 version rows · 0 unversioned non-drafts

Dropping the `notIn` predicate — which would empty the table and strand every
in-flight run on the instance — turns the new test red.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Release: merge development into beta
The 1.1.10-beta.20260829221810 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.
Supersedes #2993, which could not be updated in place (dependabot branch).

What changed beyond the version bump:

- `patches.lock.json` is new in v2 and is committed alongside composer.lock.
  It pins each patch by sha256, so a silently-edited patch file is now a lock
  mismatch rather than a quiet re-apply.

- `extra.composer-exit-on-patch-failure` is removed. It is v1-only config;
  v2 has no such setting because it always throws. Verified rather than
  assumed: with a deliberately broken hunk, `composer install` exits 1 from
  Patcher/Patches.php:288 ("No available patcher was able to apply patch").
  Behaviour is unchanged — the flag was already what v2 does unconditionally.

The three existing patches all apply cleanly under v2. This matters: v2 drops
the GNU `patch` backend and applies via `git apply`, which honours offsets but
NOT fuzz. A patch that had been applying with fuzz under v1 would have started
failing here. None did, so the current patch set is exact against its pins.

Note for anyone building outside CI: v2 needs `git` on PATH (GitPatcher /
GitInitPatcher); v1 only needed `patch`. GitInitPatcher creates a temporary
`.git` inside the package dir and removes it again — verified 0 leftover .git
directories under vendor/ after a clean install.

Local verification (real exit codes, base development @8a5eef382):
- composer install from an empty vendor/: exit 0, all 3 patches applied and
  their content verified present in vendor/
- composer install --no-dev --dry-run: exit 0
- lint / phpcs / phpmd / psalm / phpstan: all pass
- phpunit: 17774 tests, 0 failures, 0 errors

check:strict exits 1 both here and on clean development, for the same
local-only reason: no coverage driver is installed, so PHPUnit reports
"OK, but there were issues!". Identical warning/risky profile to the
baseline (1 runner warning, 10 warnings, 43 skipped, 3 risky).
* chore(deps): bump theodo-group/llphant from 0.9.12 to 1.0.1

Supersedes #2986, which could not be updated in place (dependabot branch).

Root cause of #2986's 14 red checks: exactly one of them was real, and it
killed the other thirteen. `patches/llphant-ollama-usage-capture.patch` does
not apply to 1.0.1, and with `composer-exit-on-patch-failure: true` that
aborts `composer install` — so vendor/ never existed and every PHP job (lint,
phpcs, phpmd, psalm, phpstan, phpmetrics, phpunit, license, security) died on
a missing autoloader rather than on anything it was meant to measure.

Both patches are re-anchored against 1.0.1:

- think/keep_alive: context unchanged, hunk moved 257 -> 269.
- usage-capture: hunk 1 failed because the constructor signature changed
  (`?LoggerInterface $logger = null` -> `private readonly LoggerInterface
  $logger = new NullLogger()`). Hunk 2 moved into the new
  `generateChatRecursive()` and gained a `$this->logger->debug($contents)`
  line — under GNU patch it was applying with **fuzz 1**, which is not a pass.
  The new hunk is exact. It is anchored on the `/** @var Message[] $toolsOutput */`
  line below it, because 1.0.1 has two identical `decodeJson` sites and only
  the recursive one should accumulate usage.

psalm-baseline.xml loses one entry rather than gaining any: `OllamaConfig`
now really declares `$apiKey`, so the baselined UndefinedPropertyFetch in
ConversationManagementHandler is stale and psalm fails on UnusedBaselineEntry
until it is removed. The upgrade retires a suppression.

Nothing else in the app needed changing — with the patch deliberately left
off, psalm and phpstan each reported exactly one error, both of them the
missing $lastUsage property. The renamed ChatInterface methods
(generateTextOrReturnFunctionCalled -> ...ToCall) are not called anywhere here.

Transitive: openai-php/client v0.13.0 -> v0.19.2, guzzle 7.15.2 -> 7.15.5,
new yethee/tiktoken 0.10.0. All MIT; `composer audit --locked` exit 0.
Nothing in lib/ or tests/ touches the OpenAI SDK directly.

Local verification (real exit codes, base development @0131ef736):
- composer install from an empty vendor/: exit 0, all 3 patches applied and
  their content verified present in vendor/
- lint / phpcs / phpmd / phpstan: pass
- psalm: exit 0, "No errors found!"
- phpunit: 17775 tests, 0 failures, 0 errors

check:strict exits 1 both here and on clean development for the same
local-only reason: no coverage driver, so PHPUnit ends on "OK, but there were
issues!". Identical warning/risky profile to the baseline.

* chore(deps): relock the patch hashes after the development merge

composer-patches v2 (#3086) records a sha256 per patch in patches.lock.json.
This branch re-anchored both llphant patches for 1.0.1, so their content
changed — and merging development brought in the lock carrying the OLD hashes.
CI then failed with 'Hash mismatch for patch downloaded from
patches/llphant-ollama-think-keepalive.patch', which reads like a corrupt
download and is really a stale lock.

Regenerated with the plugin's own 'composer patches-relock' rather than by
editing the hashes by hand: the file also carries a top-level _hash whose
derivation is the plugin's business, not something to reproduce from the
outside. Diff is three lines — the two patch hashes and that _hash — and a
recomputation check now finds zero stale entries.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…tionContext (#3095)

Nextcloud added IRegistrationContext::registerGlobalScaleService (@SInCE
34.0.3) and backported it onto stable33. Our test helper is a concrete
implementation of that interface, so the new method turned it into an
implicitly-abstract class and PHPUnit aborted with a fatal error before
running a single test:

  PHP Fatal error: Class ...\RecordingRegistrationContext contains 1
  abstract method and must therefore be declared abstract or implement
  the remaining methods (IRegistrationContext::registerGlobalScaleService)

That is why development went red with no change of ours: the PR that
merged was green, and the stable33 branch tip moved underneath it. Both
failing cells were NC stable33, exit code 255 on "Run PHPUnit tests".

Adds the no-op recorder method, matching the other 37. Verified the
helper now covers the full interface surface on stable33 (38 methods),
stable34 (38) and master (39), so no further drift is pending.
…at full parity (#2634)

* l10n: sort all frontend bundles alphabetically

Rewrites every l10n/*.js with keys in case-insensitive alphabetical order so
that subsequent translation diffs are small and reviewable. Previously the
bundles carried historical insertion order, which meant any tool that rewrote
a file produced a whole-file diff and buried the actual changes.

Sorting is case-insensitive with a code-unit tie-break, so it is deterministic
and does not depend on the Node/ICU localeCompare implementation.

Purely mechanical: values were taken from HEAD and verified unchanged.
Verified across all 37 files / 54113 keys: zero value changes, zero keyset
changes, all files valid JS, OC.L10N.register executes with the expected key
count and plural-forms string.

* l10n(nl): fix 10 broken and formal-address entries

Ten Dutch entries were still defective after the main Dutch pass:

Half-translated machine output, Dutch words in English word order:
  "Weet u zeker dat u wilt verwijderen the geselecteerd audittrails?"
  "No bestands have been extracted yet"
  "Kon niet update schema properties"
  "Select default organisatie"
  and two "Use filters to narrow down ..." strings left almost fully English

Formal address, which the rest of the bundle and Nextcloud core both avoid:
  "Dank u!" -> "Bedankt!", "uw" -> "je", "Weet u zeker" -> "Weet je zeker"

These slipped through because the previous check had no formal-address test at
all, and its English-marker list excluded so many words that collide with
Dutch that badly-mixed strings scored as clean.

Note the embeddings warning exists twice in the bundle, once with a literal
\n escape and once with a real newline; only the escaped copy was broken.

Verified: 0 keys lost, 0 added, exactly 10 values changed, 0 formal-address
entries remaining, 0 plural-arity errors.

* l10n(de): complete German frontend translation (1996/1996 keys)

Brings l10n/de.js from 950 to full coverage of every string the frontend
requests via t()/n(). No entry has a value equal to its key.

  harvested            63  existing Transifex output for the identical source
                           string, from sibling Conduction apps and the
                           Nextcloud server tree
  hand-translated     970  written for this app
  formal -> informal   98  see below
  placeholders        109  entries whose value equalled their key
  identity keys        65  removed, see below

Register: informal du/dein throughout. Nextcloud ships the formal German
variant as a separate de_DE locale, and openregister has no de_DE, so plain
de.js is the informal bundle. Verified against the server tree:
core/l10n/de.js is 58 informal vs 1 formal, settings 108 vs 4. 98 pre-existing
entries used "Sie"/"Ihr" and were rewritten ("Verwalten Sie Ihre Register" ->
"Verwalte deine Register").

Identity strings are ABSENT rather than stored as value===key. For source
strings that are genuinely the same word in German ("Code", "Status", "Port",
"Maximum", "Repository") or must not be translated at all ("sk-...",
"https://example.com/webhook") OC.L10N falls back to the English source and
renders identical text, but the entry is no longer indistinguishable from an
untranslated placeholder. Tracked in de-identity.json.

Terminology: the source embeds Dutch legal vocabulary, which is mapped to
German equivalents rather than passed through -- "Inzage (Art 15)" ->
"Auskunft (Art. 15)", "Art 17 vergetelheid" -> "Recht auf Vergessenwerden",
"Art 20 portabiliteit" -> "Datenübertragbarkeit", "Bewaartermijn" ->
"Aufbewahrungsfrist", "verwerkingsactiviteit" -> "Verarbeitungstätigkeit",
"verantwoordingsdocument" -> "Rechenschaftsdokument", GDPR/AVG -> DSGVO.

Three harvested values were rejected as wrong for this app's context:
"Open" -> "Öffnen" (a button, not the adjective "Offen"), "Right" -> "Recht"
(an RBAC permission, not the direction "Rechts"), "Subject" ->
"Betroffene Person" (a GDPR data subject, not an email "Betreff").

Known source-side limitation: the "object{plural}" family interpolates a
literal "s"/"" for pluralisation, which cannot work in German. Those render as
"Objekt(e)"; "schema{plural}" keeps the placeholder because German does
pluralise Schema with -s.

Verified: 1996/1996 frontend keys translated, 0 absent, 0 value===key anywhere
in the bundle, 0 plural-arity errors, 0 unreviewed formal address, all 5 plural
keys carry 2-form arrays, file is valid JS and OC.L10N.register executes with
2324 keys. No pre-existing translation was lost: the only 33 baseline keys
removed were deliberate identity strings that had been value===key.

* l10n(fr): complete French frontend translation (1996/1996 keys)

Brings l10n/fr.js from 951 to full coverage of every string the frontend
requests via t()/n(). No entry has a value equal to its key.

  harvested            61  existing Transifex output for the identical source
                           string, from sibling apps and the Nextcloud server tree
  hand-translated     920  written for this app
  placeholders         27  entries whose value equalled their key
  identity keys        86  removed, see below

Register: FORMAL (vous/votre), which is what Nextcloud core actually uses for
French. This differs from the informal de/nl bundles and was measured, not
assumed: across server/{core,lib,apps/*}/l10n, fr is 39 informal vs 412 formal
(core alone 76 vs 9, settings 171 vs 16), and core ships no separate formal
French variant. The rule is "match Nextcloud core", and for French that means
vous.

Identity strings are ABSENT rather than stored as value===key, so the runtime
falls back to the English source and renders identical text without the entry
being indistinguishable from an untranslated placeholder. French shares a great
deal of vocabulary with English here ("Action", "Configuration", "Description",
"Format", "Total", "Type", "Version", "Notifications", "Expiration", "Notes",
"Score", "Public"), so the identity list is larger than German's. Tracked in
fr-identity.json.

Typography follows French convention: a space before ':' '?' '!' and guillemets
« » for quoted UI labels, as Nextcloud French does.

Terminology: Dutch legal vocabulary in the source is mapped to French GDPR
terms -- "Inzage (Art 15)" -> "Accès (art. 15)", "Art 17 vergetelheid" ->
"droit à l'oubli", "Art 20 portabiliteit" -> "portabilité", "Bewaartermijn" ->
"Durée de conservation", "verwerkingsactiviteit" -> "activité de traitement",
"verantwoordingsdocument" -> "document de responsabilité", AVG/GDPR -> RGPD.

Five harvested values were rejected as wrong for this app's context:
"Open" -> "Ouvrir" (button, not the adjective "Ouvert"), "View" -> "Afficher"
(action verb, not the noun "Affichage"), "Right" -> "Droit" (RBAC permission,
not the direction "Droite"), "Subject" -> "Personne concernée" (GDPR data
subject, not email "Objet"), "Link" -> "Associer" (dialog button verb, not the
noun "Lien").

Note French plural-forms is "nplurals=2; plural=(n > 1)", unlike German's
"(n != 1)"; all 5 plural keys carry correctly ordered 2-form arrays.

Verified: 1996/1996 frontend keys translated, 0 absent, 0 value===key anywhere
in the bundle, 0 plural-arity errors, 0 unreviewed wrong-register entries, file
is valid JS and OC.L10N.register executes with 2315 keys. No pre-existing
translation was lost: all 49 baseline keys removed were deliberate identity
strings that had been value===key.

* l10n: drop 875 dead placeholder entries from 32 locale bundles

Removes entries that are BOTH unreachable and untranslated:
  * no t()/n() call requests the key — neither a literal call nor any of the
    enumerated dynamic ones (see below), and
  * the value equals the key, i.e. it was never translated.

This cannot change what any user sees. An entry whose value equals its key
already renders the English source string; once removed, OC.L10N falls back to
the English source and renders the same string. Verified mechanically across all
37 files: 904 keys removed, 0 of them holding anything other than value===key,
and 0 existing values altered.

Unreachable keys that DO carry a real translation were deliberately left alone.
They are harmless, and deleting them would risk discarding real translation work
if the reachability analysis were ever incomplete.

Most of these are residue from two known events. Commit 03cda6c27 ("fix(i18n):
unwrap numeric/URL placeholders from t() per PR #1273 review") correctly stopped
wrapping numeric and infrastructure-URL placeholders in t(), but never removed
the keys it orphaned -- hence "3", "30", "http://localhost:11434" and
"https://api.fireworks.ai/inference/v1" in every bundle. Separately, the SOLR /
Zookeeper settings UI was removed from src/ without cleaning its strings, so
keys like "Zookeeper Hosts" and "SOLR Connection Settings" survive with no call
site. Confirmed absent from src/ before removal.

en.js is excluded: it is the English source bundle, where value===key is correct
by definition rather than a placeholder.

All 37 files remain valid JS.

* l10n(nl,de,fr): translate 15 dynamically-keyed strings that static extraction missed

Some strings reach t() as a variable rather than a literal:

  t('openregister', action)        PermissionMatrix.vue:41, over
                                   actions: ['read','create','update','delete','manage']
  t('openregister', step.status)   ApprovalStepList.vue:17, over the approval
                                   statuses used by lib/Controller/ApprovalController.php
  t('openregister', preset.label)  DashboardIndex.vue:91/120/360, over the date
                                   presets declared at :212-216

None of these keys can be found by scanning for literal t() arguments, so all 15
were absent from every bundle. The Permission Matrix column headers, the
approval-status badges and the dashboard date-range presets were therefore
rendering in English even in locales reported as fully translated. The key list
now lives in dynamic-keys.json with its provenance, and feeds the same
absent/placeholder/register checks as every other key.

Two dynamic sites remain un-enumerable and are documented as such: ApprovalStepList
step.role (schema-configured, arbitrary) and MainMenu.vue:76 translate(key) (app
manifest labels). A third, RegisterSchemaCard.vue:714, wraps a runtime-built
template string in t() and so can never match a catalogue key — that is a source
bug rather than a missing translation.

Also in this commit, for nl only:
  * "Driver" -> "Stuurprogramma", "Url" -> "URL", "object{plural}" -> "object(en)",
    "log{plural}" -> "logboek(en)", and both real-newline variants of the
    PERMANENT DELETION WARNING, which had only been done for the \n-escaped copies
  * 29 identity strings ("Code", "Status", "Type", "Dashboard", "sk-...") converted
    from value===key to absent, matching the treatment already applied to de and fr
  * nl-identity.json reconstructed (72 entries) so the check is reproducible

Verified: nl, de and fr each report 2011/2011 keys translated with 0 absent,
0 value===key, 0 wrong-register and 0 plural-arity errors.

* l10n(es): complete Spanish frontend translation (2011/2011 keys)

Brings es.js from 972 to 2011 reachable keys: 983 new translations,
16 placeholder entries replaced with real Spanish, and 17 legitimate
identity strings dropped so they fall back to the English source
instead of masquerading as translations.

Also fixes 106 PRE-EXISTING entries that the earlier register check
could not see. Spanish is pronoun-dropping, so formality lives in the
verb, not in a pronoun: "Seleccione un registro" is formal address with
no "usted" anywhere, and a check for the pronoun alone reported the
bundle clean. Widening the check to the usted imperative (3rd-sg
subjunctive: -ar -> -e, -er/-ir -> -a) plus the possessive su/sus
surfaced 107 formal entries, 105 of which were converted to the tú
forms Nextcloud core uses for Spanish. The remaining one was a
terminology split ("Rastro de auditoría" against nine occurrences of
"registro de auditoría").

The verb list was harvested from the sentence-initial and
post-punctuation words actually present in es.js rather than guessed —
"Gestione" and "Habilite" were both missing from the guessed list.

su/sus is both the formal "your" and the third-person "his/her/its/
their", so it is gated on the English source containing "your";
where the source says "its"/"their", su/sus is simply correct. Ten
positive/negative controls cover the gate. Only five strings that say
both "your" and "their" still need suppressing by hand.

Harvest sources are now ranked core-first. Previously the walk order
let sibling Conduction apps shadow server/, so generic UI strings were
taken from apps whose own Spanish is not authoritative. Eleven of the
67 harvested values were still wrong for this app's context and were
rewritten: Open/View/Link are verb buttons here (Abrir/Ver/Vincular,
not Abierto/Vista/Enlace), Right is a permission (Derecho, not the
direction Derecha), Subject is the GDPR data subject (Interesado, not
the email Asunto), and Languages are human languages (Idiomas, not
Lenguajes).

Unlike German and Dutch, Spanish pluralises with -s exactly as English
does, so the object{plural}/register{plural} family interpolates
correctly here and is translated with the placeholder intact.

Verified: 2011/2011 reachable keys, 0 absent, 0 value===key, 0 hybrid,
0 wrong register, 0 plural-arity errors, valid JS, and OC.L10N.register
loads all 2355 entries. Backend l10n/es.json is untouched.

* l10n(it): complete Italian frontend translation (2011/2011 keys)

Brings it.js from 969 to 2011 reachable keys: 983 new translations, 17
placeholder entries replaced with real Italian, and 19 legitimate
identity strings dropped so they fall back to the English source
instead of masquerading as translations.

No pre-existing entry needed rewriting — unlike Spanish, the Italian
already in the bundle was consistently informal (Seleziona, Gestisci,
Crea). That is a verified result rather than an assumption: the register
check for Italian was as blind as the Spanish one, matching only
(Lei|Vi preghiamo), which finds almost nothing in a pronoun-dropping
language. It now covers the usted-equivalent imperative, which in
Italian is the MIRROR of Spanish: -are -> -i (selezioni), -ere/-ire -> -a
(scelga, inserisca). That collides head-on with the INFORMAL -ere/-ire
imperative, which also ends in -i (scegli, inserisci), so the ending
alone proves nothing and the verb list is explicit.

Filtri, Ordini, Usi, Controlli, Termini, Continui and Faccia are left
out on purpose: each is an ordinary Italian noun or adjective and would
bury real hits in noise. Infinitive-as-instruction ("Eliminare",
"Utilizzare") is standard register-neutral Italian UI and is not
flagged. 16 positive/negative controls cover the pattern, including one
that caught a genuine inversion in my first draft: "Premi" is the
INFORMAL imperative of premere and had been listed as formal.

Six of the 70 harvested values were wrong for this app's context.
Subject was the worst: pipelinq's "Oggetto" is the email subject AND
this bundle's own word for Object, so a GDPR data subject column would
have read "Object" — it is "Interessato". Open/Link are verb buttons
here (Apri/Collega, not Aperto/Collegamento), Right is a permission
(Diritto, not the direction Destra), Other labels a group (Altri), and
Mappings is properly "Mappature". "Test" was also reclassified: it is a
webhook action button, so Italian wants the verb "Prova", not the noun
loanword.

Italian pluralises by vowel change, not with -s, so the
object{plural}/register{plural} family CANNOT use the literal "s" the
source interpolates — it would render "oggettos". Those are written with
an explicit both-forms notation (oggetto/i, registro/i, schema/i), and
file{plural}/log{plural} simply drop the placeholder because both nouns
are invariant in Italian.

"{count} email" is the one entry deliberately written as value===key:
"email" is invariant, so both plural forms equal the English source, and
leaving the key ABSENT is not equivalent — OC.L10N would fall back to
the English plural rule and render "{count} emails". apply.js gained a
narrow --allow-identity opt-in for exactly this case; --force still does
not lift the value===key ban.

Verified: 2011/2011 reachable keys, 0 absent, 0 hybrid, 0 wrong
register, 0 plural-arity errors, valid JS, OC.L10N.register loads all
2353 entries, and the only value===key entry is the documented
invariant plural. No pre-existing translation was altered. Backend
l10n/it.json is untouched.

* l10n(pt): complete Portuguese frontend translation (2011/2011 keys)

Brings pt.js from 973 to 2011 reachable keys: 984 new translations, 17
placeholder entries replaced with real Portuguese, and 15 legitimate
identity strings dropped so they fall back to the English source
instead of masquerading as translations.

PORTUGUESE INVERTS THE SPANISH RULE, and getting that backwards would
have wrecked the whole locale. In Spanish, "Seleccione" / "su" is
deferential usted and had to be replaced with tú forms. In Portuguese
the same 3rd-person morphology ("Selecione" / "seu") is the NEUTRAL você
register that Portuguese software UI uses, and the 2nd-person tu forms
are the ones that read wrong. Measured rather than assumed, across
server/{core,lib,apps/*}:

  pt_BR   tu 0 : você 438
  pt_PT   tu 4 : você 128

Both variants converge on você, so the single generic "pt" bundle this
app ships is correct for both. The register check for pt was therefore
written to flag the TU forms, with a comment saying so, because the
obvious next move for anyone reading the Spanish entry would be to
"fix" it by analogy and break 2000 strings. 10 positive/negative
controls pin the direction down.

The bundle is EUROPEAN Portuguese and the new strings follow it:
ficheiro, registo, eliminar, guardar, utilizador, aplicação,
definições. That was measured too (306 pt_PT-style terms against 11
apparent pt_BR ones, and all 11 turned out to be correct anyway —
"padrão" translates Pattern, not "default", and "configurações"
renders configurations as distinct from settings/"definições").
Existing style is also preserved: infinitive for controls (Selecionar,
Criar, Eliminar) and você imperative for prose instructions (Selecione,
Configure, Introduza).

Only one pre-existing entry was changed: "Trilho de auditoria" against
nine occurrences of "registo de auditoria".

Three of the 31 harvested values were wrong for this app's context —
Right is a permission (Direito, not the direction Direita), Link is a
confirm button (Associar, not the noun Ligação), and Edit Endpoint kept
the loanword to match the bundle's existing "Adicionar Endpoint" rather
than openconnector's "ponto final". None of the harvest was
authoritative here: core ships pt_BR and pt_PT but no bare pt, so every
candidate came from a sibling Conduction app and each was reviewed.

Like Spanish and unlike Italian, Portuguese pluralises with -s, so the
object{plural}/register{plural} family works with the literal "s" the
source interpolates and is translated with the placeholder intact.

Verified: 2011/2011 reachable keys, 0 absent, 0 value===key, 0 hybrid,
0 wrong register, 0 plural-arity errors, valid JS, and OC.L10N.register
loads all 2357 entries. Backend l10n/pt.json is untouched.

* l10n(sv): complete Swedish frontend translation (2011/2011 keys)

Brings sv.js from 958 to 2011 reachable keys: 981 new translations, 15
placeholder entries replaced with real Swedish, and 23 legitimate
identity strings dropped so they fall back to the English source
instead of masquerading as translations. No pre-existing translation
was altered.

Swedish shares far more of this app's vocabulary outright than the
Romance locales do, which cut both ways. The identity list is nearly
twice as long (register, schema, status, organisation, person, port,
version, format, maximum, minimum are all Swedish words with the same
spelling) and the hybrid detector was firing on nine perfectly good
Swedish strings purely because they contained "register" and "schema".
Those words are now declared as collisions for sv, the same treatment
de and nl already needed, so a hybrid hit means something again.

Register/Schema are capitalised mid-sentence throughout, which is not
standard Swedish orthography but IS this bundle's established
convention for the domain entities — measured at 54 capitalised
against 2 lowercase before I added anything, so the new strings follow
it rather than splitting the file two ways. Terms with their own
precedent keep it: "slutpunkt" stays lowercase to match the existing
"Lägg till slutpunkt".

Swedish is informal by default (du) — 358 informal against 0 formal in
core — and needed no register conversion.

Four of the 45 harvested values were wrong for this app's context: Open
is a verb button here (Öppna, not the adjective Öppen that circles
supplies), Right is a permission (Rättighet, not the direction Höger),
Link is a confirm button (Länka, not the noun Länk), and Edit Endpoint
was recased to match the bundle. Core's "Webbadress" for URL was
deliberately NOT taken: this bundle uses "URL" consistently (Bas-URL,
Databas-URL, "namn eller URL"), so the standalone label stays identity.

Swedish pluralises by suffix change or not at all, never with -s, so
the {plural} family cannot use the literal "s" the source interpolates.
These are count-labels under a number, so: object{plural} and
register{plural} drop the placeholder entirely (both nouns are
invariant — "5 objekt", "5 register"), while file{plural},
log{plural} and schema{plural} use an explicit both-forms notation
(fil(er), logg(ar), schema(n)).

Verified: 2011/2011 reachable keys, 0 absent, 0 value===key, 0 hybrid,
0 wrong register, 0 plural-arity errors, valid JS, and OC.L10N.register
loads all 2347 entries with no benign suppressions needed at all.
Backend l10n/sv.json is untouched.

* l10n(da): complete Danish frontend translation (2011/2011 keys)

Every string the frontend reaches via t()/n() now has a real Danish
translation. 981 new entries, 15 English placeholders replaced, 23
identity strings dropped so the runtime falls back to the source.
No pre-existing translation was altered except one grammar fix (below).

Register: informal (du/din/dit/dine), verified against Nextcloud core.

The old detector for Danish was /(De|Deres)/ and reported 15 hits in
core. All 15 were false positives: lowercase de/dem/deres are the
ordinary words for they/them/their -- and "de" is also the definite
article -- so they capitalise at sentence start and become
indistinguishable from the formal pronouns. "De genererede billeder" is
"The generated images"; "Deres stier" is "Their paths". Core is
572 informal : 0 genuinely formal.

Rewrote the detector to require a MID-SENTENCE capital, excluding every
position where a capital is explained by orthography rather than
register (string start, after sentence punctuation, after newline or
bullet, after an opening quote). Danish opens quotes with the glyph
English uses to close them, so both directions count as sentence start.
Validated on 16 must-not-fire and 6 must-fire controls, then swept all
4487 Danish strings in core: 0 hits, down from 15. Applied to nb/nn too.

Domain-term capitalisation is the INVERSE of Swedish. Swedish measured
54 capitalised : 2 lowercase and so keeps Register/Schema capitalised
mid-sentence; Danish measures 1 : 15 and follows standard orthography,
so register/skema/organisation/objekt stay lowercase.

Bundle consistency over core, twice:
  - imperative of -ere verbs: bundle is Aktivér 13:0, core prefers
    Aktiver 21:7. Followed the bundle.
  - "endpoint": bundle 5:0, harvest offered core-adjacent "slutpunkt".
    Kept endpoint.
Where the bundle already had a mapping it wins outright: Host -> Vært,
so unlike Italian this bundle needed no identity entry for "Host *".

Harvest corrections (3 of 41 candidates were wrong in context):
  - "Right" is a permissions-table header (EditOrganisation.vue:288),
    not a direction -- Rettighed, not Højre.
  - "Assigned collaborative tags" arrived as "Tildelte samarbejds tags";
    Danish compounds are one word -> samarbejdstags.
  - "Edit Endpoint" -> Rediger endpoint, per the bundle term above.

Single-word keys were resolved at the call site, not from the harvest:
Subject is the GDPR data subject (AvgIndex.vue:384) -> Registreret, not
the email sense; Open/View/Reject/Merge/Reverse/Link are verb buttons;
Score/Step/Survivor read off their table headers.

The {plural} source bug (the caller interpolates a literal "s") cannot
work in Danish, which pluralises by suffixing -er and mutates the stem
of register. Resolved per word: objekt(er), fil(er), log(ge),
skema(er), and register/registre where the stem changes.

48 identity strings stay absent rather than being written as value===key
(acronyms, loanwords Danish shares, and literal input examples such as
HTTP headers and YAML snippets, which would stop being valid hints if
translated). Rationale for each is recorded per key.

Also fixes a pre-existing grammar error: "Objekter bløde slettes" ->
"Objekter blødslettes".

* l10n(nb): complete Norwegian Bokmål frontend translation (2011/2011 keys)

Every string the frontend reaches via t()/n() now has a real Norwegian
translation. 984 new entries, 15 English placeholders replaced, 20
identity strings dropped so the runtime falls back to the source. No
pre-existing translation was altered.

Register: informal (du/din/ditt/dine), measured against Nextcloud core
rather than assumed from Danish. The De/Dem/Deres detector corrected in
the previous commit carries over unchanged and pays off immediately:
the old /(De|Deres)/ found 10 hits in Norwegian core, all of them
sentence-initial "The/They"; the mid-sentence-capital version finds 0.
Core is 500 informal : 0 genuinely formal.

Norwegian agrees with Danish on orthography (register/skjema lowercase
mid-sentence, measured 1:15) but disagrees on almost everything else,
which is why each convention was re-measured instead of inherited:
  - imperative of -ere verbs: nb is "Aktiver" 13:0, exactly mirroring
    da's "Aktivér" 13:0. Same verb, opposite spelling.
  - error phrasing: nb bundle uses the ACTIVE "Kunne ikke <infinitive>",
    where the Danish bundle preferred a passive construction.
  - ellipsis: nb bundle puts a space before it, 49:0 ("Laster inn ...").
    Followed for every progress string in this commit.
  - cache -> "buffer" (bundle-established: Appbutikk-bufferen,
    navnebuffer), not the "hurtiglager" core sometimes uses.
  - schema -> "skjema", endpoint -> "endepunkt" (Danish kept "endpoint").

Harvest corrections (7 of 46 candidates were wrong in context, the
highest error rate of any locale so far):
  - "Right" is a permissions-table header, not a direction -> Rettighet.
  - "Revoke" arrived as core's "Avslå", which means REJECT. Revoking a
    token is "Tilbakekall".
  - "People" arrived as "Mennesker" (humans in the abstract); it labels
    the PERSON entity type in EntitiesTab.vue:102 -> Personer.
  - "Link" arrived as the noun "Lenke" but is a confirm button
    (LinkObjectDialog.vue:62) -> "Knytt til". Kept the lenke/tilknytning
    split so Link and Connection stay distinct concepts, since the app
    ships both as separate features.
  - "Dashboard not found" -> Instrumentpanel, matching core's own
    translation of the Dashboard app name.
  - "Unknown widget type" arrived as "modultype"; core's dashboard app
    leaves widget untranslated, and "modul" is already used in this
    bundle for application modules, so "widgettype" avoids the clash.
  - "Assigned collaborative tags" lacked plural agreement -> Tildelte.

Accepted core over instinct once: "Bucket" -> "Bøtte", because core's
files_external is exactly the S3 domain this field belongs to. The
Danish bundle kept "Bucket" only because Danish core offers no entry.
Likewise "Host *" -> "Server *", core's rendering for this same field.

The {plural} source bug needed the same per-word handling as Danish, but
with Norwegian's own forms: objekt(er), fil(er), logg(er), skjema(er),
and register/registre where the stem changes.

42 identity strings stay absent rather than being written as value===key.
"Min ms" is included with a note: "min" also means "my" in Norwegian, but
beside a millisecond unit the Minimum reading is unambiguous.

* l10n(pl): complete Polish frontend translation (2011/2011 keys)

Every string the frontend reaches via t()/n() now has a real Polish
translation. 987 new entries, 15 English placeholders replaced, 14
identity strings dropped so the runtime falls back to the source. No
pre-existing translation was altered.

FIRST nplurals=3 LOCALE. All six plural keys now carry three forms
matching the declared rule (n==1 / n%10 in 2-4 / rest), verified at
runtime: obiekt / obiekty / obiektów. Every locale before this was
nplurals=2, so this is the first bundle where a two-form array would
have silently mis-rendered the genitive plural.

Register: informal, measured against core (154 informal : 0 formal).
Core's imperative style is 2nd-person singular -- Wybierz, Zapisz,
Kliknij (334 hits) -- with impersonal "Nie można" for errors (172), and
the polite 3rd-person "Proszę wybrać" appears only 19 times. Followed
that: 2sg imperatives, impersonal error phrasing.

The register detector needed rebuilding for a homograph problem that is
worse here than in Scandinavian. Formal address is Pan/Pani/Państwo, but
lowercase "państwo" is the ordinary noun for STATE / COUNTRY -- a word
this app plausibly uses, since it manages government registers -- and it
capitalises at sentence start like any other noun. Państwo therefore
counts only MID sentence; Pan/Pani match anywhere, since as address they
stay capitalised. 15 controls (10 must-not-fire, 5 must-fire) pass, and
the detector reports 0 across all 5228 Polish strings in core.

Caught a FALSE FRIEND that the value===key rule would have hidden:
"Data" was initially filed as an identity string, but Polish "Data"
means DATE -- core itself translates "Date" -> "Data". Left untranslated,
the object's data tab would have read "Date" to a Polish user and
collided with core's own term. It is now "Dane". Audited the key across
all nine finished locales: it/pt/es correctly carry Dati/Dados/Datos,
and sv/da/nb correctly leave it as identity because their word for date
is dato/datum. Polish was the only locale affected.

Orthography follows Swedish, not Danish/Norwegian: the bundle
capitalises domain terms mid-sentence (Rejestr 35:2, Schemat 37:1,
Obiekt 79:3), so those stay capitalised through all case inflections
(Rejestru, Schemacie, Obiektów). But "organizacja" measures 0:18 and
stays lowercase -- a per-word exception, not a blanket rule.

Harvest corrections (5 of 47 candidates wrong in context, plus 1 dropped):
  - "Right" -> Uprawnienie; core's "Do prawej" means "to the right".
  - "View" -> Wyświetl; core's "Podgląd" is the NOUN preview, but this
    is a verb button (OrganisationsIndex.vue:90).
  - "Revoke" -> Unieważnij; core's "Cofnij" means UNDO, which is not
    what revoking a token does.
  - "Link" -> Powiąż; the harvest gave the noun "Łącze" for a confirm
    button (LinkObjectDialog.vue:62).
  - "Mappings" -> Mapowania, not the Polglish "Mappingi".
  - "Status" dropped to identity: identical in Polish and already used
    10x in the bundle; openconnector's "Stan" is reserved here for Health.

Accepted core where it is in-domain even when the literal reading is odd:
"Bucket" -> "Kosz" (files_external IS the S3 config UI), the same call
made for Norwegian's "Bøtte". Polish core renders Trash as "Usunięte
pliki", so there is no collision. "Host" stays untranslated per core.

The {plural} source bug degrades further here: with three plural forms a
parenthetical cannot cover the genitive, so Obiekt(y) / Rejestr(y) /
Schemat(y) / plik(i) / log(i) are a documented approximation. The real
fix is for the caller to use n() instead of interpolating a literal "s".

* l10n(cs): complete Czech frontend translation (2011/2011 keys)

Every string the frontend reaches via t()/n() now has a real Czech
translation. 985 new entries, 16 English placeholders replaced, 13
identity strings dropped so the runtime falls back to the source. No
pre-existing translation was altered.

Register: FORMAL -- the first formal-target locale since French, so the
detector polarity flips back. Measured against core: 177 vy/váš hits,
and all 6 apparent informal hits are the plural DEMONSTRATIVE "ty"
meaning "those" ("pouze ty stávající" = "only those existing"), not the
2sg pronoun. So core is 177 : 0 genuinely informal.

The old detector was /(Tvůj|Tvoje|Tvá)/ -- three possessive forms and
nothing else. That missed where Czech formality actually lives: the
IMPERATIVE ENDING. Formal is 2nd-person PLURAL in -te (Vyberte, Zadejte,
Spravujte); informal is the bare 2sg stem (Vyber, Zadej, Spravuj). So
"Vyber registr" is informal address with no pronoun present at all --
the same blind spot Spanish had, in a different language family.
Rebuilt with the full possessive paradigm plus a curated bare-imperative
list. 22 controls pass and the detector reports 0 across all 5005 Czech
strings in core.

Bare "ty" is deliberately NOT matched. Unlike Polish Państwo, where a
mid-sentence-capital rule separates the two readings, Czech informal "ty"
and demonstrative "ty" are identical in case and position, so no rule can
tell them apart. The possessives and imperatives are unambiguous, so
nothing is actually lost -- documented in the detector comment.

Second nplurals=3 locale, but a DIFFERENT rule from Polish: Czech splits
1 / 2-4 / 5+ where Polish keys on n%10. Verified at runtime that all six
plural keys carry three forms matching the declared expression
(objekt / objekty / objektů).

The bundle's own style, followed throughout: formal -te imperatives for
instructions, plus INFINITIVES for button labels (Zobrazit, Smazat,
Obnovit, Filtrovat), which are register-neutral in Czech and must not be
"corrected" to imperatives. Impersonal "Nepodařilo se" for failures and
"Při ... došlo k chybě" for errors, both already established here.

Orthography follows Danish/Norwegian, not Swedish/Polish: domain terms
stay lowercase mid-sentence (registr 0:35, schéma 0:41, objekt 1:79).

Bundle consistency over core once: the bundle uses "notifikace" (3:0)
where core prefers "oznámení". Kept the bundle's term.
Established terms adopted: ścieżka -> "auditní záznam" (audit trail),
"schránka" (clipboard), "mezipaměť" (cache), "měkce smazané" (soft
deleted), and core's "Hostitel" for Host and "Profilový obrázek" for
Avatar.

Harvest corrections (2 of 45 candidates wrong in context):
  - "Right" -> Právo; core's "Vpravo" means "to the right".
  - "Link" -> Propojit; the harvest gave the noun "Odkaz" for a confirm
    button (LinkObjectDialog.vue:62).
Also translated "Test" rather than treating it as identity: Czech buttons
take infinitives here, so the webhook action reads "Testovat", matching
"Testovat připojení" elsewhere in the bundle.

"Data" stays identity, and this was checked rather than assumed after the
Polish false friend: Czech "data" IS the word for data, because Czech
uses "datum" for date. The trap is Polish-specific.

* l10n(ru): complete Russian frontend translation (2011/2011 keys)

Fills every string the frontend reaches through t()/n() with a real
Russian translation. 2011/2011 keys, 0 absent, 0 placeholders,
0 register violations, 0 plural-arity errors.

Register: formal, measured rather than assumed. Nextcloud core ru
carries 328 formal pronouns and 164 formal 2pl imperatives against
ZERO of either informal marker across 3905 strings -- the least
ambiguous reading of any locale so far.

Rebuilt the register detector, which previously covered only five
nominative possessives. Russian informal address hides in three
places a pronoun check misses:
  * the oblique cases (тебя/тебе/тобой and declined твой), which is
    most of what running prose actually uses;
  * the imperative ending -- formal is 2pl -ите/-йте (Выберите),
    informal the bare 2sg (Выбери), so "Выбери реестр" is informal
    with no pronoun present at all. Same blind spot Czech had;
  * the 2sg present -ешь/-ишь (хочешь, увидишь). Feminine soft-sign
    nouns end in -чь/-щь/-ышь/-ушь (ночь, помощь, мышь, тушь), never
    -ишь/-ешь, so the ending is unambiguous.
вы/вам/ваш are deliberately NOT matched: lowercase вы is the ordinary
polite address here, not a plural-only form, so it is evidence of
nothing. 32/32 controls pass, 0 hits on core.

First non-Latin-script locale, so the --latin script-coverage check
replaces the --hybrids check. It started at 24 hits; 16 were genuinely
untranslated English (the entire browser/VAPID web-push block, plus
Slug) and are now translated. The 11 that remain are reviewed-benign:
every word of prose is translated and the Latin run is a literal --
a file path, an API field name (conversationId, fileCollection), or a
product name (Zookeeper). Byte-majority cannot tell those apart from
an untranslated string.

Harvest review caught four core/sibling values that were wrong in
sense for this app's context and would have passed every automated
check:
  * Right -> "По правому краю" (right-ALIGNED) where the key is a
    permissions-table header. Now "Право".
  * View -> "Режим просмотра" (view MODE) where the key is an action
    button. Now "Просмотр".
  * Open -> "Открытый" (the adjective, harvested from circles) where
    the key is an action button. Now "Открыть".
  * Search -> "Найти" (the verb) where the key is a tab/field label,
    and the bundle already reads "Поиск / Представления". Now "Поиск".
Also corrected Link (noun -> "Привязать", it is a confirm button),
People ("Люди" -> "Персоны", it labels the PERSON entity type),
Mappings (dropped a sibling app's "(Mapping)" gloss), and the two
Dashboard strings to the bundle's own "Дашборд" rather than core's
"Панель управления" -- bundle-internal consistency outranks core.

Bucket keeps core's literal "Корзина" from files_external even though
Russian cloud docs prefer "бакет", applying the same in-domain-core-
wins rule used for Polish "Kosz" and Norwegian "Bøtte". The bundle has
no other Корзина string, so nothing collides.

One pre-existing mistranslation fixed: Test was "Тест" (the noun) on
what is a webhook test BUTTON; now "Проверить".

nplurals=3 with a third distinct rule -- Russian keys form 0 on
n%10==1 && n%100!=11, so the arrays were built against the ru
expression rather than copied from Polish or Czech. All 6 plural keys
carry 3 forms.

22 keys are left ABSENT as identity strings (ID, Id, ID:, URL, Url,
UUID:, CSV, PDF, RBAC, DSAR, Deck, Excel/OpenDocument format names,
API-key prefix hints, literal header/YAML examples). OC.L10N falls
back to the English source, which renders the same correct text --
writing them as value===key would be indistinguishable from an
untranslated placeholder and would never get revisited. Slug was NOT
treated this way: a lone Latin word in a Cyrillic UI reads as
untranslated, so it is "Слаг".

The five object{plural}-style keys remain parenthetical approximations
("объект(ы)"). Three-form agreement means a parenthetical cannot cover
the genitive; the real fix is for the caller to use n() instead of
interpolating a literal "s".

l10n/ru.json (backend catalogue) untouched.

* l10n(nl,cs): fix two mistranslations found during the Russian review

Both were caught by cross-locale probes while resolving the same keys
for Russian, and both survive every automated check because the values
are real words that differ from their keys.

nl: Right was "Rechts", which is the DIRECTION "right". The key is a
column header in the organisation permissions table
(src/modals/organisation/EditOrganisation.vue:288), so it means a
permission. Now "Recht". Every other locale already had the noun
(Recht / Droit / Derecho / Diritto / Direito / Rättighet / Rettighed /
Rettighet / Uprawnienie / Právo / Право).

cs: Uses and Used by were BOTH "Používá". Those are two separate tabs
on the object view (outbound vs inbound relations, ViewObject.vue:254
and :290), so the pair rendered identically and the user could not
tell which direction a tab showed. Used by is now "Používáno v".
No other locale collides on this pair.

* l10n: add frontend l10n tooling (CRUD, audit, unwrapped-string detector)

Brings the l10n/*.js toolchain into the repo: a shared library plus four
CLIs — l10n-ai.js (key CRUD), check-l10n.js (audit en.js against src/),
clean-l10n.js (remove unreferenced keys) and find-unwrapped.js (find
prose that was never wrapped in t()).

These existed untracked, and enter the repo with four defects fixed. All
four were the kind that stay invisible until they cost you data or a
review cycle.

n() was invisible to the usage scanner. collectUsedKeys and
findKeyReferences matched only `\bt\s*\(`, so every plural key came back
unreferenced despite live call sites. That armed clean-l10n.js: it
deletes en.js-minus-used from ALL 37 locale files, so adding the plural
source keys to en.js — which is correct and expected — would have made
the next --apply erase them everywhere, including populated plural
arrays. Demonstrated against a fixture before fixing. The three scripts
had three separate copies of the extractor; they now share one that
handles t(), n() (BOTH key arguments) and the $t/$n template variants,
while rejecting identifiers that merely end in t or n (format(, fn(,
min(). As a direct consequence `rm` now correctly refuses to delete a
key referenced only from an n() call.

serializeJs reformatted every file it touched. It emitted tabs,
"key": "value", a trailing comma and `)`, where the shipped files use
four spaces, `"key" : "value"`, no trailing comma and `);` — so a
one-key edit produced a ~4400-line diff. The key order was wrong too:
localeCompare matches ZERO of the 37 files, case-insensitive code-unit
order matches 36, and localeCompare varies by Node/ICU version, which
made the sort order depend on who ran the tool. Round-trip is now
byte-identical for 36/37 files; en.js is the lone outlier, still in the
original extraction order, and will re-sort once on first write.

The eslint pass was destroying the format it was meant to normalise.
Both writers ran `eslint --fix` on the locale files, and l10n/ is not
ignored — l10n/cs.js alone reports 9760 fixable "errors". The fix
rewrites the file to tabs and SINGLE quotes, undoing the serializer
immediately and diverging from what Transifex regenerates. Locale files
are generated data, not source code, so runEslintFix is gone.

find-unwrapped.js hung forever, which is why it could never be wired
up. A bare '<' in template text ("5 < 10") is rejected as a tag open and
falls through to the text branch, whose loop stops immediately because
it is already sitting on '<' — the region is empty, the index never
advances. Now completes over the full tree in ~0.1s. Two further fixes
there: the app id no longer defaults to the hardcoded literal
'opencatalogi' (a different app — a wrong app id makes every wrapped
string look unwrapped), and looksLikeProse no longer discards
"Creating..." / "Loading..." / "Saving...", which its dotted-identifier
filter matched via the trailing run of dots. That last one was
suppressing the single most commonly unwrapped class of label.

Verified: add/set/rm/remove round-trip byte-identically and produce
one-line diffs, `set` still refuses plural arrays, `rm` still blocks on
live references, and all five files are lint-clean.

* l10n: gate English-identical values and plural arity, wire tooling to npm

Extends the parity gate with the two checks that would have caught this
translation effort's real failures, wires the tooling to npm, and
replaces the l10n guidance.

The parity gate's central assumption was backwards. Its comment read
"Values identical to English are allowed (cognates / proper nouns /
acronyms are legitimately the same) and only counted." But absent and
identical are OPPOSITES, not degrees of the same problem:

  absent    -> OC.L10N falls back to the English source, so the UI
               renders correct text AND the gap stays visible to tooling,
               keeping the key on the work list.
  identical -> renders the same characters but is indistinguishable from
               finished work, to tooling and to the next maintainer, so
               it is never revisited. A permanent invisible hole.

Identical is therefore the worse of the two and is the one worth gating.
This is exactly how ru shipped 24 untranslated English strings behind an
otherwise clean report. Cognates now belong ABSENT rather than written
out; --allow-identical restores the old tolerance for a bulk migration.

Also added: plural arity against each locale's OWN declared nplurals.
That is the one l10n defect invisible to reading the file — OC.L10N
indexes the array with the plural expression's result, so a short array
renders blank for some counts. Note arity alone is not sufficient
protection: ru, pl and cs all declare nplurals=3 with three mutually
incompatible expressions, so a Polish array pasted into Czech has the
right length and the wrong boundaries.

npm wiring: test:l10n:parity, check:l10n, clean:l10n, find:unwrapped.
The last three were documented but had never existed as npm scripts, so
every command in the old guidance failed.

CLAUDE.md was an unedited copy from opencatalogi: the wrong app id
throughout (a wrong id fails lookup silently and renders untranslated
text with a green pipeline), three npm scripts that did not exist, and
it forbade touching l10n/*.json — the file the sanctioned extractor
actually writes. Rewritten with the verified commands, the two
translation sets described as the independent catalogues they are, and
the rules established across 12 completed locales.

One obsolete rule deliberately reversed: the old text said never to
narrow `add --locales` and never to defer a locale. Written for a
two-locale app, that now demands 37 hand-written values per string and
invites precisely the placeholder filler the value===key rule forbids.
`en` is required; the rest are optional and better left absent.

The per-language method — measuring formality register against Nextcloud
core instead of assuming it, why harvested values must be checked at the
call site, plural incompatibility, and the established per-locale
conventions — moved to docs/l10n-ui-translation.md so it is read on
demand rather than billed on every call.

* fix(l10n): decode \uXXXX and \xXX escapes when extracting keys

readStringLiteral handled only \n, \t and \r; every other escape fell
through to `else value += n`, which drops the backslash and keeps the
letter. So the source literal

    t('openregister', '⚠️ PERMANENT DELETION WARNING ...')

extracted as the key "u26A0uFE0F PERMANENT DELETION WARNING ...", while
at runtime JS produces "⚠️ PERMANENT DELETION WARNING ...". The two
never match, so any translation stored under the extracted key is dead
on arrival, and check-l10n reports the real key as missing forever.

The 12 finished locales happen to hold the correct emoji key, so nothing
shipped broken -- but the tooling could not see that, and reported those
two keys as untranslated in every locale.

Decode \uXXXX, \u{XXXXX} and \xXX, and add the remaining single-letter
escapes (\b, \f, \v, \0) so the extracted key is byte-identical to the
runtime key. Verified against seven literal forms, including the ⚠️/•
case and a surrogate-pair \u{1F600}.

* l10n: translate the Ukrainian frontend catalogue (uk)

1985 of 2011 frontend keys now carry a real Ukrainian translation, up
from 1005. The remaining 26 are deliberate: acronyms and formats
identical in Ukrainian (ID, URL, UUID:, CSV, PDF, RBAC, IBANs), proper
names (Deck, Excel (.xlsx), OpenDocument (.ods)), literal placeholder
examples (sk-..., org-..., fw_..., myapp, example URLs, header/YAML
samples) and one pure-placeholder format string. They are left absent
rather than written out, so the runtime falls back to English and the
keys stay visibly untranslated.

Register: formal, measured against Nextcloud core rather than assumed.
Across core/lib/apps uk.json, 632 distinct strings carry formal markers
(ви/ваш, 2pl imperatives in -іть/-те) against 2 informal, and both of
those are deliberately casual content (a user-status prompt and a sample
calendar event) rather than UI chrome. Detector validated on 22
must-fire / must-not-fire controls; it finds 0 informal markers in the
finished uk.js. Matching core, ви/ваш is used for address and possession
and actions are infinitives (Додати, Зберегти, Переглянути).

Harvested 24 values from core/lib and bundled apps, ranked core first.
Three were correct translations of the wrong sense and were rewritten
after checking the call site:

  People  PERSON entity-type label, not "Users"  -> Люди
  View    row action button, not display mode    -> Переглянути
  Bucket  histogram score range, not basket/bin  -> Діапазон

Right (permission column, not text alignment), Subject (GDPR data
subject, not mail subject) and Open (NcButton verb, not adjective) were
checked against the same trap list and translated in their real sense.

Plurals use Ukrainian nplurals=3 (1/21 | 2-4 | 5-20,0); all 6 plural
keys carry 3 forms. The object{plural} family follows the parenthetical
convention already used by ru and cs -- об'єкт(и) -- because the source
interpolates a literal "s" instead of calling n().

Verified: 0 value===key, 0 bad plural arity, 0 informal markers, no
Latin-only values, loads under OC.L10N.register with the correct
plural-forms header, and diffing against the previous file shows 988
keys added, 8 value===key cognates removed, 15 placeholders replaced,
and 0 existing real translations altered.

* fix(l10n): stop reporting n() plural sources as missing keys

extractTCalls pushed BOTH arguments of an n() call into the used-key set,
so every plural source string was compared against en.js as if it were a
catalogue key of its own. It never is: an n() call has two source strings
but one key -- the singular -- and the plural lives in that key's value
array. The six plural sources were therefore permanently reported as
missing, and no amount of correct translation could clear them.

Track plural source -> singular key while extracting, and treat a plural
source as satisfied when its singular key holds an array. check:l10n now
reports 0 missing instead of 6 unfixable ones.

* fix(i18n): wrap unwrapped UI prose in ViewObject and MassValidateModal

Six strings in two ternaries rendered English for every user despite five
of them already being translated in all 13 finished locales -- the
literal was simply never passed through t():

  ViewObject.vue   isCopied ? 'Copied' : 'Copy'
                   isSaving ? ('Creating...' | 'Saving...')
                            : ('Create' | 'Save')

Copy, Creating..., Saving..., Create and Save were all already in the
catalogue and translated; only Copied is new.

Also wraps "Mode:" and "Error Details" in MassValidateModal. Those two
mattered beyond the display bug: both keys existed in en.js with no t()
call, so they read as dead and were on the unused-key removal list.
Wrapping them keeps their translations rather than discarding work that
would be needed the moment the string was wrapped.

t() is available in these templates via main.js's
app.mixin({ methods: { t, n } }).

* fix(l10n): protect variable-keyed strings from the unused-key sweep

Static extraction cannot see a key passed to t() through a variable:

  t('openregister', action)        PermissionMatrix.vue:41
  t('openregister', step.status)   ApprovalStepList.vue:17
  t('openregister', preset.label)  DashboardIndex.vue:91,120,360
  t('openregister', key)           MainMenu.vue:76 (manifest labels)

All four are real, live keys. actions and the date presets are hardcoded
frontend arrays; step.status is a raw DB enum that the approval-steps API
returns verbatim, so the backend never localises it and the frontend must.

They therefore look unused. Once en.js was completed to cover them,
clean-l10n --apply would have deleted 17 keys from all 37 bundles,
silently un-translating the Permission Matrix headers, the approval
status badges, the dashboard date presets, and the "Data sources" and
"Endpoints" menu labels.

Adds DYNAMIC_KEYS + collectDynamicKeys() to lib/l10n.js, documenting
where each key comes from, and teaches both consumers to treat them as
used: check-l10n no longer reports them unused, clean-l10n never offers
them for removal. clean-l10n now prints the protected count so the
exclusion is visible rather than implicit.

* l10n: rebuild en.js from source and full-sync the 13 finished locales

en.js had drifted badly from src/: 1410 keys where the code uses ~2000,
so check:l10n reported 997 missing and 405 dead and neither number could
be trusted as a completeness signal for any locale.

en.js is now generated from the actual t()/n() call sites and is a strict
superset of every locale: 2018 keys. The six n() keys hold proper
[singular, plural] arrays instead of being absent, and the 15
variable-keyed strings were added here rather than deleted from the
locales, because they are genuinely used (see previous commit).

Dead keys removed from all 37 bundles (13,483 entries), all verified to
have no t() reference and no complete quoted literal in src/ -- obsolete
features: agents, conversations, collections, Solr/Zookeeper, memory
prediction. Also removed 240 keys that existed in a locale but not in
en.js at all, which is backwards -- a translation for a string the source
does not contain: nl's entity-type map keys (PERSON/EMAIL/... are JS
object keys in formatType, not translation keys), three PHP %1$s
notification strings that belong to the backend .json catalogue, two
pre-fix mangled ⚠ variants, and 226 obsolete SOLR/agent keys in tr.

The 13 finished locales (nl de fr es it pt sv da nb pl cs ru uk) are now
key-for-key identical to en.js -- 2018 keys each, 554 cognates written
out explicitly. This reverses the earlier "leave cognates absent" rule
for these locales only; the 23 unfinished ones keep their current shape
so their real progress stays measurable. Note the consequence:
test:l10n:parity now reports 22-70 English-identical per finished locale
where it previously reported them as missing. Those counts are the
cognate counts, not defects.

Wrong-sense and gap fixes found by cross-checking the finished locales
against each other. Most one-off absences are legitimate -- French really
does share Action/Configuration/Contacts with English, Dutch shares
Object/Complex, German shares Name/Status -- so each was judged per
language rather than by majority vote. The genuine errors:

  Bucket   WRONG SENSE everywhere. It labels a histogram score range,
           but nb had "Bøtte", pl "Kosz" and ru "Корзина", all
           "basket/pail". Now a range in all 13. These are the only
           three pre-existing translations this commit overwrites.
  Object A/B, Object #{id}  absent in de and fr, which render Object as
           Objekt/Objet, so these showed English.
  Multitenancy, GitHub/GitLab Personal Access Token  absent in de, fr.
  Name *, Name*  absent in fr, which has Name -> "Nom".
  Facetable  absent in es; log{plural} absent in es and pt.
  Account  absent in pt, sv. Endpoints absent in pt. Agents absent in nl.
  Copied   new key, translated for all 13.

Verified: en.js is a superset of all 36 locales, the 13 finished ones
have identical key sets, 0 bad plural arity anywhere, every bundle loads
under OC.L10N.register with plural arrays matching its own nplurals, and
diffing against HEAD shows 0 real translations altered beyond the three
Bucket fixes above.

* l10n: translate the Greek frontend catalogue (el)

el now carries a real Greek translation for every string that needs one:
1007 keys added and 15 English placeholders replaced, taking it from
1011 to 2018 keys -- key-for-key identical to en.js, matching the
full-sync shape of the other finished locales. The 26 remaining
English-identical values are deliberate cognates: acronyms and formats
Greek keeps as-is (CSV, PDF, RBAC, URL, UUID:, IBANs, DSAR, Slug,
Webhook, Email -- core Greek also renders Email as "Email"), proper
names (Deck, Excel (.xlsx), OpenDocument (.ods)) and literal placeholder
examples (sk-..., org-..., fw_..., myapp, sample URLs, header/YAML
snippets).

Register: formal, measured against Nextcloud core rather than assumed.
Core/lib alone gives 3 informal vs 135 formal distinct strings; with all
bundled apps, 8 vs 541. The 8 informal hits are demo content ("Γεια σου
κόσμε!"), the standalone "Εσύ" label, and one residual 3sg-past false
positive that itself contains formal σάς. So σας/εσείς for address, 2pl
imperatives in -τε (Επιλέξτε, Πατήστε, Εισάγετε) and 2pl present
(Μπορείτε, Έχετε).

Building that detector took three corrections, because the naive version
reported 532 informal vs 639 formal -- effectively noise:

  σε           read as the 2sg clitic, but it is overwhelmingly the
               preposition "to/in" (351 hits). Dropped.
  -εις / -άς   matches plural NOUNS (ειδοποιήσεις) and genitive
               singulars (γραμματοσειράς), not just 2sg verbs. Replaced
               with a closed verb list.
  -σε verbs    the 2sg imperative is homographic with the 3sg past: "Ο
               {actor} δημιούργησε" is "created", not "create!". Now
               requires no sentence-initial 3rd-person subject -- and
               that cue had to be anchored to the start, because mid-
               sentence "το" is the neuter article ("πάτησε το κουμπί").

Validated on 19 must-fire / must-not-fire controls, including those
false-positive strings; the finished el.js has 0 informal markers.

Convention follows core Greek: actions are verbal nouns (Αποθήκευση,
Διαγραφή, Προσθήκη, Επεξεργασία), not imperatives. Glossary: Μητρώο
(register), Σχήμα, Αντικείμενο, Οργανισμός, Διακριτικό (token), Τελικό
σημείο (endpoint), Ιστορικό ελέγχου (audit trail), Όψη (facet),
Απόκρυψη (redaction), Εγγραφή αναφοράς (golden record).

Harvested 24 values from core/lib and bundled apps. Bucket was the
familiar wrong-sense trap -- files_external offers "Κάδος" (bin/basket)
where the string labels a histogram score range, so it became "Εύρος".
People correctly harvested as "Άτομα" here (unlike uk, where core gave
"Users"), and Right/Subject/Open were translated in their verified
senses: Δικαίωμα (permission, not direction), Υποκείμενο (GDPR data
subject, not mail subject), Άνοιγμα (action, not adjective).

Plurals use Greek nplurals=2; all 6 plural keys carry 2 forms. The
object{plural} family follows the parenthetical convention already used
by ru/cs/uk.

Verified: 0 missing, 0 empty, 0 bad plural arity, key set identical to
en.js, loads under OC.L10N.register, and diffing against HEAD shows 0
existing real translations altered or removed.

* l10n: translate the Finnish frontend catalogue (fi)

fi goes from 1011 to 2018 keys -- key-for-key identical to en.js -- with
1007 keys added and 15 English placeholders replaced. The 24 remaining
English-identical values are deliberate cognates: acronyms and formats
(CSV, PDF, RBAC, URL, UUID:, IBANs, DSAR, Slug, Webhook), proper names
(Deck, Excel (.xlsx), OpenDocument (.ods)) and literal placeholder
examples.

Register: 2sg (sinuttelu), and this is the first locale in this effort
that is NOT formal -- so it was worth measuring rather than carrying the
previous locales' answer over. Finnish does not map onto the Slavic/Greek
T-V pattern, and core is unambiguous: the formal pronoun te/teidän has
ZERO hits in core+lib, while sinä and the 2sg possessive -si are
pervasive ("salasanasi", "Kirjautumispolettisi"). core+lib scores 2sg 138
vs 2pl 5; with all bundled apps, 554 vs 31, and the 2pl residue is
false-positive: -kaa/-kää also forms A-infinitives and 3sg ("Haku alkaa"
= search begins), so that ending is not usable as a marker and a closed
verb list is used instead.

Because 2sg is correct here, the register detector is INVERTED relative
to el/uk/ru: it flags 2pl, not 2sg. Validated on 23 must-fire /
must-not-fire controls -- including the -kaa false positives and correct
2sg forms that must never be flagged -- and the finished fi.js has 0
formal-2pl markers.

Convention follows core Finnish: buttons and actions are 2sg imperatives
(Tallenna, Poista, Peruuta, Muokkaa, Luo, Lisää, Kopioi), the exact
opposite of Greek's verbal nouns. Error messages take the natural Finnish
nominal shape ("Asetusten tallentaminen ei onnistunut") rather than a
literal "Failed to ...". Token is "poletti", matching core's
"Kirjautumispolettisi".

Harvested 22 values from core/lib and bundled apps. Revoke needed
correcting: settings offers "Peru oikeus", which is permission-specific,
but the call site revokes an API token -> "Mitätöi". Bucket was absent
from the Finnish harvest entirely, so it was translated fresh as "Väli"
(range) rather than inheriting the basket/bin sense that was wrong in
nb/pl/ru. People correctly harvested as "Ihmiset" for the PERSON entity
type; Right/Subject/Open translated in their verified senses (Oikeus,
Rekisteröity, Avaa).

Plurals use Finnish nplurals=2; all 6 plural keys carry 2 forms.

Verified: 0 missing, 0 empty, 0 bad plural arity, key set identical to
en.js, loads under OC.L10N.register, and diffing against HEAD shows 0
existing real translations altered or removed.

* l10n: translate the 7 CnAppNav menu-group labels

These labels live in src/manifest.json and reach t() only through
CnAppNav's `translate` prop (MainMenu.translate), so static extraction
never saw them: Administration, Audit, Data quality, Documentation,
Features & roadmap, Integration, Search / views. They were absent from
en.js and from all 36 locale bundles, i.e. the top-level navigation
groups have been rendering in English in every language.

Also narrow collectDynamicKeys() to the manifest fields that are
actually translated. It previously harvested every label/name/title in
the manifest, which pulled in observability.metrics[].name (Prometheus
metric identifiers such as objects_created_total) and pages[].title,
which CnPageRenderer forwards to the page component as a raw prop
without translating. Only menu[].label (recursed through children) and
the two nav label overrides reach t().

en.js and the 16 completed bundles are now 2025 keys, key-for-key
identical, with no extra keys in any locale.

* l10n: stop translating a technical literal, fix the Dutch file plural

The webhook headers placeholder is a code example, not UI copy, so it
does not belong in t(). Unwrapping it makes
"X-Custom-Header: value\nAuthorization: Bearer token" a dead key, so it
is dropped from en.js and all 36 locale bundles. It was value===key in
every one of them, so no translation is lost.

Also fix nl "file{plural}", which was left as "bestand{plural}". The
source interpolates plural: count !== 1 ? 's' : '', an English plural
marker, so that rendered "bestands"; Dutch is "bestanden". Its siblings
already sidestep this as "logboek(en)" / "object(en)", so this follows
them with "bestand(en)". nl "register{plural}" -> "registers" is left
alone because the English -s happens to be correct Dutch there, as it is
for es and pt throughout.

Note the underlying source defect this exposes: hardcoding 's' is only
correct for languages that pluralise with -s. Turkish (-lar/-ler) still
renders "dosyas"/"nesnes", and it cannot work at all for Finnish
partitives, Hungarian (no plural after a numeral) or the three-form
Slavic plurals. These call sites should use n() with a real plural key.

* l10n: translate the Hungarian frontend catalogue (hu)

hu.js goes from 1011 to 2024 keys, key-for-key identical to en.js: 1013
added, 16 English placeholders replaced with real translations, and 23
deliberate cognates (Audit, CSV, PDF, RBAC, Id, Port, URL, format names,
and literal example values shown verbatim in inputs).

Register: Hungarian core is formal. The measurement is unambiguous —
core+lib+apps has 43 hits for Ön/Önnek/Önt against 3 for te, and every
instructional string uses the polite third-person imperative
("Kattintson", "Lépjen", "szerkessze"). Formal Hungarian also takes the
3sg possessive, so "your password" is "a jelszava", never "a jelszavad".
Validated with a closed-list detector (2sg pronouns, 2sg verb forms, 2sg
possessives…
….1.10-beta.20260829221810

chore(release): sync beta back into development
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Dexie refuses to run twice in one page: it throws "Two different versions of
Dexie loaded in the same app". Nextcloud loads openregister's global
integration script and hermiq's agent leaf on every page, alongside whichever
leaf app you are in, so all three have to agree on one dexie.

After the dependabot sweep on 2026-08-30 they did not. openregister resolved
4.4.4 while hermiq resolved 4.4.5, and the throw happened before the leaf app
mounted, so every app page rendered as bare Nextcloud chrome with no content.

This pins the floor at ^4.4.5 and regenerates the lock, matching the apps that
were already there.

Verified in the browser: the Dexie error is gone from the console and app pages
render their navigation and content again.
Release: merge development into beta
…3103)

* fix(sidebar): render the manifest page's sidebar alongside our own

This app fills CnAppRoot's `#sidebar` slot, and Vue only renders a slot's
fallback when the slot is ABSENT. So filling it suppressed
`pages[].sidebarComponent` silently: no warning, no error, no sidebar. The
ADR-110 flow sidebar was declared in the manifest, registered in registry.js
and present in the bundle, and still never rendered.

Nine apps in the fleet fill this slot and all nine were affected. The five that
do not fill it rendered the flow sidebar correctly, which is what identified
the cause.

CnAppRoot now passes the resolved component to the slot (nextcloud-vue#857), so
this renders both: our own rail, and whatever the routed manifest page asks
for.

Verified: npm run build exits 0.

* chore(deps): @conduction/nextcloud-vue 2.24.3, which carries the sidebar slot prop

2.24.3 is the release that passes the resolved `pages[].sidebarComponent` into
CnAppRoot's `#sidebar` slot. Without it the App.vue change in this branch is a
no-op, because the slot prop it reads does not exist yet.

Verified on filinq in the browser against the dev instance: the flow rail
(Flow, Steps, Runs, Version, Publish, the trigger list) now renders next to the
canvas, and the app's own sidebar still mounts alongside it.
…0260830125150

chore(sync): carry beta back into development
The 'render the manifest page's sidebar alongside our own' commit landed
unformatted, and quality / Frontend Check (format) has been red on
development ever since:

  prettier --check "**/*.{js,ts,vue,css,scss}"
  [warn] src/App.vue

Eight apps took the same change and eight went red together. This is
prettier --write over the affected files and nothing else.

Verified: npm run format exits 0.
E2E went red on development with

  strict mode violation: locator('.cn-flow-sidebar') resolved to 2 elements
  Error: the flow sidebar did not render — the controls are unreachable again

#3103 fixed the real problem: CnAppRoot only offered the manifest's
`sidebarComponent` as the DEFAULT content of its #sidebar slot, and this
app fills that slot, so the manifest key was live config that rendered
nothing. #3103 passes the component through as a slot prop instead, so
App.vue can render it.

What it did not do is remove the workaround that existed BECAUSE the
manifest key rendered nothing — a hardcoded <FlowDetailSidebar> in
SideBars.vue, whose own comment explains it was there for exactly that
reason. With the manifest route working, both rendered.

So this deletes the workaround, not the fix. The manifest is now the
single source of truth for a page's sidebar, and the comment left behind
says so, because adding a route here for a page that declares
`sidebarComponent` will duplicate it again.

The unused import and components entry go with it — a registration left
behind after its template use is removed is the next reader's puzzle.

src/App.vue is Prettier-formatted here too; the same one-file fix is in
#3107, and whichever lands first makes the other a no-op.

Verified: eslint exits 0, webpack build compiles, npm run format exits 0.
Release: merge development into beta
@rubenvdlinde
rubenvdlinde merged commit 7f1a425 into main Aug 30, 2026
49 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