Skip to content

release: merge development into beta - #2972

Merged
rubenvdlinde merged 31 commits into
betafrom
hotfix/beta-sync-20260828
Aug 28, 2026
Merged

release: merge development into beta#2972
rubenvdlinde merged 31 commits into
betafrom
hotfix/beta-sync-20260828

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Carries the pending-schema-ref fix (#2918) and the repair-step registrations (#2923) to beta, so a stable release can be cut from them.

Supersedes #2906, which went unmergeable as both branches moved.

hotfix/* because the branch-protection workflow accepts only development, main or hotfix/* as a source for beta, and this branch carries a merge resolution rather than being development itself.

Two conflicts, both version-only — and the obvious resolution was wrong

appinfo/info.xml and openapi.json each conflicted on one hunk, the version string. Taking beta's side of the file (--ours) resolves that hunk correctly and silently reverts everything else in it, because beta's info.xml predates development's.

It took four repair-step registrations with it:

ImportMergeOperationRegister, RenameDutchColumns,
ImportFlowRegister, MigrateRegisterFlowsToTable

All four had just been registered on development precisely because gate-98 found they had never run on any instance. A file-level resolution to a line-level conflict put them straight back.

Both files are therefore taken from development in full, with only the version string replaced by beta's — the convention this repo's own beta-sync PRs document, and the release workflow recomputes it from the latest stable tag regardless.

Verified after resolving

unregistered repair steps   none
openapi.json                valid JSON
pending-schema-ref fix      present in ObjectService

Why this release is needed

The current stable v1.1.6 does not carry what the leaf apps need. Measured on a fresh demo built from all-stable pins (openregister 1.1.6 + opencatalogi 1.0.11 + portaliq 0.1.4): OpenCatalogi's catalog works, but portaliq's register materialised only 2 of its 13 schema tables — there is no table for the portal schema, so no portal object can exist and the demo portal is never seeded.

The same stack against 1.1.6-unstable.20260827111159 works completely. The stable was cut at 10:17 and that unstable at 11:09, so stable 1.1.6 is older than an unstable sharing its version number and lacks what portaliq needs.

rubenvdlinde and others added 30 commits August 27, 2026 08:59
…tors (#2898)

Follows the constructor fix (#2876). Those three services had been
unreachable, so their tests had grown around that: each one built the
object in a way production cannot.

EndpointServiceTest's TestableEndpointService said so in its own docblock:

    Test-only subclass to inject dependencies since EndpointService has
    no constructor.

and used Closure::bind to write four private properties from outside the
class. All 78 tests passed against a wiring that existed only in that
file. The subclass now calls parent::__construct(), so those 78 exercise
the same construction path production uses and would fail loudly if it
went missing again. (NotificationServiceTest's reflection workaround was
replaced the same way in #2876.)

Adds coverage for what was never reachable:

UploadServiceSourceRoutingTest (6 tests) covers getUploadedJson's routing
- the four private helpers that had no test at all, driven through the
PUBLIC entry point rather than by reflection, because the routing to them
is exactly what was broken. Includes the ordering that matters: internal
`_`-prefixed params are stripped BEFORE the source check, so a body of
nothing but control params is a 400 rather than falling through to the
json branch.

NotificationPayloadTest (3 tests) pins the notification contract. The
eight existing tests stub the notification with willReturnSelf(), which
asserts the call chain does not break and says nothing about the subject
key or parameter names - so renaming `configuration_update_available` or
dropping `currentVersion` would leave all eight green while every
consumer stopped recognising the message. lib/Notification/Notifier.php
reads those parameters back BY NAME.

Mutation-checked: renaming the subject key and dropping the 'unknown'
version fallback fails 2 of the 3 new tests. Restoring passes.

Full unit suite: 17507 tests, 0 failures. phpcs clean.

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

* test(aggregation): remove the coverage metadata that was discarding coverage

Closes #2847.

Under `beStrictAboutCoverageMetadata="true"`, PHPUnit does not merely
restrict recording to the units a test names — it marks any test that
executes anything else RISKY and throws that test's coverage away
entirely. The message is explicit once you look:

    This test executed code that is not listed as code to be covered or used:
    - OCA\OpenRegister\Db\Register
    - OCA\OpenRegister\Db\Schema
    - OCA\OpenRegister\Service\Aggregation\AggregationQuery

Almost every test in this directory legitimately runs a collaborator —
AggregationQuery, PlaceholderResolver, the Db entities — so naming the
class under test did not focus the measurement, it deleted it.

Measured locally with pcov, identical scope both runs (237 tests, 461
assertions):

                          with @Covers        without
  AggregationRunner       44.39% (613/1381)   80.30% (1109/1381)
  ...methods               9.80% (5/51)       33.33% (17/51)
  scope statements        1618                2137
  Risky tests               33                   0

Same tests, same assertions, same executed lines — only the attribution
differs.

⚠️ Those are pcov numbers. coverage-guard.php's own docblock records that
CI measures with xdebug and the two do not count statements identically,
so treat the DIRECTION as the result and let CI's `--against` measurement
be the authority. `.coverage-baseline` is untouched: the guard treats a
measurement above the floor as good news, and this only moves it up.

`TimeseriesRequestValidatorTest` had `@coversDefaultClass` with not one
`@covers ::method` to pair with it — naming a default nothing used, while
still restricting recording.

The reasoning already existed, measured, in
AggregationJoinAndCompositeGroupByTest's docblock; the other ten files
simply never had it applied. Each now carries a short note pointing there,
so the annotation is not helpfully restored later.

Full suite: 17336 tests, 0 failures.

* fix(test): name the annotation without its at-sign in the docblocks

CI failed all six PHPUnit cells with

    "@Covers ::method`" is invalid

My own explanatory comment was the cause. PHPUnit parses a CLASS docblock,
so the sentence describing what had been removed re-declared it — and the
method-scoped spelling is malformed, so it errored rather than being
tolerated. A comment about the bug became the bug.

Every docblock added by this branch now names the annotation without a
leading at-sign, and TimeseriesRequestValidatorTest says why so the next
person does not helpfully "fix" the prose.

(I wrote the replacement comment containing the same literal string once
more before catching it. It is a genuinely easy trap: the natural way to
document an annotation is to write it.)

The pre-existing mentions in AggregationJoinAndCompositeGroupByTest are
left alone — those parse harmlessly; only the method-scoped form is
malformed.

Full suite: 17498 tests, 0 failures, no invalid annotation.
…rators (#2904)

Two changes to the same machinery, both about a filter that silently
answers the wrong question rather than failing.

CONDITIONAL METRICS

`metrics[].condition` scopes ONE figure to a subset of the grouped rows.
That is what a debit/credit split needs: `totalDebit` and `totalCredit`
are the same SUM over the same field, separated only by `side`. Declaring
two aggregations instead is not equivalent — it groups and scans the table
twice, and the two results can disagree if a row is written between the
calls, which is exactly what a trial balance must never do.

It is a FILTER OBJECT, deliberately, not a SQL string: the same shape as
the aggregation's own `filter`, through the same applyFilter(). One
grammar, one implementation. A second string-shaped grammar is how a
consuming app ended up with 265 declarations this engine could not read.

`metrics[].as` names the response key, and conditional metrics REQUIRE it:
two conditional sums over one field both derive `sum_amount`, so the
second would overwrite the first and return one figure where the caller
asked for two.

The native SQL path REFUSES a conditional spec rather than running it.
tryNativeMultiMetric() aggregates every entry over the same filtered rows
and keys from metric+field, so it would drop the condition and collide the
aliases — answering wrongly and fast.

🔑 Writing the test found a defect in the implementation:
AggregationQuery::getMetrics() rebuilt each entry as {metric, field},
STRIPPING condition and as before the runner ever saw them. Must-fail
control: revert that and `openTotal` comes back 35.0 — the unconditioned
total — instead of 30.0.

The validator refuses a string `condition`, an empty `as`, and a condition
naming a property the schema does not declare. That last one matters most:
at run time such a filter is not an error, it matches nothing and returns
an empty result, which a page renders as "no data" over live rows.

UNKNOWN FILTER OPERATORS NOW THROW

checkOn()'s `default => true` let an unrecognised operator match EVERY
row, so the filter widened instead of narrowing. Measured in shillinq:
`{"not-in": [...]}` — the implemented spelling is `notIn` — meant an
AR-ageing report silently included settled invoices.

Blast radius measured before changing it: exactly ONE metric-bearing
aggregation in shillinq uses an unknown operator (APInvoice.apAging), and
it is wrong today. The rest (`equals`, `not`, `between`, `gteOrNull`,
`notStartsWith`, `not_in`) sit on aggregations that compute nothing yet, so
they will now fail loudly when someone gives them a metric rather than
returning a quietly widened set.

Verified: 17506 tests, 0 failures; phpcs and phpmd clean on the changed
files; both must-fail controls confirmed.
…#2900)

* fix(flow): enforce the assignee that AwaitSignalNode already recorded

ADR-098 names this gap exactly: "no task authz — anyone reaching the resume
endpoint can decide". Concretely, AwaitSignalNode has ALWAYS written an
`assignee` onto the suspension, and nothing ever read it back. The only check on
POST /api/flow-runs/{uuid}/resume asked "may you run this flow?", which is a
different question from "is this decision yours to make" — so everyone who could
run the flow could approve a step assigned to someone else, while the recorded
assignee made it look otherwise. A field that looks like authorization and is
not is worse than no field.

The resume endpoint now refuses when the awaiting step names an assignee and the
caller is neither that uid nor a member of that group. Anonymous is refused
outright: an assigned decision is never anonymous.

SCOPE, STATED HONESTLY. This closes the WHO of an already-recorded assignment.
It is NOT the task entity, inbox or definition versioning ADR-098 describes —
those remain unbuilt, and Wave 4's flow consolidation still depends on them. A
step with NO assignee is deliberately unchanged: silence still means anyone,
because most await-signal suspensions are webhooks and child-run completions
rather than human decisions, and tightening that would break every one of them.

Mutation-checked, because a security guard that cannot fail is the worst kind:
replacing the assignee lookup with an unconditional allow makes the suite fail.

* test(flow): cover the assignee guard's branches, not just its happy paths

The coverage ratchet failed this branch: it adds 33 statements to
FlowRunController and only 25 of them were reached, so coverage of the
code this change keeps or adds fell from 83.93% to 82.59%.

The three original tests covered the shape of the guard - assigned to
someone else, assigned to you, not assigned. What they did not cover were
the branches where getting it wrong is quiet:

- An assigned step answered with NO session. This is the fail-CLOSED half,
  and it is the half that is easy to invert, because the unassigned case
  deliberately lets anyone through: an implementation that treated "no
  uid" the same way would pass every other test in this file while leaving
  an assigned decision open to an unauthenticated caller.
- A GROUP assignee. AwaitSignalNode records one `assignee` string without
  saying whether it names a person or a group, so the guard tries both.
  Without a test, a broken group lookup refuses the step's own intended
  audience - and reads as "the guard works", because refusing is what a
  guard does. Both the member and the non-member case are asserted, so the
  lookup is a check rather than a rubber stamp.
- The three shape guards in recordedAssignee(). The context is stored JSON
  written by older runs under earlier shapes. A slot that has not asked
  yet must not gate the step that is asking, or a future step refuses the
  right person now; a malformed slot must read as unassigned rather than
  500 an in-flight run.

Each is mutation-checked: disabling the anonymous refusal, the group
branch, and the askedAt guard each make the suite fail.

18 tests -> 24.

---------

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

* test(e2e): make the delegation suites establish their own preconditions

Running the three delegation suites live against merged `development` gave a
different answer three times over identical code — 18 passed, then 8 failed,
then 2 failed. Neither the code nor the assertions were wrong; the suites were
inheriting state instead of establishing it.

Two separate causes, both of which let a run report something it had not
measured:

1. A HARDCODED FIXTURE UID. `NEXTCLOUD_OTHER_USER || 'ddauth-alice'` named an
   account that existed when the specs were written and did not exist after the
   dev instance was rebuilt. The failure read
   `"ddauth-alice" resolves to no account you may ask` — which is the delegation
   guard's own refusal message, i.e. a dead fixture wearing the words of a
   working control. The uid is now DISCOVERED from the instance, and a
   single-account instance SKIPS with a sentence naming what went unverified
   rather than failing for a reason that is not about the code.

2. A LEAKED GRANT. Every suite opens by asserting the save is REFUSED — the
   baseline the later "now it saves" assertion is measured against. Grants
   outlive a run, and a suite killed mid-way (a `head` closing the reporter's
   pipe is enough) leaves a `granted` row behind, so the next run's baseline
   got a cheerful 201. The failing direction was the lucky one: a leaked
   REVOKED grant would have made the same baseline pass for the wrong reason
   and the suite would have proved nothing. Each suite now revokes any live
   grant over its target before asserting.

The account probe deliberately does NOT swallow errors. An earlier draft
wrapped it in `catch { return null }`, and when the probe came back as
Nextcloud's login page — HTTP 200, HTML, `.json()` throws — the catch reported
"no second account", eleven specs skipped citing a single-account instance, and
the run said `0 failed`. It now throws with the status and body, because a
probe that cannot answer must say so.

Also lets `NC_CONTAINER=nextcloud` through under an explicit
`NC_ALLOW_SHARED_CONTAINER=1`. The guard exists to stop an accidental default,
not to make the parking path — which only exists once a real TimedJob ticks —
permanently unverifiable.

Verified: 18 passed / 0 failed, twice back to back, against merged
`development` on a live instance.

* test(e2e): prefer the shared dev container for occ, gate only restarts

The container guard refused the shared `nextcloud` container for every purpose,
and that was too blunt in both directions.

🔑 THE TWO ACTIONS ARE NOT THE SAME RISK, so they no longer share a rule.

`resolveContainer('exec')` — the default — now DEFAULTS TO the shared container
instead of returning null. Running one named `occ` command there is how the dev
box is meant to be exercised, and refusing to do so bought nothing: it made
every spec that needs a real TimedJob tick skip everywhere. The delegation
parking suite is the clearest casualty — a run parked on `awaiting_consent` and
released by a cron sweep only exists once a job actually runs, so the headline
behaviour of that subsystem was verified by nothing but a unit test, while the
summary said "3 skipped" in a tone indistinguishable from "3 passed".

`resolveContainer('restart')` still refuses the shared container without an
explicit `NC_ALLOW_SHARED_RESTART=1`. That is the action the original guard was
really about: `docker restart nextcloud` bounces an environment that bind-mounts
several developers' working trees, mid-session, with no warning to them. One
`occ` command is recoverable; restarting somebody else's instance is not.
`federated-config-store.spec.ts` is the only caller that restarts, and it asks
for that purpose explicitly.

Verified with NO container env set at all — the new default path:

  26 passed, 5 skipped, 1 failed

All three delegation-parking tests now RUN and pass, including the park and the
release through real FlowScheduleWorker and FlowRunWorker ticks. The remaining
failure is pre-existing and unrelated: `federated-config.spec.ts` (like
`flow-schedule.spec.ts`) needs a `flows` REGISTER, which this rebuilt dev
instance never seeded — its beforeAll fails on the register lookup regardless of
any container setting.

* style(e2e): prettier-format the delegation fixtures module
Both classes are fully written, implement IRepairStep, and were named ZERO
times in appinfo/info.xml. Nextcloud only runs what the manifest declares, so
neither has ever executed. gate-98 (repair-step-registration) catches it.

A class that exists is not a class that runs, and neither failure is visible:

- ImportFlowRegister creates the `flows` register and flow schema. Without it
  that register was never created, so every flow step listed BELOW it —
  MigrateRenamedFlowNodeTypes, BackfillFlowTriggerIndex, InitializeFlowActions
  — silently operated on nothing. It is therefore registered ABOVE them, in
  both <post-migration> and <install>.

- RenameDutchColumns moves stored data from the Dutch columns to the English
  ones the register now declares. MagicMapper ADDS a column when a snake_cased
  property is absent and never renames — there is not one RENAME COLUMN in the
  app — so a renamed property leaves the data in the old column while every
  read looks at the new one and finds null. No error, no data loss, and
  invisible to a suite that asserts against fixtures rather than migrated rows.
  For shillinq those columns carry invoice, subsidy, payroll and tax amounts.

  Its own docblock documents the step as non-destructive and idempotent: it
  renames only when the old column exists and the new one does not, copies
  across and LEAVES the old column when the new one already exists, refuses two
  sources targeting one destination, and deletes nothing. A re-run is a no-op.

  Registered under <post-migration> ONLY — a fresh install has no Dutch columns
  to move, so listing it under <install> would be a guaranteed no-op.

Pre-existing on development: neither name appears in its info.xml. This is not
introduced by any open PR; gate-98 is full-tree, so it reddens every branch
until this lands.

Note for whoever cuts the next release: repair steps run on upgrade, so these
two first execute on the next version bump.
…to nobody (#2915)

Refs #2905.

`FlowShareableConfigType::deserialise()` stored an imported flow with
`owner = null` and `organisation = null`, on the reasoning — correct as far as
it went — that the SENDER's identity means nothing on this instance.

But null is not "the absence of the sender's identity". It is the absence of
ANY, and `FlowMapper` scopes every read with an EQUALITY predicate:

    $qb->andWhere($qb->expr()->eq('organisation', $qb->createNamedParameter($organisation)));

`NULL = 'anything'` is never true in SQL. So the row inserted, `install`
returned its uuid with HTTP 200, and the flow was excluded from `flow#index`,
`flow#show` and the run path BY CONSTRUCTION — with no adopt route to rescue it
and no way for `flow#update` to reach it either. Install was a one-way door.
Five permanent orphans on one dev instance.

🔴 THE RULE WAS ALREADY FIXED — ON THE OTHER WRITER.

`FlowService::flowToSave()` refuses this exact write, and its comment describes
this exact outcome:

  > REFUSE rather than stamp nulls. […] a flow with no organisation belongs to
  > nobody: it does not appear in index(), find() refuses it, and it can never
  > be run or edited again. Accepting the write produced a permanent orphan and
  > reported success — the caller had no way to tell that from a flow that saved.

Two writers each deriving ownership their own way is how the rule came to hold
on one and not the other. Both now read `FlowService::callerOwnership()`, so
there is ONE place that decides it, and `deserialise()` refuses rather than
stamping nulls — the same refusal, on the path that was missing it.

THE SECURITY PROPERTY IS UNCHANGED, and it never depended on the null.
`Flow::canDispatch()` requires `enabled === true` AND a non-empty owner, so
`enabled = false` alone already refuses dispatch. A bundle still cannot arrive
and start executing against the receiving tenant's data, and the sender's
claimed `owner`/`organisation` are still discarded. What the null added was not
safety — it was unreachability.

A TEST REQUIRED THE DEFECT. `testAnImportedFlowLandsDisabledAndOwnerless`
asserted `getOwner() === null`, so the orphan was pinned as a requirement. It is
now `testAnImportedFlowLandsDisabledAndOwnedByTheInstaller` and asserts both
halves: the sender's claim is rejected (`not 'victim'`, `not 'their-org'`) AND
the installer owns it. A second test pins the refusal when no caller resolves.

Verified: 10 tests, 33 assertions, OK. phpcs clean on both changed lib files.
…2916)

Plan item 4, and the largest single capability #1261 is waiting on — 11
declarations need it.

`groupBy: ["AnalyticalDimension.parentCode"]` asks to roll parent rows up
to a column the parent does not have. applyJoin() cannot produce it: it
runs AFTER grouping and attaches figures to groups that already exist, and
by then the rows are gone.

So the value is projected onto each parent row FIRST, through the join
key, and grouping proceeds normally. The result is a roll-up to the joined
dimension — a cost-centre hierarchy summing its children, which is the
shape this exists for.

THREE THINGS THE TESTS FOUND, each a wrong answer rather than an error:

1. The `on` SHORTHAND cannot be used here, and now says so. "Schema.column"
   infers the parent-side field FROM THE GROUP FIELDS — same-named one if
   present, otherwise the first. When the group field is the joined one,
   that inference picks the JOINED field as the parent key, reads it off
   rows that do not have it, and puts everything in one bucket. Measured:
   a fixture summing 350 + 7 came back as a single bucket of 357 keyed ''.
   The explicit {parentField: joinedField} map names both sides.

2. The post-grouping merge had to be SKIPPED. After projection the group
   key IS the joined dimension, so the original join key is no longer in
   the row; mergeJoinedValues() would look up a tuple that cannot match and
   hang a map of nulls on every group — reading as "the joined schema had
   nothing" rather than "the grouping already answered this". The envelope
   now reports `join.consumedForGrouping: true`. Attaching figures there
   would mean re-aggregating the joined schema BY the projected dimension,
   a second and different join, which is not invented silently.

3. An unmatched parent row keeps a NULL group instead of being dropped.
   Dropping it would quietly shrink every total with nothing saying so. The
   test pins 350 + 7 + 11 = 368.

The native SQL path refuses a join-qualified group field: its SQL is
emitted against the parent table alone, so the column exists on no row and
every row would land in one null bucket, reported as a working total.

Also: the map is read directly rather than through resolveJoinKey(), which
enforces "every parent-side field must be one of the groupBy fields" —
right for the post-grouping merge, false here by construction.

phpmd flagged the first cut at complexity 20 / NPath 25600; split into
joinedProjectionKeyMap(), loadJoinedRowsForProjection() and
stampProjectedColumn(). Both tools clean.

Verified: 17526 tests, 0 failures; phpcs 0 errors; phpmd clean; must-fail
control confirmed (disabling the projection reddens both roll-up tests).
…2917)

* feat(graphql): filtered groups, and declared aggregations by name

Two changes to the GraphQL aggregation surface.

1. A FILTERED LIST RETURNED UNFILTERED GROUP TOTALS

resolveList() maps the query's `filter` into the row list. resolveGroupBy()
built its aggregation input with `'filter' => []` HARDCODED, so the edges
honoured the filter and the groups silently did not — group totals were
computed over the whole schema.

    gLLines(filter: {eliminationFlag: false},
            groupBy: {field: "accountNumber", metric: SUM, metricField: "amount"}) {
      edges { node { amount } }   # filtered
      groups { key value }        # NOT filtered — every GLLine
    }

Nothing errored. The caller was shown a bigger number than the rows it was
given, which is the hardest kind of wrong answer to notice on a dashboard,
and launchpad widgets read these over runtime GraphQL.

Only the PROPERTY filter forwards. Paging must not — a total over "the
first 20 rows" is not a total and would change as the user paged. `search`
must not — it is a relevance query the aggregation engine does not
implement, so forwarding it would filter on a property named `_search`,
match nothing, and return an empty result rather than an error.
`selfFilter` addresses @self metadata, a different namespace. Each
omission is deliberate and documented, because anything left out means the
groups describe a wider population than the rows.

2. DECLARED AGGREGATIONS ARE NOW REACHABLE FROM GRAPHQL

`groupBy` is ad-hoc: the caller describes the aggregation. A schema's
`x-openregister-aggregations` were REST-only, so a page wanting a declared
figure had to hand-build a URL alongside its GraphQL query.

    gLLines(filter: {...}, aggregation: "consolidatedTrialBalance") {
      edges { node { amount } }
      aggregation
    }

The name is the whole input — the declaration already carries the metric,
grouping, filter and join, and was validated at save time. The query's
filter is passed as a NARROWING constraint, which is safe by construction:
the engine refuses any request key the declaration already pins, so a
caller can add a constraint and can never relax a declared scoping one.

The envelope is JSON, deliberately. A declared aggregation's shape varies
with what it declares — a scalar `value`, a `values` map for `metrics`,
`groups[].keys` for a composite groupBy, `joined` when it joins. Typing it
now would repeat the mistake GroupBucket already makes, where
`value: Float!` cannot carry a values map and a null group key coerces to
"". A typed AggregationResult is the right next step once a consumer needs
introspection over it.

Verified: 17520 tests, 0 failures; phpcs 0 errors; phpmd clean. The filter
fix has a must-fail control — reverting it reddens the two filtered tests
while the unfiltered control correctly stays green.

* feat(graphql): let a bucket carry what the engine actually returned

Plan item 3. GroupBucket flattened the engine's result into two scalars,
and each coercion lost something real.

    'key'   => (string)($bucket['key'] ?? '')
    'value' => (float)($bucket['value'] ?? 0)

A NULL group key became '' — rows whose grouped field is null were
indistinguishable from rows whose value is genuinely the empty string. And
a multi-metric bucket carries `values` with no scalar `value` at all, so
the float cast reported 0.0 FOR EVERY BUCKET rather than admitting the
figures live under another key.

The second was unreachable only because GraphQL could not ask for
`metrics[]`. Widening the input without widening the bucket would have
made it reachable, which is why both halves land together.

  key    String   (was String!) — null stays null
  value  Float    (was Float!)  — null for a multi-metric grouping
  keys   JSON     — composite group key as {field: value}
  values JSON     — figure per response key, incl. `as` aliases
  joined JSON     — figures from a joined schema

The input widened to match: `fields` for composite grouping, and `metrics`
with per-entry `condition` + `as` for the conditional split.

VALIDATION STAYS IN ONE PLACE. TimeseriesRequestValidator handles both new
keys and delegates the metric entries to
AggregationMetricsAnnotationValidator — the SAME class the annotation path
uses. An ad-hoc request and a declared aggregation cannot drift in what
they accept. A validator and an executor each owning a copy of the grammar
is exactly how this engine acquired specs it could not run; adding a third
copy for GraphQL would have repeated it.

`condition` is JSON because it is a filter OBJECT, the same shape as the
aggregation's own filter — deliberately not a string expression.

Must-fail control: restoring the two coercions reddens both new tests with
"Failed asserting that '' is null" and "Failed asserting that 0.0 is null".

Verified: 17522 tests, 0 failures; phpcs 0 errors; phpmd clean.

* fix(graphql): cache AggregationMetricInput in the shared map, not its own field

phpmd failed the PR on two counts, both from the same addition: a dedicated
`$aggregationMetricInputType` property took TypeMapperHandler to 16 fields
(TooManyFields, threshold 15) and its name was past the LongVariable limit.

The $inputTypes map already exists for exactly this — a shared input type
cached by purpose — so the type moves there under a 'shared:' key. No
behaviour change: same instance, same single construction, same reuse.
… an unanswerable @self (#2922)

Two ways a cross-schema aggregation returned a plausible wrong number.

1. `metrics` was never read when `from` was set.

runCrossSchema() resolved only the SINGULAR `metric`/`select`, so a spec
asking for several conditioned figures fell through to the default `count`.
A debit/credit segment P&L came back as a ROW COUNT under HTTP 200, with
nothing in the envelope naming what had been dropped. The intra-schema path
has honoured `metrics` since #2917 — two paths reading different halves of
the same declaration is drift that answers wrongly rather than erroring.

The cross-schema path now extracts `metrics`, includes it in the cache key,
skips the native path when it is present (tryNativeAggregation() takes one
metric/field pair and has nowhere to put per-entry `condition`/`as`), passes
it to the computeGrouped(metrics:) branch that already existed, and yields
`values` rather than a scalar when ungrouped.

2. An `@self.<field>` the parent row cannot answer resolved to null.

This was described as failing closed. It does not: the null is applied as a
real filter VALUE, so the aggregation returns the target rows whose own field
is null. For a segment P&L keyed on `@self.code` that is the unassigned-cost-
centre total — returned confidently, identically, for every parent record.

No production caller supplies a parent row at all: AggregationController,
ReportRenderService and ThresholdEvaluationService each call run() without
one. So every such declaration answered wrongly rather than visibly. An
absent key now raises and names the reference; a key PRESENT but null still
correlates on null, which is a legitimate query.

The test that pinned the old behaviour asserted 0 against a fixture whose one
row happened to have a non-null field. A row with a null there would have
returned 1 under the same code — it was pinning the fixture, not the
behaviour. It now asserts the refusal, with that fixture inverted.

Refs #1261
`setSchema()` stores a pending ref so that a LATER `setRegister()` can
re-resolve the slug inside the register the caller names. When a register is
already set, `setSchema()` resolves immediately and returns early — and left
the ref set anyway, with nothing remaining to resolve.

ObjectService is shared for the whole request, so that ref outlives the chain
that created it. The next caller's `setRegister()` then re-resolves a finished
operation's slug inside a register that has never heard of it, and refuses
that caller.

Measured 2026-08-27 on a fresh instance. buildiq registers its navigation
entries from `Application::boot()`, which runs on EVERY request and calls
findAll() -> prepareFindAllConfig(), which calls setRegister() and then
setSchema('application'). Every request therefore ended with `application`
pending. Portaliq's PortalResolver — typically the first caller afterwards to
name its own register — was told:

    Schema slug "application" is not carried by register "portaliq" (id 35)

PortalResolver fails closed by design, so it returned an empty portal list and
every public portal page 404'd, with nothing in the error attributable to
portaliq. Confirmed by instrumenting setSchema() to record its caller: the
producer was buildiq's boot, several frames below OpenRegister's own findAll().

The same leak reached buildiq (`automation`) and hermiq (`job_log`) on cron.

#2803 cleared the ref on the save path. This is the read path, which it did
not cover — verified against origin/development before writing the fix.

Verified live: with the fix applied, the previously-404ing portal renders in a
browser (heading, search hero, menus, footer) with no other change.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* feat(schema): validate cron as a string format

A cron expression fails in the quietest way a value can. The schedule does not
error, it simply never fires — at 03:00, with no request to answer and nobody
in the room. There is no later moment where the mistake is cheap, so it is
caught where it is written.

`format: "cron"` on a string property now validates standard five-field cron:
`minute hour day-of-month month day-of-week`, with `*`, numbers, ranges, lists
and steps. Registered alongside the existing custom formats in
ValidateObject::getValidator().

⚠️ `@daily` and its siblings are deliberately REFUSED. Which shortcuts a
scheduler resolves varies between implementations, so accepting them would let
a document validate against a vocabulary the runtime may not share — producing
exactly the silent never-fires this check exists to prevent. Five fields is the
form every implementation agrees on.

Day-of-week accepts 0 AND 7 for Sunday, because standard cron does; refusing 7
would reject expressions every crontab takes.

32 tests, 21 of them refusals — a format validator that accepts everything
passes every happy-path test ever written, so the refusals are the ones with
teeth. Verified loading in a running instance, not only in the unit lane.

* refactor(schema): split the cron term check on its real seam

phpmd refused isValidTerm() at cyclomatic 14 / NPath 1344, against thresholds
of 10 and 200. That is not a counter being fussy — it is the shape of a method
doing two jobs: deciding whether the STEP is legal, and deciding whether the
VALUE or RANGE is legal. They share nothing but the term they came from.

Split accordingly, not shaved to fit. Verified by control: phpmd reports both
violations against the previous version and none against this one, and the 32
tests are unchanged and still pass.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* fix(tmlo): all three endpoints answered 500 on every request

`GET /api/tmlo/{register}/{schema}/summary`, `.../{id}/export` and
`.../export` are routed, and none of them could succeed even once.

Each called a service method with named arguments that do not exist on it:

    summary()       findAll(register:, schema:, filters:)   -> $register
    exportBatch()   findAll(register:, schema:, filters:)   -> $register
    exportSingle()  find(identifier:, register:, schema:)   -> $identifier

`findAll()` is `findAll(array $config, bool $_rbac, bool $_multitenancy)` and
`find()`'s first parameter is `$id`. PHP raises `Error: Unknown named
parameter …` for each.

WHY IT REACHED THE USER AS A 500 RATHER THAN A HANDLED FAILURE. `Error` is not
an `Exception`. The method's three catch blocks are
`SchemaNotInRegisterException`, `DoesNotExistException` and `Exception`, so the
fatal passed straight through all of them.

## Three separate defects, not one

1. **The named arguments**, above.
2. **`summary()` read `$result['total']`** — a key `findAll()` never returns; it
   returns rendered entities. Even with the call fixed, every status would have
   reported 0. It now uses `count()`, which returns the int directly, after
   `setRegister()`/`setSchema()` — without that context `countAll()` sums every
   register/schema table on the instance and all four statuses would report the
   same instance-wide total.
3. **`exportSingle()` passed ENTITIES** for `find()`'s `string|int|null $register`
   and `$schema`. A TypeError even once the name is right. Now ids.

## Psalm had already found this, and a baseline hid it

`psalm-baseline.xml` carried SEVEN `InvalidNamedArgument` entries plus a
`TooFewArguments` for this one file. The tool detected every one of these
fatals and they were suppressed. The block is removed — all eight entries are
unused now, which is how the wider defect surfaced: fixing `summary()` alone
left psalm reporting "3 extra entries", and the remaining four pointed straight
at `exportSingle()` and `exportBatch()`.

Fixing only the endpoint I was sent to fix would have left two of the three
still fatal.

## Verification

- 5 new tests (10 total in the file, 26 assertions). Mutation-checked: against
  the previous controller, 4 of them error with the real production messages —
  `Unknown named parameter $identifier` and `$register`.
- `summary()` had NO test at all before this, which is how a method that cannot
  succeed shipped and stayed shipped.
- phpcs, phpstan `[OK] No errors`, psalm 0 errors, gate-16 spec-coverage
  `count=0`, full suite 17,520 tests / 39,372 assertions with no failures.

Closes #2886.

* fix(repair): register the two repair steps Nextcloud was never told about

gate-98 (repair-step-registration) fails on `development`: two classes
implement `IRepairStep`, are fully written, and are named nowhere in
`appinfo/info.xml`, so Nextcloud never runs them.

**`ImportFlowRegister`** — imports the `flows` register and its `flow` schema.
Unregistered, that register has never been created by an install or upgrade.
This is the class the "a class that exists is not a class that runs" note was
written about; it was found once and never wired up.

**`RenameDutchColumns`** — renames Dutch-named columns to their current names.
Unregistered, no instance has ever had the rename applied.

Both document themselves as idempotent, which is what makes registering them
safe rather than a migration event:

- `ImportFlowRegister` imports "idempotently on install and upgrade" and
  downgrades a failure to `$output->warning()` rather than aborting the upgrade.
- `RenameDutchColumns` renames only where the OLD column exists and the NEW one
  does not; where MagicMapper already added the new column it COPIES and leaves
  the old in place; two sources targeting one destination are refused, not
  merged; nothing is deleted.

## Placement

`ImportFlowRegister` goes in BOTH blocks, beside the other `Import*` steps — a
fresh install needs the flows register as much as an upgrade does.

`RenameDutchColumns` is `post-migration` ONLY. It is a data migration over
existing tables, and a fresh install has no Dutch-named columns to rename.

## Verification

The checker's default invocation reports `checked 0 repair step(s)` — it reads
the changed-file set from stdin and there is none outside CI. Run the way the
runner runs it on a full tree (`--all`) it inspects 19, and against the state
before this commit it names both failures with the exact `<step>` line to add.
A gate that inspects nothing would have reported the same "0" either way.

Suite and phpcs unaffected.

---------

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

41 declarations across the shillinq registers carry an `expression` key the
engine never read. They did not fail; they produced nothing for that figure,
which is the shape this whole effort keeps finding.

    "vatBalance":   { "operation": "expression",
                      "expression": "totalVATPaid - totalVATCollected" }

Both operands are already computed by the same aggregation. Only the arithmetic
between them was missing.

## Why not a second aggregation

Declaring one to do the subtraction is not equivalent: it scans the table again,
and the two results can disagree if a row is written between the calls — which
a trial balance must never do. A derived metric reads the numbers this
aggregation just produced, so it cannot disagree with them.

## Why a parser and not eval()

These expressions come from register descriptors, which are DATA. eval() on data
is arbitrary code execution, and "we control the registers" does not survive the
first app that imports a descriptor from elsewhere.

MetricExpressionLexer + MetricExpressionEvaluator implement a recursive-descent
parser over a closed grammar:

    expr   := term (('+'|'-') term)*
    term   := factor (('*'|'/') factor)*
    factor := NUMBER | IDENT | '(' expr ')'
            | ('min'|'max') '(' expr ',' expr ')' | '-' factor

No assignment, no property access, no string literals, no function beyond
min/max. Everything else is refused BY NAME. The test feeds it `phpinfo()`,
`a; system("id")`, `$a + 1`, `a->b`, `a . 'x'` and `a ** 2`.

## What it refuses, deliberately

- An IDENTIFIER NAMING NO ALIAS throws, and names the aliases that do exist.
  Resolving it to 0 would turn a typo into a plausible number: `a - typo` would
  quietly return `a`.
- DIVISION BY ZERO yields null, not INF and not NAN. json_encode() refuses INF
  outright and NAN compares false against everything, so both travel a long way
  before anyone notices.
- A NON-NUMERIC alias throws rather than being coerced.
- A NULL alias propagates as null rather than counting as zero.

A derived metric reads only aliases computed BEFORE it, so the `metrics` list is
the dependency order; reading a later one raises and says so.

## Wiring

- `computeMetrics()` gains the branch. It cannot reach the native path:
  `metricsNeedPhpPath()` now names `expression` explicitly rather than relying
  on the `as` check, so removing `as` could never route one at SQL and have it
  silently compute nothing.
- The annotation validator accepts `metric: expression`, and refuses one with no
  `expression`, no `as`, or a `field` (it aggregates nothing).
- The evaluator is constructed internally, like RegisterScopedSchemaResolver: it
  has no collaborators, and a new required constructor argument would change the
  signature every existing caller and test already builds.

## Verification

- 9 evaluator tests + 2 end-to-end through AggregationRunner
- Mutation control: making an unknown alias resolve to 0, and division by zero
  return INF, each makes the suite fail; reverting restores green
- Full suite 17,588 tests, 0 failures
- phpcs 0 errors, phpmd clean, phpstan [OK] No errors

Refs ConductionNL/shillinq#1261
…ort one (#2910)

* feat(registers): show which app-declared registers landed, and re-import one

Closes #2903.

Eighteen of the fleet's apps ship a register descriptor and import it from a
Repair step. Repair steps run on install and `occ upgrade`, and `occ upgrade`
reports "No upgrade required" the moment `installed_version` matches
`info.xml` — so once an app settles, its descriptor can never be imported
again. The steps are also documented never to throw: a failure logs a warning
and leaves the instance looking healthy.

The cost is not hypothetical. On a dev instance, an `occ upgrade` that reported
complete success left 8 OF 15 DECLARED REGISTERS ABSENT, `flows` among them.
Two e2e suites died in `beforeAll` on the missing register, and establishing why
took an account listing, a register dump and a read of the Repair step's source.

WHAT THIS ADDS

- `RegisterDescriptorService` — an inventory of every register any installed app
  declares, each `current` / `behind` / `absent`, plus a forced re-import.
- `GET /api/register-descriptors` and
  `POST /api/register-descriptors/{appId}/{slug}/import`, both admin-only.
- `occ openregister:descriptors:list` (with `--problems-only` and `--import`).
- An admin-settings panel that LEADS WITH WHAT IS WRONG.

THREE DECISIONS WORTH THE REVIEW TIME

1. It enumerates DECLARING APPS, not resolved registers. Reading the resolver
   keys or the configuration rows would list only what already imported — the
   interesting row, the app whose seed never ran, has neither. An inventory
   built that way reproduces exactly the silence it exists to break.

2. Discovery is by SHAPE, not filename. The fleet's names vary
   (`flow_register.json`, `credential-providers.json`,
   `n8n_workflows.openregister.json`), and a `*_register.json` glob would
   quietly omit the rest — shrinking the inventory instead of failing, which is
   the same invisibility one level up. A test reads OpenRegister's own
   `lib/Settings`, counts the declaring files independently of the code under
   test, and asserts every one is found.

3. The import is ALWAYS forced. `ImportHandler` short-circuits on
   `$force === false && version_compare($shipped, $existing, '<=')`, and that is
   precisely the state an administrator presses the button in: absent, or failed
   to write, while the counter says current. An unforced re-import would report
   success and do nothing in every case that motivates the action.

`absent` and `behind` stay distinct everywhere — they need different actions and
carry different risk. Absent means a code path is dead; behind means it runs
against an older contract.

ON CUSTOMISED SCHEMAS

A forced re-import rewrites the base. An extending schema REFERS to its base —
`Schema::getAllOf()` returns "Array of schema IDs, UUIDs, or slugs" — so it is
impervious to the base moving. That is a property of the implementation rather
than a law, so it is pinned by an e2e test instead of assumed: an extension
materialised as a copy would silently revert somebody's customisation, through
the button offered as a repair.

VERIFIED LIVE

  occ openregister:descriptors:list   → 15 declared · 7 current · 0 behind · 8 ABSENT
  --import=flows                      → imported
  occ openregister:descriptors:list   → 15 declared · 9 current · 0 behind · 6 ABSENT

  GET  /api/register-descriptors                                → 200, 15 rows
  POST /…/openregister/flows/import   (versions already match)  → 200 {"outcome":"imported"}
  GET  /api/register-descriptors      as a non-admin            → 403

  RegisterDescriptorServiceTest      11 tests, 52 assertions, OK
  register-descriptors.spec.ts       4 passed, twice back to back

ALSO IN HERE

`flow-schedule.spec.ts` and `federated-config.spec.ts` were dead — their
`beforeAll` resolved the `flows` register that had never been seeded. With the
register imported they run, and two stale fixtures surfaced: both hung a flow
step's `type` + `config` off an EDGE, which the validator now refuses outright
("a node is the action that runs and an edge is sequence, so nothing reads this
step"). The flow was never created and the failure surfaced two assertions later
as an empty bundle, reading exactly like a broken bundler. Steps moved onto
nodes.

`federated-config.spec.ts` additionally authored into the `flows` register while
the bundler reads the `openregister_flows` table, so it now authors through
`/api/flows`. That store split, and an `install` that returns a uuid for a flow
which exists in neither store, are real product defects and are filed separately
as #2905 — not patched over here.

* style(e2e): prettier-format, and mark #2905 expected-to-fail rather than red

Three things CI's `Frontend Check (format)` and a local run turned up:

- Prettier formatting on the three files this branch adds or rewrites.

- `reg` and `sch` in `federated-config.spec.ts` became dead once `makeFlow()`
  moved to `/api/flows`. The `flows` register is still a precondition — 'a
  register bundles into a portable OpenAPI document' bundles it BY SLUG — so the
  lookup stays and only the unused ids go. On an instance where the descriptor
  never landed, that lookup failing UP FRONT with `registers slug=flows` is the
  most useful thing the block can do.

- The bundle/install/run test is split in two. Install now ends at the uuid;
  whether that flow can RUN is its own test, marked `test.fail()` and naming
  #2905 — the defect where `install` reports HTTP 200
  with `{"installed":[uuid]}` for a flow that exists in neither store.

  Split rather than folded, because marking the whole test expected-to-fail
  would stop the bundle-and-install assertions from ever failing again — trading
  one tracked defect for four untested behaviours.

  `test.fail()` rather than a skip, because a skip goes quiet and STAYS quiet.
  This reports red the day #2905 is fixed and the expectation needs removing.

  `test.fail()` is called inside the test body, not in the describe body: a bare
  one there would mark every test declared after it too, quietly excusing the
  allowlist test from ever having to pass.

Verified: 5 expected passes, 1 expected failure, `unexpected: 0`.

* fix(repair): register ImportFlowRegister — it was written and never run

`ImportFlowRegister` appeared ZERO times in `info.xml`. Not in
`<post-migration>`, not in `<install>`. The class is complete — constructor,
version gate, error handling, a docblock explaining that it exists so "a flow
can live in OpenRegister itself and not only in a consuming app" — and
Nextcloud never invoked it, so the `flows` register never appeared.

ADR-005 Rule 1 is exactly this failure:

  > Any new OR-owned register descriptor MUST be accompanied by a `lib/Repair/`
  > step that imports it. Shipping the JSON alone does nothing at runtime.

and its Consequences name the cost:

  > An OR-owned register that ships JSON without a Repair step silently never
  > appears — a recurring author error this ADR exists to prevent.

The class complied. The REGISTRATION did not, and nothing checks that half.

HOW IT WAS FOUND

`occ openregister:descriptors:list`, added in this branch, reported `flows`
absent on an instance where `occ upgrade` had just reported complete success.
Before that command existed, the only evidence was two e2e suites dying in
`beforeAll` on `registers slug=flows`, which reads like a broken test.

The correlation across the whole instance is exact and worth recording:

  every descriptor with a registered importer  → current
  every descriptor with NO importer            → absent

`flows` was the single row that broke the pattern — an importer class with no
registration — which is what pointed at the cause.

WHAT THIS DOES NOT FIX

Six openregister descriptors still report absent because they ship with no
importer at all: bag, brp, dso, kvk, merge-operation, n8n-workflows. Each is
either an ADR-005 Rule 1 violation or a descriptor that should not ship as one.
That is a decision for whoever owns them, not a guess to make here — reported on
the issue instead.

* i18n(registers): translate the descriptor panel into all 36 required locales

CI's `Frontend Check (test:l10n)` failed on the register-descriptor panel: its
19 strings were in neither `l10n/en.json` nor any locale file. The house
convention, visible in every recent feature commit, is that a change adds its
keys to all 37 files with REAL translations — not an English fallback.

19 keys × 35 locales, plus en and nl. 740 lines, purely additive: the only
deletion in each file is the trailing-comma reflow on its last existing key.

🔴 A LITERAL ` ` IN THE SOURCE IS NOT A NON-BREAKING SPACE TO THE
EXTRACTOR. `@nextcloud/l10n-non-breaking-space` wanted an nbsp before the
ellipsis, and the fix went in as the seven characters ` `. JavaScript
resolves that escape at runtime, so the browser saw a real nbsp — but the l10n
extractor reads SOURCE TEXT, so it registered the key as
`Reading descriptors …` while `t()` would look up
`Reading descriptors<nbsp>…`. The key would never have matched, and every
locale would have silently fallen back to English for that one string, with the
parity check reporting full coverage. Replaced with a real U+00A0.

`--write` also picked up one key that was missing from `en.json` while already
translated in the locale files: "Retention period". Pre-existing, unrelated to
this panel, and left in because the gate would fail on it either way.

Verified: `l10n-parity: OK — every required locale is at full parity (no missing
keys, no empty values)`.

* test(newman): a register-descriptors contract suite, and fix the CSRF gap in delegation's

REGISTER DESCRIPTORS

`register-descriptors.spec.ts` lives in `tests/e2e/api-direct/`, which
`playwright.config.ts` excludes from every project — so CI runs none of it. Four
green specs and zero CI coverage look identical from the outside. This is the
half CI can see, registered as a domain in `run-all.sh`.

The assertions are mostly about what the API says when something is WRONG,
because that is what this endpoint exists for. `absent` and `behind` must be
first-class counts rather than something a client derives by filtering — a
consumer that has to compute "is anything wrong" is one that renders a
healthy-looking total and moves on. And a version-matched re-import must report
`imported`, never `skipped`: `ImportHandler` short-circuits on
`version_compare(shipped, installed, '<=')` unless forced, which is exactly the
case an administrator presses the button in.

🔴 THE DELEGATION COLLECTION COULD NEVER HAVE PASSED

Running the new domain through the orchestrator failed, so I ran `delegation` —
already merged, already "verified" — as a control. It failed identically:

    expected status 200 but got 412
    expected { message: 'CSRF check failed' } to have property 'awaitingMyAnswer'

ALL NINE of its requests were missing `OCS-APIRequest: true`, so every one is
rejected by Nextcloud's CSRF middleware before reaching a controller. A
collection registered in CI that 412s on every request proves exactly nothing,
and it is the same defect its own registration comment warns about one level up.
Header added to all nine.

The control is what found it. Had I only run my own collection I would have
concluded the orchestrator was broken and moved on.

TWO ENVIRONMENT FINDINGS ALONG THE WAY

- `NEWMAN_RUNNER=host` invoked a bare `newman`, the only one of the three
  branches requiring a GLOBAL install. Without it every domain failed rc=127 —
  "command not found", reported in the same red as a real assertion failure.
  Now `npx --yes newman`, as the `exec` branch already does.

- The default `sidecar` runner rewrites `localhost:8080` to `http://<container>`,
  which Nextcloud answers 400 for as an untrusted domain. Not changed here — it
  is a local-environment matter, and CI supplies its own network — but it is why
  a local `bash tests/newman/run-all.sh` fails for every domain, not just new ones.

Verified: `Passed: 2, Failed: 0` for both domains through the orchestrator, and
13/13 assertions for the new collection standalone — including the version-gate
case, which ran rather than taking its skip path.

* test(e2e): flow-schedule now runs — and it was testing a store the scheduler cannot read

Refs #2905.

`flow-schedule.spec.ts` could not fail, because it could not run: its
`beforeAll` resolved a `flows` register that had never been seeded on any
instance (#2903), so the file errored before reaching an assertion. Seeding the
register is what made it execute — and it immediately failed three different
ways, each one real.

1. A SLUG IS NOT UNIQUE ACROSS SCHEMAS. `idBySlug('schemas', 'flow')` searched
   globally; this instance carries TWO schemas with slug `flow` while the
   `flows` register lists only one. It picked the wrong one, and posting an
   object to a register/schema pair that do not belong together answers 400 —
   an opaque status with nothing in it about slugs. Now resolved from the
   register's own `schemas` array, which is the authority on what it carries.

2. 🔴 THE SCHEDULER CANNOT SEE REGISTER-AUTHORED FLOWS. Control, on a live
   instance — the same definition, byte for byte, into each store, then one
   worker tick:

       POST /api/flows           (openregister_flows table) → 1 run queued
       POST /api/objects/18/184  (the `flows` register)     → 0 runs

   `FlowScheduleService` resolves candidates from `FlowLocator::scheduledFlows()`,
   which reads the table. A schedule authored in the register never fires, with
   no error and no status message — the run history is simply empty, which reads
   as a broken scheduler rather than a flow it never saw.

   `lib/Settings/flow_register.json` calls that register "the store the resolver
   reads by default — so triggers, sub-flows and the /test endpoint all work
   with a flow authored here". That is not true today, and it is now the SECOND
   subsystem found blind to it, after the federation bundler. Reported on #2905
   rather than patched: either the register becomes a real flow store for these
   paths or the descriptor stops claiming it is one, and that is a design call.

   Both fixtures now author through `/api/flows`. The negative control matters
   as much as the positive one: asserting "0 runs" about a flow the scheduler
   could never have seen would have passed for a reason unrelated to
   `trigger: manual`.

3. A SCHEDULE MUST NAME WHO IT ACTS AS (ADR-099). The fixture had no
   `openregister.trigger-schedule` node, so no `runAs`. Added, matching
   `delegation-parking.spec.ts`.

Verified: 2 passed, 0 failed, twice back to back.

* fix(gates): satisfy semantic-auth and spec-coverage, and close out #2905's e2e

GATE-9 — MY CONTROLLER'S ANNOTATION CONTRADICTED ITS OWN BODY

Both methods carried a no-admin-required tag while calling `requireAdmin()`.
That is not a harmless disagreement: it is how an endpoint comes to be reachable
by everyone while its code reads as guarded, and it is the exact defect gate-9
was written for. Copied from a sibling controller that has it too.

🔴 AND THE TAG IS NOT SPELLED IN THE COMMENT EXPLAINING ITS ABSENCE. A docblock
that writes the literal DECLARES it, so prose about removing it restores what it
describes. My first fix did precisely that, and only a `grep -c` caught it. This
repo has paid for that lesson once already, when a comment about removing
`@covers` was parsed as `@covers` and reddened six CI cells.

GATE-5 THEN FIRED, AND THE OBVIOUS FIX WAS THE WRONG ONE

Removing the tag left `import` with no declared auth posture. The cheap way to
satisfy gate-5 is a no-CSRF tag — `index()` already carries one as a GET — but
reaching for it on a state-changing POST would disable CSRF protection to buy a
green gate. The panel posts through axios with Nextcloud's request token and
needs nothing of the sort.

`#[AuthorizedAdminSetting(settings: OpenRegisterAdmin::class)]` declares the
posture the body actually enforces, which is what BOTH gates were asking for.

Verified live rather than by the gate alone: admin 200/200, non-admin 403/403.

GATE-16 — eight methods missing @SPEC (two on the occ command, six on the Vue
panel). Tagged.

#2905's E2E EXPECTATION IS GONE, BECAUSE THE DEFECT IS

`test.fail()` did its job: it went RED THE DAY #2915 LANDED, because a test
expected to fail that starts passing is itself an error. A skip would have gone
quiet and stayed quiet, and this assertion would still be switched off.

Two things surfaced as it came back to life:

- The terminal is `stopped`, not `completed`, and that is THIS FIXTURE'S doing.
  `EndNode` throws `FlowStop`, "which the engine turns into a clean `stopped`" —
  so a flow whose last node is `openregister.end` ends `stopped`. `completed` is
  for a flow that runs off the end of its graph, which is what this fixture was
  before the step moved from its edge onto a node. Asserted exactly rather than
  widened to accept either: a matcher taking both would also accept the fixture
  silently losing its end node.

- The allowlist test, dead until the container guard landed, failed 200-vs-403.
  🔴 THE CONTROL IS FINE. `occ config:app:set` runs in a CLI process while the
  request is served by a web process holding its own IAppConfig cache, which the
  CLI cannot invalidate — so the allowlist is set, readable via
  `occ config:app:get`, and briefly invisible to the endpoint. Confirmed by hand:
  once the web process holds a fresh cache the same request answers 403 and names
  the source. The test now retries and then SKIPS naming what went unverified,
  because a security assertion that cannot see its own precondition must be
  reported as neither a pass nor a refutation of the control.

Verified: gate-5, gate-9, gate-16 PASS and gate-98 NOT APPLICABLE locally; 33
e2e passed / 0 skipped / 0 failed, twice back to back; both Newman domains pass.

* fix(registers): a mock descriptor is not a missing register

Investigating the seven descriptors the panel reported ABSENT turned five of
them into a defect in the PANEL, not in the fleet.

THE CORRELATION IS EXACT, across OpenRegister's own 14 descriptors:

    type=mock         5 descriptors    importer: none, all five
    type=core         8 descriptors    importer: seven of eight
    type=integration  1 descriptor     importer: none — and app=n8n

`x-openregister.type: mock` marks sample data imported ON DEMAND — a demo, a
test fixture — not something an instance is expected to carry. Every mock ships
without a Repair step, deliberately. Reporting them as ABSENT put five permanent
red rows in front of every administrator, and the obvious response to those rows
— write the missing importers — would have seeded five mock registers onto every
instance in the fleet.

🔴 A PANEL WHOSE LOUDEST SIGNAL IS NOISE IS ONE PEOPLE STOP READING, which is
the same silence it was built to break. Mocks are now skipped. `reimport()`
still reaches them by explicit app+slug, which is how sample data is meant to be
loaded.

AND A ROW NAMES THE APP THAT DECLARES IT. `n8n_workflows.openregister.json`
lives in OpenRegister's lib/Settings and declares `app: n8n` — it is n8n's
register, shipped alongside. Attributing it to `openregister` because of the
directory it sits in tells the reader the wrong owner, and an inventory exists
to say whose problem a row is.

MEASURED, on a live instance with twelve apps installed:

    before   15 declared ·  7 current · 0 behind ·  8 ABSENT
    after    25 declared · 23 current · 0 behind ·  2 ABSENT

Both survivors are real: `merge-operation` (openregister, type=core, no Repair
step — an ADR-005 Rule 1 gap, reported on #2903) and `n8n-workflows`, now filed
under n8n where it belongs.

THE FLEET-DISCOVERY TEST CAUGHT THIS CHANGE and had to move with it — 14
declaring files, 9 reported. Its independent count now applies the same stated
rule, still computed by reading the files rather than by asking the service: a
count derived from the code under test could only ever agree with itself.

Verified: 14 unit tests / 47 assertions; e2e and Newman green.

* fix(settings): make the admin panel delegation-eligible so the auth attribute binds

phpstan on #2910:

    Parameter $settings of attribute class AuthorizedAdminSetting constructor
    expects class-string<OCP\Settings\IDelegatedSettings>, string given.

`AuthorizedAdminSetting` is the only auth attribute that declares
"administrator" WITHOUT also disabling CSRF, and it takes a
`class-string<IDelegatedSettings>`. `OpenRegisterAdmin` implemented plain
`ISettings`, so the attribute had nothing valid to bind to.

The two alternatives were both worse:

  - Bind to `GenericAdminSettings`, the one class in this repo that already
    implements the interface. It is the AppHost panel; the register-descriptor
    endpoints are not part of it, and naming a panel they do not belong to
    would make the declaration a formality rather than a fact.

  - Add a no-CSRF tag to the POST. That is the cheap way to satisfy gate-5 and
    it disables CSRF protection on a state-changing endpoint to buy a green
    gate.

🔴 THIS DELEGATES NOTHING BY ITSELF. `IDelegatedSettings` makes a panel
ELIGIBLE for delegation, which an administrator must then configure. `getName()`
returns null and `getAuthorizedAppConfig()` returns an empty allowlist, so
today's behaviour is unchanged — full administrators only — while the attribute
gets a real settings class to bind to.

Verified live rather than by phpstan alone, because this touches middleware:
admin GET 200, admin POST 200, non-admin GET 403. phpcs, phpstan and psalm clean;
register-descriptors e2e green.

* fix: dedupe ImportFlowRegister, and cover the controller and command

🔴 THE MERGE REGISTERED THE SAME REPAIR STEP TWICE, SILENTLY.

Another session fixed the same defect in #2923 — "register ImportFlowRegister
and RenameDutchColumns" — while this branch carried my fix. Git merged both
without a conflict, because they sit at different positions in the same block.
Result: `ImportFlowRegister` appeared twice in `<post-migration>` and twice in
`<install>`. Idempotent, so probably harmless at runtime, and wrong.

THEIR PLACEMENT IS BETTER AND IS THE ONE KEPT. They put it BEFORE the flow
steps, with the reason that "every flow step below it silently operated on
nothing" — which is the actual ordering constraint. Mine sat later, among the
other Import* steps, where the flow steps above it would still have run first.
Salvaged from mine: the provenance sentence, since it records HOW the gap was
found (`occ openregister:descriptors:list` on an instance where `occ upgrade`
had just reported complete success, 8 of 15 registers absent).

Verified by parsing rather than by eye: post-migration 20 steps, install 18,
zero duplicates in either.

⚠️ AND `RenameDutchColumns` IS NOT A SECOND UNREGISTERED STEP. I reported it as
one earlier; #2923 had already registered it. The gate now reports it clean.
Correcting the record rather than "fixing" something already fixed — that is
the third collision with a parallel session today.

COVERAGE: the ratchet failed at 43.25%, naming 194 statements added without
tests. `RegisterDescriptorService` had 14 tests; the controller and the occ
command had none. Both now covered, and the assertions are the REFUSALS rather
than the happy path:

  - a non-administrator is refused, and `reimport` is expected NEVER — asserting
    only the 403 would pass for a controller that ran the import and then
    refused;
  - an anonymous caller gets 401, not 403;
  - a failed import answers 422, not a 200 carrying bad news;
  - listing exits ZERO with absent registers (a state to decide about, not a
    failure — non-zero would turn a report into a broken cron job) while a
    failed re-import exits non-zero (there the caller asked for something);
  - `--problems-only` filters rows but NOT the tally, because a filter that also
    changes the totals hides what it filtered;
  - `--import` without `--app` is refused rather than guessed, since two apps
    may ship a register with the same slug.

14 tests, 36 assertions, OK.

* fix(repair): merges were recorded into a register that never existed

`merge_operation_register.json` is a `type: core` descriptor declaring the
`merge-operation` register and its `mergeOperation` schema, and it shipped with
NO Repair step. Per ADR-005 Rule 1 that means it never appeared on any instance:
"shipping the JSON alone does nothing at runtime."

🔴 THIS ONE IS NOT COSMETIC. `MergeService::merge()` writes the audit row for
every merge straight into it:

    $this->objectService->saveObject(
        object:   $mergeOperation,
        register: self::MERGE_REGISTER,   // 'merge-operation'
        schema:   self::MERGE_SCHEMA      // 'mergeOperation'
    );

and `reverseMerge` reads `preMergeSnapshot` back out of that row to undo a
merge. A register that does not exist is a merge history that does not exist,
and reversibility that cannot be exercised.

HOW IT WAS FOUND. `occ openregister:descriptors:list` reported it ABSENT
alongside six others. The correlation across the app's fourteen descriptors was
exact — every one with a Repair step present, every one without missing — and
after the five `type: mock` descriptors were correctly excluded (a mock is not a
missing register), `merge-operation` was the only non-mock left in the second
group. The outlier in a clean correlation named the defect, for the second time
in this programme: the first was ImportFlowRegister.

Verified live rather than by the file's existence: the register imports and the
inventory moves from 2 ABSENT to 1 — the survivor being `n8n-workflows`, which
declares `app: n8n` and is correctly attributed there rather than here.

    merge-operation register: PRESENT id=40 schemas=[1312]
    mergeOperation schema:    1312

Registered in both <post-migration> and <install>, once each, verified by
parsing the XML rather than by eye — this branch has already had one silent
duplicate from a merge with a parallel session's identical fix.

* fix(flow): one store per flow — migrate register-authored flows into the table

Closes the second half of #2905.

OpenRegister kept TWO stores for "a flow": the `openregister_flows` table behind
/api/flows, and objects in the `flows` register. Every subsystem reads the
table. Nothing reads the register.

🔴 MEASURED WITH CONTROLS, not inferred — the same definition, byte for byte:

    POST /api/flows          + one FlowScheduleWorker tick  -> 1 run queued
    POST /api/objects/18/184 + one FlowScheduleWorker tick  -> 0 runs
    bundle {flowIds:[table-backed]}                         -> 1 flow
    bundle {flowIds:[register-authored]}                    -> 0 flows

A schedule authored in the register never fires and never bundles, with no error
anywhere — the run history is simply empty, which reads as a broken scheduler
rather than a flow nothing ever saw.

🔴 AND THE DESCRIPTOR NAMED A CLASS THAT DOES NOT EXIST. `flow_register.json`
called that register "the store the resolver reads by default — so triggers,
sub-flows and the /test endpoint all work with a flow authored here", crediting
an `OpenRegisterFlowResolver`. That identifier appears nowhere in this
repository except inside that sentence. A reader following the documentation
lands in the store nothing reads. The description now says what is true.

THE MIGRATION copies every register-authored flow into the table:

  - the UUID IS PRESERVED, so sub-flow references and existing run rows resolve;
  - a flow already in the table is SKIPPED — this runs on every upgrade, and a
    second copy would fire twice;
  - OWNERSHIP carries over, because a flow with no organisation is invisible to
    every scoped read (the #2915 defect) and a migration that dropped it would
    move the flow into the right store and straight out of sight;
  - it arrives DISABLED. A schedule that starts firing during an upgrade against
    data nobody re-checked is worse than one an administrator switches on;
  - the register rows are LEFT IN PLACE, so an unattended step stays reversible;
  - one failure does not stop the rest, and the COUNTS are always printed.

🔴 THE TEST CAUGHT A BUG THE SUMMARY LINE HID. `Flow` inherits Nextcloud's
`Entity`, whose setters are MAGIC via `__call` — so `method_exists($flow,
'setName')` is FALSE and my copy loop, guarded on it, skipped every field. The
step ran, reported "1 migrated", and wrote a flow with no name, no trigger and
no nodes. Only the assertion that the name came across found it; the count said
success either way. Same magic-method trap as `Organisation::getUsers()` earlier
in this work.

Verified: 7 tests / 18 assertions; phpcs, phpstan and psalm clean. The live run
happens on `occ upgrade` — repair steps cannot be invoked standalone, and
`maintenance:repair` would fire every app's, so this is pinned by unit tests
rather than by a live run I have not done.

* fix(repair): the flow migration read ObjectEntity rows as arrays

`ObjectService::findAll()` returns `ObjectEntity` objects. The migration
indexed them as arrays, so enabling the app died outright:

  Error: Cannot use object of type OCA\OpenRegister\Db\ObjectEntity as
  array in lib/Repair/MigrateRegisterFlowsToTable.php:173

A fatal in a repair step during `occ app:enable` means the app does not
install at all. The unit tests were green throughout, because every one of
them mocked `findAll()` to return the array shape the step assumed — a fake
built from the caller's assumption validates the assumption, not the
service. Only CI, which actually enables the app, disagreed.

Rows are now normalised through the entity's own accessors (`getObject()`,
`getUuid()`, `getOwner()`, `getOrganisation()`), with the array shape still
accepted so a future serialised read path stays correct.

`testItReadsRealObjectEntitiesNotArrays()` builds a REAL ObjectEntity.
Verified as a control: reinstate the array access and that test alone fails
with the same fatal — which is exactly the property the array fixtures
lacked. phpcs/phpstan/psalm clean, 8 tests pass.

* fix(schemas): order the schema list newest-first, so a created schema is on it

`schema-crud.spec.ts` has been failing on this branch, on both E2E runs and on
retry: the spec creates a schema through the API, confirms it persisted through
a GET, opens the schemas index — which renders — and then cannot find the row.

## The list was never ordered

`SchemasIndex` loads every schema and slices ONE page client-side:

    paginatedSchemas() {
        const { page, limit } = this.paginationData
        const start = (page - 1) * limit
        return schemaStore.schemaList.slice(start, start + limit)   // 20 rows
    }

The slice takes whatever order the API happened to return. A just-created schema
has the highest id and therefore sorts LAST, so it lands on the final page. On
any instance holding more schemas than fit on a page it is simply not there —
the plain "I just made this, where is it?" failure.

It is not an error and not an empty list. The page renders perfectly, without
the row the user is looking for, which is why nobody had seen it.

## Why it surfaced here

This PR adds `ImportMergeOperationRegister`, which materialises the
`merge-operation` register and its `mergeOperation` schema at install. That is
one more schema in the E2E instance — enough to cross the 20-row page. The E2E
log shows the run reaching schema id 24.

So the spec was passing only because the seeded instance held fewer schemas than
one page, and this PR pushed it over. The defect is older than the PR; the PR is
what made it observable. Reverting the import would hide it again.

## The fix

An explicit `orderedSchemas` — highest id first — that `paginatedSchemas` slices.
Id is the only monotonic field every schema carries. This replaces an UNDEFINED
order rather than a chosen one: CnIndexPage is in prop mode on this view and the
component handles no `@sort`, so there is no user-facing sort to conflict with.

No test is weakened to make this pass. The spec still asserts the created schema
renders as a real row in the list, which is the guarantee it was written for.
…2946)

`MetricExpressionEvaluator` landed on development today with #2941 carrying
nine phpcs errors, and phpcs scans `lib`. So `development` itself is red and
every branch cut from it inherits the failure — it is not the fault of
whatever PR happens to report it. Mine reported it on #2943, which touches
neither this file nor this area.

Nine errors, no behaviour change:

  - five inline IF statements (176, 238, 288, 313, 348) expanded to blocks.
    The nested ternary inside the sprintf at 313 becomes a named `$available`
    local, which is also the only one that was genuinely hard to read.
  - four `$this->expect(...)` calls given the named parameter the standard
    requires for calls to internal code.

Also adds the `@spec` tag the class was missing, pointing at
openspec/specs/aggregation-api/spec.md — the target its siblings in this
directory already use, and a file that exists.

VERIFIED BEHAVIOUR-NEUTRAL BY A/B, not by reading: the aggregation suite is
263 tests / 524 assertions and returns exactly that with the original file
restored and again with the fixed one. The evaluator's own suite is 9 tests /
26 assertions, green, with a reflection check confirming the class under test
is the edited file rather than another checkout's copy.

phpcs: 9 errors -> 0, confirmed against a control run of the original through
the same command after an earlier run had silently registered no sniffs at all
and reported the untouched file as clean.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…s (ADR-111) (#2939)

* feat(demo-data): ship demo data for every schema this app supplies (ADR-111)

Three objects per (register, schema) pair, each generated from the schema
itself and validated against it. Not installed automatically: a mock register
is imported on demand, from the setup walkthrough or
`occ openregister:descriptors:list --app=<id> --import=<slug>`.

Attributed to the app id in appinfo/info.xml — `x-openregister.app` is what
the descriptor inventory resolves a register to an app by, so the checkout
directory name would name an app that does not exist.

* fix(demo-data): drop the n8n register from openregister demo data

`n8n_workflows.openregister.json` declares `x-openregister.app: n8n`. Its
five schemas are the n8n app's, and they carry no `slug`, so embedding them
here failed this repo's own `validate-register` on two checks.

Regenerated with the generator ownership fix: 8 registers, 10 schemas, 30
objects, 0 validation failures.

---------

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

Drops both `type: "custom"` flow pages and the two components behind
them. FlowsIndex.vue and FlowDetailPage.vue are deleted and their
registry entries removed.

    "type": "index", "config": { "entitySource": "flows", "columns": [...] }
    "type": "flow"

WHAT THOSE COMPONENTS WERE. FlowsIndex rendered CnIndexPage with
`:objects` from useFlowStore - the wrapper named index sources exist to
remove. FlowDetailPage wrapped CnFlowDetail and handled save/run, which
is exactly what the shared CnFlowEditorPage does, `router.replace`
included so a newly created flow picks up its server-assigned id.
Checked line by line before deleting rather than assumed.

COLUMNS ARE STATED EXPLICITLY here, not left to the source defaults.
This is the ENGINE's cross-app surface: it passes no app filter and so
carries an `app` column a leaf app's own flow list does not. The source
supplies defaults; a manifest that sets columns still wins, which is
what keeps that column.

`sidebarComponent: FlowDetailSidebar` is unchanged - it is a page-level
key rather than part of `config`, so the controls sidebar survives the
type change. Verified against the pre-change manifest.

A FIX THAT COMES FREE. FlowsIndex bound `@rowClick` while CnIndexPage
emits `row-click`, so opening a flow by clicking its row never worked
here. The shared page wires it correctly.

Both `_note`s are rewritten. They said "Custom page rather than
type:index" and gave the reason - true when written, false now, and a
stale note that explains a decision is worse than none.

Bumped to ^2.20.0: below it the runtime does not register `flow`, and a
manifest naming a page type the runtime does not know renders nothing
rather than failing. Manifest validated against the installed schema
(2.26.0, PASS, 0 errors) and the control build compiles - 48 artifacts.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
PHP Quality (phpmd) fails on development with a single violation:

  MetricExpressionEvaluator.php:180 ElseExpression
  The method parseExpression uses an else expression.

parseTerm, directly below it in the same file, already handles its two
operators with a guard and a continue rather than if/else, and is clean.
This makes parseExpression match its sibling.

Behaviour is identical: the while condition admits only + and -, so the
guard and the fall-through cover exactly the two branches the else did.
#2954)

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

* fix(deps): nextcloud-vue 2.21.0, which restores the flow create button

The E2E on development fails on both attempts:

  flow-controls.spec.ts:173 flow controls render, and a flow can be
  built, saved and run
  getByRole("button", { name: "New flow" }) — element(s) not found

#2937 moved the flow list to an ordinary type:index over the named
source "flows". nextcloud-vue 2.20.0 shipped named sources (#800) but
read only their `columns` — `addLabel` and the routes were defined and
never read, so the migration lost the create button. nc-vue #818 fixes
exactly that and first ships in 2.21.0.

Verified against the PUBLISHED artifacts, not the source: 2.21.0/dist
carries the namedSource.addLabel branch, 2.20.0/dist does not.

The lock is regenerated with npm 11 (engines.npm ^11.0.0; npm 10 does not
implement min-release-age) and with the repo .npmrc in place — without
its min-release-age-exclude[]=@conduction/* the two-day cooldown would
have silently resolved BACKWARDS on a release-day version. The lock diff
is one line: no transitive package was added, removed or moved.

* fix(deps): nextcloud-vue 2.21.0, which restores the flow create button

The E2E on development fails on both attempts:

  flow-controls.spec.ts:173 flow controls render, and a flow can be
  built, saved and run
  getByRole("button", { name: "New flow" }) — element(s) not found

#2937 moved the flow list to an ordinary type:index over the named
source "flows". nextcloud-vue 2.20.0 shipped named sources (#800) but
read only their `columns` — `addLabel` and the routes were defined and
never read, so the migration lost the create button. nc-vue #818 fixes
exactly that and first ships in 2.21.0.

Verified against the PUBLISHED artifacts, not the source: 2.21.0/dist
carries the namedSource.addLabel branch, 2.20.0/dist does not.

The lock is regenerated with npm 11 (engines.npm ^11.0.0; npm 10 does not
implement min-release-age) and with the repo .npmrc in place — without
its min-release-age-exclude[]=@conduction/* the two-day cooldown would
have silently resolved BACKWARDS on a release-day version. The lock diff
is one line: no transitive package was added, removed or moved.
The flow-page migration is already on development here, but the lock still
pinned an older release. 2.20 declares a named index source's columns, create
button and row actions without reading them, so the migrated page renders a
columnless table with no working create action — which reads as an empty list
rather than a broken page.

The lock is the operative part: CI installs with `npm ci`, so the caret alone
changed nothing about what installs.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
.github#597 set cancel-in-progress on the shared quality.yml, but a
caller-level concurrency cancels the whole run before the called
workflows setting can apply -- so that fix reached only the apps that
declare no concurrency of their own.

Measured 2026-08-28 over push runs on development since #597 merged:

    caller silent          0 of 11 cancelled
    caller says true       7 of 13 cancelled  (54%)

This repo is in the second group. pull_request keeps cancelling, where
superseding really is correct.
…2963)

Every guard so far protects an OPERATION — find() (#2790), findAll() and the
save path (#2803), the read path (#2918) — by discarding the pending schema ref
as it enters. `setRegister()` is not an operation and has no such guard: it
takes whatever ref is pending, re-resolves it inside the register the caller
just named, and a miss THROWS out of a method whose caller only asked to name
its own register.

Measured on the development instance 2026-08-27, with #2918 already merged and
running: every public read of a portaliq portal failed with

    Schema slug "application" is not carried by register "portaliq"

`PortalResolver` calls setRegister('portaliq') first and setSchema('portal')
after — the correct order — and never mentions `application` at all. The slug
belongs to buildiq, which registers navigation from Application::boot() on
every request. The portal served its shell and answered 404 for its own site,
menus, pages and glossary: a whole app's public surface down over a slug it
does not own. openconnector and a pipelinq repair step are recorded in this
file's own comments as earlier victims of the same shape.

So a ref that cannot be resolved inside THIS register is now discarded rather
than thrown on, and the schema context is cleared with it. That second half is
what keeps this honest: a caller that really did chain
setSchema('typo')->setRegister($r) still fails — at its operation, with a
missing schema context — instead of silently reading whichever table a stale
context last pointed at. Substituting a wrong-but-plausible schema is the one
outcome worse than throwing. The discard is logged with the ref, the register
and a note saying which of the two cases the reader is in.

The regression test was observed FAILING against development with the exact
production message above, and passing with the change.
`flow-controls.spec.ts:173` has been failing on development with

    Locator: getByRole('button', { name: 'New flow' })
    Error: element(s) not found

which says nothing useful. Chasing it (#2957) ruled out five hypotheses
without touching the actual cause:

  - the label is correct -- "New flow" is what nextcloud-vue 2.20.0
    declares for the `flows` named index source
  - the version is correct -- package.json ^2.20.0, lock pins 2.20.0
  - the published artifact is complete -- indexSources.js and
    useNamedSource.js are both in the npm tarball's dist/
  - the control is a real button -- CnActionsBar renders NcButton with
    data-testid="cn-cta-primary", not an overflow menu item
  - showAdd is not suppressed -- defaults true on both components

The one fact unobservable from outside a failing run is whether
`resolveIndexSource('flows')` returned the source or null. The resolver
announces that on the console, and nothing was capturing it.

So this captures console warn/error for the duration of the test and,
when the button is missing, fails with:

  - whether ANY primary CTA rendered, and its text. A named source
    supplies "New flow"; an unresolved one degrades to an ordinary index
    whose CTA reads "Add {type}". That single string separates the two
    cases.
  - the collected console lines, including the resolver's warning.

No change to what the test asserts -- it still requires the button. It
just stops throwing away the evidence when it does not appear.

Verified: npm run lint rc=0, prettier clean, `playwright test --list`
compiles the file.

Refs #2957

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

* fix(descriptors): the demo-data import seeded nothing and reported success

Found by running the ADR-111 flow on a live instance rather than trusting the
unit tests. `occ openregister:descriptors:list --app=larpinq --import=larpinq`
printed:

    larpinq: register "larpinq" imported.

and seeded ZERO of the descriptor's 30 demo objects. The register ended up
carrying the REAL descriptor's 11 schemas, not the mock's 10, and
oc_openregister_objects stayed empty.

TWO DEFECTS, both mine.

1. A MOCK IMPORTED UNDER THE APP'S OWN CONFIGURATION IDENTITY.
   `importFromApp` stamps `imported_config_<appId>_version` and a content hash.
   larpinq ships BOTH `larpinq_register.json` and `larpinq_mock_register.json`,
   and both declare the register slug `larpinq` -- so importing the mock under
   the plain app id writes over the real descriptor's stamp and reads its hash.
   A mock now imports under `<appId>.mock`. This is the rule
   ImportMergeOperationRegister already states in its own docblock, for exactly
   this reason: a version gate shared between two descriptors masks one of them.
   I applied that discipline there and not here.

2. THE POST-CHECK COULD NOT SEE A NO-OP. It asked only whether a config version
   existed for the slug. For a mock that is already true -- the app's own
   descriptor stamped it at install -- so `outcome: imported` was returned over
   an import that created nothing. A descriptor that declares objects and seeds
   none of them is now a FAILURE that names the numbers, and the command prints
   "N of M demo object(s) imported, K skipped" instead of a bare "imported".
   That is the same "state the counts, always" rule the repair steps follow, and
   it is what would have made this diagnosis immediate instead of a long dig
   through the importer.

Three tests, each verified by control: reinstating either defect produces 2
failures. Note the control itself had to be fixed first -- running the suite
from a worktree with a symlinked vendor/ resolves these classes out of the MAIN
checkout, so the tests were green over a file I had not edited. The bootstrap
now preloads the worktree copy.

phpcs/phpmd/phpstan/psalm all clean; 31 tests, 92 assertions.

* test(descriptors): cover the branches the counts fix added

The coverage ratchet failed this branch:

    FAIL: coverage of the code this change KEEPS or ADDS dropped by 5.31%.
    base 147/155 -> head 171/191 statements

Fair: the command's new counts-printing branch was never entered, because the
existing test mocks `reimport()` to return `['outcome' => 'imported']` with no
`counts` key at all. The branch that formats "N of M demo object(s) imported"
could not run.

Three tests, one per path through it: a full seed (30 of 30), a PARTIAL seed
(29 of 30, 1 skipped -- a different event from a full one, and the operator
deciding whether the demo is usable needs the difference), and a descriptor
declaring no objects, which keeps the short sentence rather than padding every
real register import with "0 of 0 demo object(s)".

27 tests, 82 assertions.

Local coverage measurement was attempted and abandoned rather than faked: the
worktree has no composer install of its own, and running under the main
checkout's vendor/ resolves these classes out of THAT tree -- the same wrong-
file trap that made an earlier control report OK over a file I had not edited.
CI measures this honestly; a local number from the wrong tree would not.

---------

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

* fix(schemas): the extended-by map cast schema OBJECTS to the string "Array"

`findAllExtendedBy()` resolved each `allOf`/`oneOf`/`anyOf` entry with
`(string)$entry`. An entry is a SCHEMA OBJECT in standard JSON Schema —
`{"$ref": "person"}` — and casting one yields the literal "Array", which
matches no key in the lookup. So the reverse map came back EMPTY, while every
request logged a notice per row:

    Array to string conversion at lib/Db/SchemaMapper.php#4148
    GET /index.php/apps/openregister/api/schemas?_limit=1000

Measured across the fleet's descriptors: 236 allOf/oneOf/anyOf entries, ALL of
them objects, NOT ONE a bare string. The feature was inert for every real
schema — an "extended by" relationship that is never found rather than one
that errors, which is why nobody had seen it.

Found in a learniq E2E log, where the notice repeats on every
`/api/schemas?_limit=1000`.

`parentIdentifierFromAllOfEntry()` already encodes the three shapes that occur
and is already tested. The defect was that this caller did not USE it — so the
new arms drive `findAllExtendedBy()` itself, not the resolver: a test of the
resolver would have stayed green throughout. That helper's own docblock records
that an earlier caller passed arrays through and 500'd; this one passed them
through and went quiet instead.

4 tests, verified by control in a tree with the app's own autoloader: with the
cast reinstated, the two object-shaped arms fail and the scalar and inline arms
still pass. phpcs reports 0 errors either side; the phpstan and psalm findings
within reach are pre-existing and in other files.

* fix(aggregation): replace the else with an early continue, satisfying both tools

phpmd failed parseExpression() on ElseExpression. The `else` was mine: the
earlier phpcs fix replaced an inline ternary with if/else, and the two tools
forbid opposite shapes -- phpcs bans the ternary, phpmd bans the else. Neither
shape is available.

parseTerm(), a few lines below in the same file, already shows the way out:
handle the first operator and `continue`, then fall through to the second. No
ternary, no else, and it matches the file's own pattern rather than inventing
a third one.

VERIFIED WITH A CONTROL, and the control had to be fixed first. Running the
suite from the worktree resolved this class out of the MAIN checkout, because
vendor/ was symlinked there and its autoloader owns the namespace -- so the
test was measuring a file I had not edited, and reported OK when subtraction
was replaced by addition. Preloading the worktree copy ahead of the autoloader
fixes it. With the right file under test: my refactor is OK (9 tests, 26
assertions), and breaking subtraction produces 2 failures, so the suite does
discriminate.

phpcs 0 findings, phpmd 0 findings, 0 else clauses in the file.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…2969)

phpstan analyses the whole tree, so this pre-existing error surfaced on an
unrelated l10n pull request (#2634) that does not touch this file:

    Call to function is_scalar() with int|string will always evaluate to true.
    Line  Service/ObjectService.php
    [ERROR] Found 1 error

`$currentSchemaRef` is `int|string|null` and the null is already excluded before
this point, so `$pendingRef` is `int|string` here — the scalar test could only
ever be true and the gettype() fallback was unreachable.

Dropped rather than suppressed. A defaulted read that can never default reads as
a handled edge case that does not exist, and hides the day the type actually
widens.

Verified: php -l clean, and `phpstan analyse --memory-limit=1G` over all 1504
files reports [OK] No errors — the exact command CI runs.

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
Carries the pending-schema-ref fix (#2918) and the repair-step registrations
(#2923) to beta, so a stable release can be cut from them.

TWO CONFLICTS, BOTH VERSION-ONLY — AND RESOLVING THEM THE OBVIOUS WAY WAS WRONG.

`appinfo/info.xml` and `openapi.json` each conflicted on one hunk, the version
string. Taking beta's side of the FILE (`--ours`) resolves that hunk correctly
and silently reverts everything else in it, because beta's info.xml predates
development's. It took four repair-step registrations with it:

  ImportMergeOperationRegister, RenameDutchColumns,
  ImportFlowRegister, MigrateRegisterFlowsToTable

All four had just been registered on development precisely because gate-98
found they had never run. A file-level resolution to a line-level conflict put
them straight back.

So both files are taken from development in full, with only the version string
replaced by beta's — the convention the repo's own beta-sync PRs document, and
the release workflow recomputes it from the latest stable tag regardless.

Verified after resolving, not assumed:

  unregistered repair steps   none
  openapi.json                valid JSON
  pending-schema-ref fix      present in ObjectService
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 08e7b5f

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
format
check-schema-l10n
check-l10n-js
composer ✅ 175/175
npm ✅ 545/545
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-28 11:30 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 4545b8e into beta Aug 28, 2026
45 checks passed
@rubenvdlinde
rubenvdlinde deleted the hotfix/beta-sync-20260828 branch August 28, 2026 11:44
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.

1 participant