Skip to content

Release: merge development into beta - #1271

Merged
rubenvdlinde merged 67 commits into
betafrom
development
Aug 30, 2026
Merged

Release: merge development into beta#1271
rubenvdlinde merged 67 commits into
betafrom
development

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

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

Merging this PR will trigger the beta release workflow.

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

This page already handles the failure well: `loadCatalogue()` stores the
message and the body renders an error banner instead of the report list.

The KPI tile above it did not take part. It binds `:count="reports.length"`,
which is 0 when the load failed, so the page showed

    [ 0 report types ]
    Failed to load the report catalogue

— a confident zero directly above the sentence explaining that nothing was
read. Of the two, the number is the one a reader takes at face value.

One line. `CnStatsBlock`'s `error` prop (nextcloud-vue 2.19.0) replaces the
count with a dash and "Unavailable", so the tile and the banner now say the
same thing.

Smaller than the sibling fixes in decidiq and pipelinq, where nothing surfaced
the failure at all — here it was only the tile disagreeing with the page.

Verified against what is installed, not what the library repo holds:
`package.json` ranges `^2.19.0`, `node_modules` holds 2.19.0, and that copy
contains `hasError()`. eslint and prettier pass.
…1269)

First live-checked batch of #1261. Three of the seven segment-P&L
aggregations translate cleanly; four cannot be translated at all.

TRANSLATED — GLLine.byCostCenter / byCostObject / byProject

Each declared `source: "GLLine"` on GLLine itself. That key is inert:
AggregationRunner reads only field/filter/from/groupBy/join/metric/
metrics/select/where. It is emphatically NOT the engine's `from`, which
switches the runner into its cross-schema path and needs a parent row —
translating `source` to `from` would have changed the execution mode, not
the spelling. Since source == the declaring schema, dropping it is the
whole change, plus `sum: ["amount"]` -> `metric: "sum", field: "amount"`.

VERIFIED AGAINST THE ROWS, not for a non-empty response. Seeded two GLLine
rows carrying codes and compared with the underlying data:

  byCostCenter  CC-001=5000   CC-002=10000   null=24200
  byCostObject  KD-001=5000   KD-002=10000   null=24200
  byProject     PRJ-001=5000  PRJ-002=10000  null=24200

24200 is exactly 12100 + 10000 + 2100, the three pre-existing rows that
carry no codes. Sums and grouping both correct, and the null bucket proves
the grouping discriminates rather than collapsing everything.

NOT TRANSLATED — these need ENGINE FEATURES, not a rewrite:

  byCostCenterHierarchy   groups by `AnalyticalDimension.parentCode`, a
                          field on the JOINED schema
  byAnalyticalDimension   groups by the wildcard `dimensions.*`
  segmentPnl              conditional metrics — `sum WHERE side='debit'`.
  segmentPnlByCostObject  Checked: the engine has no `condition` concept
                          anywhere in lib/Service/Aggregation.
  spentToDate             `recursiveOver` plus an OR expression inside a
                          filter VALUE

That is the useful finding from this batch: part of #1261 is not badly
written declarations but capabilities OpenRegister does not have. They
need engine work or an accepted PHP service, and no amount of renaming
will help.

Ratchet 219 -> 216. The fragment test now pins which two are translated
and which two deliberately are not, so the gap stays visible.

Verified: 4980 tests / 46766 assertions, 0 failures; 6 JS validators;
prettier; the new groupBy gate still passes.
await leaf.click()

await expect(page).toHaveURL(
new RegExp(`${REQUISITIONS_ROUTE.replace(/\//g, '\\/')}$`),
await entry.locator('a.app-navigation-entry-link').first().click()

await expect(page).toHaveURL(
new RegExp(`${OVERVIEW_ROUTE.replace(/\//g, '\\/')}$`),
await openLink.click()

await expect(page).toHaveURL(
new RegExp(`${SAMPLE_VIEW_CARD.route.replace(/\//g, '\\/')}$`),
await link.click()

await expect(page).toHaveURL(
new RegExp(`${GENERATED_ROUTE.replace(/\//g, '\\/')}$`),
The largest remaining gap in the fleet. The manifest is data the renderer
walks, not source the l10n extractor scans, so nav labels, page titles,
descriptions, setup-wizard copy and the getting-started tour all looked up keys
that were never in the catalogue. A missing key falls back to the English
source, so a Dutch user read English across nearly the whole app.

2,213 strings, spanning general ledger, AR/AP, VAT, payroll, treasury,
consolidation, IFRS 15/16, pensions (IAS 19), inventory, EU funds and the
Dutch public-sector reporting chain.

This is regulated-domain Dutch. The terms are the ones the profession and the
statutes use, not literal translations:

  Aging bucket          -> ouderdomscategorie
  Dunning ladder        -> aanmaningstrap
  3-way match           -> driewegmatch
  Deferred participants -> slapers
  DBO                   -> pensioenverplichting
  DTA / DTL             -> actieve / passieve belastinglatentie
  Fair presentation     -> getrouwheid
  Lawfulness            -> rechtmatigheid
  Provisions            -> voorzieningen
  Contingent liabilities-> niet in de balans opgenomen verplichtingen
  Beslagvrije voet, cesuur, dotatie, aanwending: kept as-is

Statutory names are left exactly as the law writes them: Vpb, BTW, WBSO, KOR,
SiSa, ENSIA, BBV, Iv3, RGS, BADO, BIK, RUDDO, Wet Fido, art. 29 OB, Archiefwet,
AWR, AVG, Digipoort, Peppol, SEPA pain.001, ESRS, IFRS, RJ.

TWO EDITORIAL SWEEPS, both deliberate.

Requirement ids are stripped from user-facing text. Dozens of strings carried
"REQ-BR-002", "REQ-CCD-001", "per REQ-AP-006 .. REQ-AP-008" and similar
straight into a page description. Those belong in the specs; a bookkeeper
reading a page header does not need the ticket number, and the Dutch reads as
an instruction rather than a changelog entry.

App names are corrected on the way through: docudesk -> Filinq, "NC Files" ->
"Bestanden". Referring users to an app under a name it no longer has is worse
in translation than in English, because they cannot pattern-match back to the
original.

Then `l10n:build`, because nl.json alone ships nothing: the frontend reads
OC.L10N from l10n/nl.js and a raw .json is not served from an app directory.

Verified: 0 manifest strings left without Dutch, keys DROPPED = 0 against the
pre-change catalogues, nl.js registers under "shillinq" with 5,196 keys and
resolves "Balance Sheet" -> "Balans", "Payees" -> "Crediteuren", "Provisions"
-> "Voorzieningen". test:l10n, test:l10n-dutch-tokens, check:schema-l10n and
check:manifest all PASS.
…credit split (#1276)

First aggregation translated onto openregister#2904's conditional metrics.

`consolidatedTrialBalance` declared a filter as a STRING
("eliminationFlag = false"), a groupBy as a STRING, and its two figures
under an `operations` dict whose per-entry `condition` was a SQL string.
None of that is engine grammar, so it computed nothing at all.

It now uses `filter` as an object, `groupBy` as an array, and two
conditional `metrics` entries whose `as` aliases keep the original response
keys — `totalDebit` and `totalCredit` — so no consumer changes.

VERIFIED AGAINST THE ROWS on a live ledger:

  1300  {"totalDebit": 12100, "totalCredit": 0}
  8000  {"totalDebit": 0,     "totalCredit": 10000}
  1510  {"totalDebit": 0,     "totalCredit": 2100}
  4400  {"totalDebit": 15000, "totalCredit": 6000}

Account 4400 carries rows on BOTH sides — debit 5000 + 10000, credit
3000 + 3000 — so one group exercises both conditions. Every figure matches
its rows. Had the conditions been dropped, both columns would read 21000,
which is the shape of wrong answer this whole programme keeps producing.

Ratchet 216 -> 215.

⚠️ Correction to my own estimate: I expected conditional metrics to unblock
about ten accounting aggregations. It unblocks ONE. TrialBalance.
trialBalanceTotals and BalanceSheet.balanceSheetTotals aggregate
`GLLine.amount` while being declared on other schemas — that is
cross-schema, a different capability — and both also want an
`operation: "check"` with an expression. Detail and the corrected blocker
table are on #1261.

Verified: 4980 tests / 46766 assertions, 0 failures; 8 JS validators;
prettier; both new gates still pass.

Refs #1261
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ 0bdc2f8

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-27 10:52 UTC

Download the full PDF report from the workflow artifacts.

…1279)

* fix(aggregations): gate the BARE half of the field-reference check

checkAggregationFieldRefs() opens with `if (dot === -1) continue`, so it only
ever validated `Schema.field` refs. The bare form — `groupBy: ["fiscalYearId"]`,
`filter: { costCenterCode: ... }` — was never looked at, and it is the larger
half: 120 of the 451 bare references now checked resolve to no declared
property at all.

None of them errored. A bare groupBy on a property the target schema does not
declare groups every row into ONE null bucket; a bare filter key that resolves
to nothing narrows to zero rows. Both answer HTTP 200 with a figure that looks
like an answer, which is why the class never surfaced as a bug.

The bare form was skipped because "the source schema would need resolving,
which source/sourceSchema spell inconsistently across fragments". The engine
settles it: AggregationRunner reads `from` and nothing else — `source` and
`sourceSchema` are inert keys it never consults. The target is `from` when
present and the declaring schema otherwise, exactly as the runner computes it,
so the ambiguity that justified skipping this is gone. Baselined at 120 and
ratcheted, not waived.

Also in this change:

- The three segment-P&L declarations (Project, AnalyticalDimension x2) drop
  the `@self.code` correlation. It cannot work: no caller supplies a parent
  row — AggregationController, ReportRenderService and ThresholdEvaluationService
  all invoke AggregationRunner::run() without one — so `@self.code` resolved to
  null, and a null is applied as a real filter VALUE. Each would have returned
  the unassigned total, confidently, for every parent record. The correlating
  field becomes a groupBy DIMENSION instead, which needs no parent row and is
  narrowed per-record through the extraFilter query params the REST endpoint
  already passes through.

- `kostenDragerCode` -> `costCarrierCode`. GLLine declares no kostenDragerCode,
  so that filter matched nothing.

- byCostCenterHierarchy gains `metric: sum` / `field: amount` and an explicit
  `on: {costCenterCode: code}` map. The `on` shorthand names only the joined
  side, leaving the parent key inferred — which produced one '' bucket instead
  of one per cost-center (OpenRegister #2916).

- The @self placeholder check no longer exempts `where` on a cross-schema spec.
  That exemption assumed the parent row was supplied; it is not.

`GLLine.fiscalYearId` stays as-is and stays baselined. GLLine has no fiscal-year
property, and periodId is a FINER grain — substituting it would silently change
what these roll-ups group by, which is a schema decision rather than a rename.

Refs #1261

* style: prettier formatting for the bare-ref gate

* chore(gate): lower AGG_NO_METRIC_BASELINE to 211 after #1276

#1276 (consolidated trial balance) gave one more aggregation a real
metric/metrics pair, so the ratchet correctly refused to stay at 212. The
number is the gate's own measurement, not a derived estimate.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ 82c0080

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-27 12:57 UTC

Download the full PDF report from the workflow artifacts.

rubenvdlinde and others added 2 commits August 27, 2026 15:15
…xists (#1284)

Documentation only. The held step stays held, and is deliberately NOT
registered by this change.

The hold cited "issue #503", which is the CODEBERG issue number. On
GitHub #503 resolves to an unrelated MERGED pull request about manifest
gates, so anyone following the reference lands on finished work and the
hold reads as stale. It migrated to GitHub as #1094 ("Phase 3/4 +
live-verify the held FoldIntoOrder migration"), which is still open and
does describe the hold. Both citations are corrected.

Why a stale reference matters more here than usual: gate-98
(repair-step-registration) flags this step as "written but never
registered in appinfo/info.xml, so Nextcloud will not run them", and it
cannot tell a deliberate hold from an oversight. Its suggested remedy is
to register the step. On this step that remedy deletes irreversible
source rows and removes the rollback window the hold exists to preserve
- the fold must lead the delete by at least one release. This comment is
the only thing between that advice and real data, so its reference has
to resolve to something that explains itself.

Found while clearing gate-98 across the fleet: integriq (#1603), filinq
(#836) and opencatalogi (#1145) had genuinely forgotten steps and are
registered; shillinq's is the one that must not be.
* fix(deps): development cannot npm install

A bare `npm install` on development fails with ERESOLVE. The quality
suites pass because they install with --legacy-peer-deps, which accepts a
tree only npm-with-an-existing-lock can produce, never one a fresh clone
can.

npm reports one conflict at a time, so this had to be peeled layer by
layer. Every layer is the same shape: a bump landed past what a toolchain
package's peer range allows.

  gridstack                          ^13.2.0 -> ^12.6.0
  @babel/core                        ^8.0.1 -> ^7.22.9
  postcss-html                       ^2.0.0 -> ^1.0.0
  sass-loader                        ^16.0.8 -> ^17.0.0
  stylelint-config-html              ^2.0.0 -> ^1.1.0
  stylelint-config-recommended-vue   ^2.0.0 -> ^1.6.1
  webpack-cli                        ^7.2.2 -> ^6.0.1

@babel/preset-env 8 requires @babel/core ^8.0.0 while
@nextcloud/webpack-vue-config@7.0.4 requires ^7.22.9 -- no version
satisfies both, so the babel pins revert together. Moving one alone is
what produced the deadlock, and is why the babel-8 dependabot PRs cannot
land individually.

gridstack is pinned to ^12.6.0 so development installs now.
ConductionNL/nextcloud-vue#801 widens that peer to accept 13 (measured:
identical build and 6752 passing tests on both majors); dependabot can
raise it again once 2.20.0 publishes.

Verified with no --legacy-peer-deps: npm install rc=0 (was ERESOLVE),
build rc=0, lint rc=0, stylelint rc=0.

* fix(deps): rebase the lock on development's, not a from-scratch resolve

The previous commit deleted package-lock.json before installing. That
turns a five-package pin into a full re-resolution: on pipelinq it moved
172 package versions, added 64 and removed 132, when five were intended.

One of those unintended moves broke boot. dexie went 4.4.4 -> 4.4.5, and
@conduction/nextcloud-vue's published dist BUNDLES its own dexie copy, so
the app loaded two and Dexie throws at module load:

  pageerror: Two different versions of Dexie loaded in the same app:
             4.4.5 and 4.4.4

The E2E boot gate caught it -- "the bundle loaded but rendered nothing" --
while build, lint, stylelint and unit tests were all green. A passing
build says nothing about whether the app mounts.

Starting from development's lock and letting npm move only what the
manifest forces cuts the change to 85/17/46 and leaves dexie alone.

Control: development's own E2E run is 309 passed / 1 failed with no
dexie pageerror and no boot-gate failure, so the breakage was mine.

---------

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

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ 49afa65

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
app:check-code ⏭️
info.xml ⏭️
REUSE ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-27 13:43 UTC

Download the full PDF report from the workflow artifacts.

* fix(clean-env): point the vendored skill at a script that exists

This repo carries a copy of hydra's clean-env skill, and the copy told you
to run `bash .claude/scripts/clean-env.sh` -- a file that does not exist
here, in hydra, or in any other checkout. Its app list was five names long
against a fleet of twenty-one, and three of those five (softwarecatalog,
docudesk-era names, mydash) are app directories or retired apps rather than
app ids, so following it by hand enables nothing.

Synced from hydra's canonical version. It now drives `.github/dev-up.sh`,
and its verification section names what to actually check: an app enables
successfully with no vendor/autoload.php and then fatals on every request,
and enables successfully with a pre-rename JS bundle and then renders a
blank page. Both were true of live apps in this workspace on 2026-08-27,
and "confirm apps are listed and enabled" sees neither.

* fix(repair): record WHY RetireSubsidieSchema is withheld, in the form gate-98 reads

gate-98 shipped yesterday and fails this repo on RetireSubsidieSchema. The
mechanism it reports is real -- the step's <step> entry is commented out, so
Nextcloud will never run it -- but the conclusion is wrong: it is commented
out on purpose. The reasoning was already written above it in prose (issue
#503): the step irreversibly deletes the Subsidie rows FoldIntoOrder folded,
so it must lag that fold by at least one release to leave a folded instance a
rollback window. Doing what the gate asked -- registering it -- would have
enabled a destructive step the team had deliberately withheld.

The prose stays. This adds the same decision in the machine-readable form the
gate now understands (.github#588), so the hold is recorded once and read by
both a person and the gate:

    <!-- hydra-gate-98 held: OCA\Shillinq\Repair\RetireSubsidieSchema — ... -->

The marker names this one class and carries the reason and the condition for
lifting it; it does not suppress anything else. RetireSubsidieSchema remains
unregistered and does not run.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ e9d6acf

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-27 15:57 UTC

Download the full PDF report from the workflow artifacts.

gate-70 (walkthrough-flows-stop) failed on development itself: shillinq ships a
`type:"flows"` page and no tour step pointed at it, so the automation surface
was discoverable only to someone who already knew it was there.

The gate measured the fleet on 2026-08-27: 19 apps declare a walkthrough, 12
ship a flows page, and exactly ONE tour mentioned flows at all. That is the same
shape as a feature that ships and is never found.

The new stop is view-only by construction: `optional: true`,
`allowManualNext: true`, and it advances on `route-match` rather than
`object-created`. The gate's sibling rule (forcing-flows-stop) exists precisely
so "here is where flows live" cannot become "build one first" — a tour you
cannot finish is worse than one that stays quiet, because the user never reaches
the end to learn what else exists. Placed before `done` so the tour still closes
on its closing step.

## Three traps this had to avoid

- The copy carries no em-dash or double-dash. An earlier draft did, and gate-96
  (manifest-copy-style) failed it against voice.md section 8.
- The manifest is written with tabs and compact one-line objects. Re-serialising
  it through json.dumps reformatted all 19,204 lines while changing nothing, so
  this is a targeted textual insert: 12 added lines, nothing else moved.
- The l10n catalogues are NOT stored in sorted order. Sorting them on write
  rewrote all 5,196 entries and buried the three real additions, so the writer
  now re-sorts only a catalogue that was already sorted.

## Translated, and the artefact rebuilt

The three new strings are in l10n/en.json and l10n/nl.json, and l10n/*.js is
rebuilt. Every other walkthrough string is already in the Dutch catalogue, so an
untranslated stop would render in English inside an otherwise Dutch tour. No
gate checks that — check:l10n-js passes either way — which is why it is done
here rather than left to CI.

ALL 68 applicable hydra gates green, and all 68 ran. check:manifest,
check:manifest-budget, check:nav-reachability, check:l10n-js, test:l10n,
check:schema-l10n and check:markers all exit 0.
`InventoryTransfer.quantity`, `OrderPick.quantity` and
`InventoryReorderRule.reorderQuantity` declared `minimum: 0` plus
`exclusiveMinimum: true` — the draft-04 spelling. Every draft this repository
validates against expects a NUMBER there, so those three schemas are not just
mis-declared, they are unvalidatable: a validator rejects the schema before it
ever looks at an object.

This file already uses the correct numeric form elsewhere (`distance`,
`ratePerKm`), so these three were the outliers, not the convention.

`minimum: 0` + `exclusiveMinimum: true` becomes `exclusiveMinimum: 0`, which
is the same constraint — strictly positive — in a spelling the validator
understands.
Three objects per (register, schema) pair, each generated from the schema
itself and validated against it — 1497 objects, 0 failures. Not installed
automatically: a mock register is imported on demand, from the setup
walkthrough or `occ openregister:descriptors:list --app=shillinq --import=<slug>`.
…ransaction (#1287)

* feat(glline): declare fiscalYearId, and backfill it from the parent transaction

GLLine declared no fiscal-year property at all. Three segment-P&L roll-ups
grouped by `GLLine.fiscalYearId` regardless, so every row landed in ONE null
bucket — a plausible total rather than an error, which is why it survived. The
gate had it waived with the note that it needed a schema decision. This is that
decision.

`periodId` was deliberately NOT reused. A period is a FINER grain than a year,
so grouping by it would have silently changed what those roll-ups mean — twelve
buckets where the consumer expects one — instead of fixing them. That is the
same shape of defect, not a fix for it.

## What lands

- `GLLine.fiscalYearId` (string, nullable), denormalised from the parent
  `GLTransaction.fiscalYearId`. The name and type match the six schemas that
  already declare it (GLTransaction, BalanceSheet, TrialBalance,
  ConsolidatedReport, ClosingEntry, kernGegevensConfig).

- `GlLineFiscalYearBackfillMigrator` — the pure resolution core, modelled on
  GlLineAdministrationBackfillMigrator. Indexes each transaction under every
  identity a line may reference it by (id, @self.id, uuid, transactionNumber),
  because `transactionId` has been written as each of those at different points.

- `BackfillGlLineFiscalYear` — the repair step, registered post-migration only
  (a fresh install has no historical lines to stamp).

- The `GLLine.fiscalYearId` waiver is removed from AGGREGATION_REF_BASELINE. The
  dotted-reference gate now passes with NO baselined entries at all.

- `AGG_BARE_REF_BASELINE` 120 -> 116. Declaring the property also resolved four
  BARE `fiscalYearId` references in other aggregations, which the ratchet caught
  and refused to let pass unrecorded.

## Reporting rather than gating, deliberately

The administration backfill closes a config gate and refuses to reopen it unless
a re-read proves completeness. That is right for `administrationId`: it is a
tenant SCOPE, and a half-scoped ledger makes a filter return a silent zero.

`fiscalYearId` is a GROUPING key. An unresolved line is not a leak and zeroes
nothing — it appears as a null bucket, which is visible in the result. Aborting
every resolvable line because one ancient row lost its transaction would trade a
visible gap for no backfill at all. So the step stamps what resolves and REPORTS
what did not, by re-reading the store afterwards and counting the whole set. The
count is emitted even when it is zero, so "nothing left behind" is something the
operator read rather than assumed.

A line that already carries a year is never rewritten, even when its parent now
disagrees — that disagreement is reported instead, because re-pointing a posted
line is a bigger decision than a backfill gets to make.

## Verification

- 10 new unit tests, 37 assertions
- Mutation control: removing the no-overwrite guard makes the suite fail, and
  restoring it makes it pass again — the tests can detect the thing they claim
- Full suite 4990 tests, 0 failures
- validate-registers exits 0 with every gate at its baseline
- Parsed-tree diff confirms the register edit added exactly the five
  `fiscalYearId` leaves and touched nothing else

Refs #1261

* fix(l10n): add the fiscalYearId description to the catalogues and rebuild the JS

check:schema-l10n went one over its baseline: the new GLLine.fiscalYearId
description had no catalogue key, so it would have rendered in English inside
an otherwise translated form.

Both artefacts are updated, not just the source. l10n/*.json is what the check
reads; l10n/*.js is what the browser actually loads, and a translation present
only in the JSON reaches nobody.

* docs(spec): tag the fiscal-year backfill methods with @SPEC

gate-16 (spec-coverage) failed with 8 changed methods missing @SPEC. Found by
running the real gate runner locally against origin/development as the delta
base — the CI run was still queued behind a saturated runner pool, and without
an explicit base the gate reports NOT APPLICABLE rather than passing, so it
would have judged nothing.

Tags point at openspec/specs/bookkeeping-cost-centers-dimensions/spec.md
#req-cc-005 — the segment-P&L requirement that motivated the property — rather
than at an openspec/changes/ directory. Archiving a change breaks every @SPEC
tag pointing into it, and most existing tags in this repo have that shape.

Also removes a tag that landed on the CLASS_UNRESOLVABLE constant docblock
instead of a method, and adds the two the first pass placed on neighbouring
docblocks (run, assertCountsMatch) — verified by re-running the gate, not by
counting matches.

gate-16 PASS, gate-98 PASS.

* fix(l10n): stop re-sorting the catalogues, which rewrote all 5,196 entries

The previous commit added ONE string and produced a 4,473-line diff on
l10n/nl.json. The writer called dict(sorted(...)) on every catalogue, and these
catalogues are not stored in sorted order — so the whole file was reordered and
the single real addition was buried in it.

That is not cosmetic. A reviewer cannot see a one-line change inside a
four-thousand-line reordering, and the next person to touch l10n gets a
conflict against every entry rather than against the line that moved.

The catalogues are now written back in their ORIGINAL key order, re-sorting
only a file that was already sorted. Net effect of this branch on l10n drops
from ~4,500 changed lines to 8.

Verified against origin/development, not against the branch tip: the previous
commit looked small there too until the base was made explicit.
…ADR-099 §3) (#1283)

* refactor(lib): collapse three single-class namespaces into Service/ (ADR-099 §3)

    Consolidation/ConsolidationGuard      -> Service/ConsolidationGuard
    Integration/TenderNedStatusSync       -> Service/TenderNedStatusSync
    Recognition/RevenueRecognitionService -> Service/RevenueRecognitionService

## A string reference nearly shipped broken, and only a test caught it

`ConsolidationGuard` is named as an FQCN STRING six times in
`lib/Settings/shillinq_register.json`:

    "requires": "OCA\\Shillinq\\Consolidation\\ConsolidationGuard::requireFiscalPeriodClosed"

I grepped for exactly this before moving anything and got ZERO hits — the
pattern's backslash escaping did not match the JSON's doubled backslashes. The
move went ahead on a measurement that was wrong.

`RegisterLifecycleGuardsResolveTest` caught it: *"class
OCA\Shillinq\Consolidation\ConsolidationGuard does not exist"*. Without that
test, five lifecycle guards would have silently stopped resolving — the failure
mode being a guard that no longer runs, which is invisible until something
passes that should have been refused.

All six strings are updated and the register JSON still parses.

🔑 A grep that returns zero is only evidence if you have seen it return
non-zero for the same shape.

## Five directories the rule should NOT have taken

Applying "one class collapses into Service/" literally to shillinq's eleven
single-FILE directories catches things that are not services:

- **`Data/`** holds `nl_vat_rates_2026.json` — a DATA FILE, no class at all.
  The count that flagged it counted files, not classes.
- **`Request/InvoiceGenerationRequest`** is a DTO.
- **`Enums/DBAConstants`** is a constants holder.
- **`Support/FleetAppId`** is the shared helper five fleet apps keep at that
  exact path.
- **`Sort/FefoSort`** is a strategy, arguably either way; left alone rather than
  moved on a coin-flip.

Together with learniq#655 — where moving three INTERFACES broke a test that
guards ADR-022's "no new PHP" decision — Decision 3 needs carve-outs for
interfaces, data directories, DTOs, enums and `Support` before it can be
mechanised or gated.

## Verification

- 4,980 tests / 46,776 assertions, no failures. The suite was run BEFORE
  committing precisely because the last app's collapse broke an invariant.
- phpcs 0 errors, phpstan `[OK] No errors`, `check:register` passes, after
  `composer dump-autoload`.

* docs: correct the verification claim in the previous commit

The previous message said "`check:register` passes". There is no such script —
it is `check:registers`, plural, and my invocation exited 1 because npm could
not find the name, not because anything failed.

Run correctly it PASSES:

    [validate-registers] PASS — every checkable aggregation field reference resolves
    [validate-registers] PASS — every metric-bearing aggregation declares groupBy as an array
    [validate-registers] PASS — every bookkeeping + procurement schema declares
                                x-openregister-audit-trail.enabled=true

Recording it rather than quietly amending: a verification claim in a commit
message is evidence, and one taken from a command that never ran is worth
exactly nothing. The underlying change is unaffected.

* test(listener): cover the fail-soft contract the coverage ratchet named

The PHPUnit pgsql cell failed on the coverage guard, not on a test — 4980 tests
passed and then "coverage of the code this change KEEPS or ADDS dropped by
0.36%" (330/383 = 86.16% against an 86.52% base). This namespace collapse pulls
five files into diff scope, and their untested branches came with them.

Reproduced the guard's number locally with pcov (330/383 exactly, same scope)
and covered two real contracts on OrderFulfilmentTransitionListener:

- **an event of another type is ignored** — the dispatcher hands `Event`, so
  the instanceof guard is all that stands between this listener and a `getTo()`
  that does not exist on that class;
- **a throwing sync is swallowed, and the milestone still stands** — the
  REQ-006 fail-soft contract. This listener runs inside another object's
  transition, so a throw would roll that transition back; the milestone event
  fires before the sync, so it must survive the sync failing.

336/383 = 87.73%, above the base.

I also wrote a test for the `is_array($oplevering) === false` branch and then
deleted it: `ObjectEntity::getObject(): array` is return-typed and coerces null
to `[]`, so that branch is unreachable from here. Leaving a test that cannot
fail would have been worse than the gap — the branch is dead defensive code,
noted rather than papered over.

Remaining uncovered in scope (not introduced here, not required by the
ratchet): RevenueRecognitionService 18, TenderNedStatusSync 15,
ConsolidationGuard 8, RecognitionController 4.

Every check passes: lint, phpcs, phpmd, psalm, phpstan, test:all.
(`composer check:strict` needs COMPOSER_PROCESS_TIMEOUT=0 locally — the
composite exceeds composer's 300s default and reports that as a failure.)
…eep (#1288)

* feat(glline): declare fiscalYearId, and backfill it from the parent transaction

GLLine declared no fiscal-year property at all. Three segment-P&L roll-ups
grouped by `GLLine.fiscalYearId` regardless, so every row landed in ONE null
bucket — a plausible total rather than an error, which is why it survived. The
gate had it waived with the note that it needed a schema decision. This is that
decision.

`periodId` was deliberately NOT reused. A period is a FINER grain than a year,
so grouping by it would have silently changed what those roll-ups mean — twelve
buckets where the consumer expects one — instead of fixing them. That is the
same shape of defect, not a fix for it.

## What lands

- `GLLine.fiscalYearId` (string, nullable), denormalised from the parent
  `GLTransaction.fiscalYearId`. The name and type match the six schemas that
  already declare it (GLTransaction, BalanceSheet, TrialBalance,
  ConsolidatedReport, ClosingEntry, kernGegevensConfig).

- `GlLineFiscalYearBackfillMigrator` — the pure resolution core, modelled on
  GlLineAdministrationBackfillMigrator. Indexes each transaction under every
  identity a line may reference it by (id, @self.id, uuid, transactionNumber),
  because `transactionId` has been written as each of those at different points.

- `BackfillGlLineFiscalYear` — the repair step, registered post-migration only
  (a fresh install has no historical lines to stamp).

- The `GLLine.fiscalYearId` waiver is removed from AGGREGATION_REF_BASELINE. The
  dotted-reference gate now passes with NO baselined entries at all.

- `AGG_BARE_REF_BASELINE` 120 -> 116. Declaring the property also resolved four
  BARE `fiscalYearId` references in other aggregations, which the ratchet caught
  and refused to let pass unrecorded.

## Reporting rather than gating, deliberately

The administration backfill closes a config gate and refuses to reopen it unless
a re-read proves completeness. That is right for `administrationId`: it is a
tenant SCOPE, and a half-scoped ledger makes a filter return a silent zero.

`fiscalYearId` is a GROUPING key. An unresolved line is not a leak and zeroes
nothing — it appears as a null bucket, which is visible in the result. Aborting
every resolvable line because one ancient row lost its transaction would trade a
visible gap for no backfill at all. So the step stamps what resolves and REPORTS
what did not, by re-reading the store afterwards and counting the whole set. The
count is emitted even when it is zero, so "nothing left behind" is something the
operator read rather than assumed.

A line that already carries a year is never rewritten, even when its parent now
disagrees — that disagreement is reported instead, because re-pointing a posted
line is a bigger decision than a backfill gets to make.

## Verification

- 10 new unit tests, 37 assertions
- Mutation control: removing the no-overwrite guard makes the suite fail, and
  restoring it makes it pass again — the tests can detect the thing they claim
- Full suite 4990 tests, 0 failures
- validate-registers exits 0 with every gate at its baseline
- Parsed-tree diff confirms the register edit added exactly the five
  `fiscalYearId` leaves and touched nothing else

Refs #1261

* fix(l10n): add the fiscalYearId description to the catalogues and rebuild the JS

check:schema-l10n went one over its baseline: the new GLLine.fiscalYearId
description had no catalogue key, so it would have rendered in English inside
an otherwise translated form.

Both artefacts are updated, not just the source. l10n/*.json is what the check
reads; l10n/*.js is what the browser actually loads, and a translation present
only in the JSON reaches nobody.

* fix(aggregations): translate the legacy DSL — batch 1 of the #1261 sweep

Forty-one aggregations move from keys AggregationRunner never reads to keys it
does. None of them errored before; they returned an empty result or nothing at
all, under HTTP 200.

## 19 `@self` correlations become groupBy dimensions

`filter: { poolId: "@self.id" }` on an intra-schema aggregation does not
resolve. PlaceholderResolver acts only on `$`-prefixed values, so `@self.id`
stayed a LITERAL STRING and matched no row — an empty result that looks like
"no data" rather than "this never worked".

And it could not have worked: no caller supplies a parent row.
AggregationController, ReportRenderService and ThresholdEvaluationService are
the only three call sites of `run()` in OpenRegister, and none passes one.

Grouping by the same field needs no parent row and answers for every record at
once; a caller narrows to one through the `extraFilter` query params the REST
endpoint already passes through.

## 22 `operations` maps become `metrics`

`operations` is not an engine key either. Each entry is
`{field, operation, target}` — which is exactly `{field, metric, as}` — so the
translation is mechanical. `target` duplicated the map key; both became `as`.
Where `field` was written qualified (`RetainerDrawdown.drawdownAmount`) the
prefix is stripped, since `from` already resolves bare names on that schema.

Conditional entries carry through as `condition`, which the runner honours
since openregister #2917.

## What was deliberately NOT translated

- 33 `operations` maps containing an `expression` op. The engine has no
  equivalent, and the conditions are SQL-ish STRINGS ("VATLine.type =
  'collected'") where computeMetrics() takes a filter OBJECT. Guessing either
  would produce a confident wrong number, which is the failure mode this whole
  sweep exists to remove. `BtwAangifte.totalsByReturn` is pinned in its test as
  still-untranslated so the gap stays visible.
- `ExpenseClaimEntry.settlementTotals`. It uses a multi-source `sources` array
  whose "amountField"/"customerField" are CONFIG KEYS of a source descriptor,
  not fields. An earlier pass of this change translated them into metrics; that
  was nonsense and is reverted.

## Two defects the gates caught mid-change

Translating `operations` into `metrics` moved those field names into a key the
bare-reference gate can SEE, and the count rose from 102 to 111. That is not new
debt — it is debt that was previously invisible:

- `ProvisionDisclosureTabel.provisionDisclosureGeneration` kept `source` instead
  of `from`, so its five movement fields resolved against the wrong schema.
- `IBProfitAttribution.innovatieboxAdministratie` summed
  `kwalificerende_winst_na_nexus`; the schema declares
  `qualifying_profit_after_nexus`. A Dutch/English mismatch that matched nothing.
- `GhgInventory.scope3ByCategory` tripped the string-groupBy gate the moment it
  gained metrics — a STRING groupBy is silently dropped and yields one ungrouped
  total.

All three are fixed here, so the bare-ref count returns to 102 with no baseline
raised.

## Ratchets

    AGG_PLACEHOLDER_BASELINE   81 -> 62   (exactly the 19 converted)
    AGG_NO_METRIC_BASELINE    206 -> 185
    AGG_BARE_REF_BASELINE     102 -> 102  (unchanged, after the three fixes)

## Verification

- Parsed-tree diff against HEAD: exactly the intended aggregations changed,
  ZERO non-aggregation leaf changes across all 14 files
- Only files whose JSON round-trips byte-identically were rewritten, so no file
  was silently reformatted
- Five fragment tests updated from the old vocabulary to the engine's, each
  asserting the inert key is GONE rather than only that the new one is present
- Full suite 4990 tests, 0 failures
- validate-registers exits 0 with every gate at its baseline

Stacked on #1287 (GLLine.fiscalYearId), whose baseline changes this builds on.

Refs #1261

* docs(spec): tag the fiscal-year backfill methods with @SPEC

gate-16 (spec-coverage) failed with 8 changed methods missing @SPEC. Found by
running the real gate runner locally against origin/development as the delta
base — the CI run was still queued behind a saturated runner pool, and without
an explicit base the gate reports NOT APPLICABLE rather than passing, so it
would have judged nothing.

Tags point at openspec/specs/bookkeeping-cost-centers-dimensions/spec.md
#req-cc-005 — the segment-P&L requirement that motivated the property — rather
than at an openspec/changes/ directory. Archiving a change breaks every @SPEC
tag pointing into it, and most existing tags in this repo have that shape.

Also removes a tag that landed on the CLASS_UNRESOLVABLE constant docblock
instead of a method, and adds the two the first pass placed on neighbouring
docblocks (run, assertCountsMatch) — verified by re-running the gate, not by
counting matches.

gate-16 PASS, gate-98 PASS.

* fix(l10n): stop re-sorting the catalogues, which rewrote all 5,196 entries

The previous commit added ONE string and produced a 4,473-line diff on
l10n/nl.json. The writer called dict(sorted(...)) on every catalogue, and these
catalogues are not stored in sorted order — so the whole file was reordered and
the single real addition was buried in it.

That is not cosmetic. A reviewer cannot see a one-line change inside a
four-thousand-line reordering, and the next person to touch l10n gets a
conflict against every entry rather than against the line that moved.

The catalogues are now written back in their ORIGINAL key order, re-sorting
only a file that was already sorted. Net effect of this branch on l10n drops
from ~4,500 changed lines to 8.

Verified against origin/development, not against the branch tip: the previous
commit looked small there too until the base was made explicit.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ 8b378e9

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
app:check-code ⏭️
info.xml ⏭️
REUSE ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-27 17:30 UTC

Download the full PDF report from the workflow artifacts.

…y onboarded (#1296)

#1292 added the `see-flows` stop and marked it `sinceVersion: "1.3.18"` — the
same version as every other step in the tour. The manifest schema says what that
field is for:

  "sinceVersion": Semver the step was introduced in; drives the
                  "what's new" composition.

So a step claiming to have existed since 1.3.18 is not new to anyone who already
completed the 1.3.18 tour. The stop shipped, and the users it was written for
would never have been shown it.

That is the same defect gate-70 exists to catch, one level further in. The gate
asks whether the flows surface is discoverable to someone who does not already
know it is there; a what's-new entry dated before those users onboarded fails
that test just as completely as having no step at all — and it fails it while
the gate reports PASS, because the step IS present in the manifest.

The step now declares 1.4.0, and the manifest version moves to 1.4.0 with it.
The other four steps keep 1.3.18: they genuinely did exist then, and back-dating
or forward-dating them would either re-show a finished tour or hide real history.

Credit to #1291, which proposed the same stop independently and got this part
right. Its copy could not land as written (em-dashes, which gate-96 refuses
against voice.md section 8), but the version bump was the half I had wrong.

ALL 67 applicable hydra gates green, and all 67 ran. check:manifest,
check:manifest-budget, check:manifest-shell, check:l10n-js and test:l10n all
exit 0.
…e 4)

An app installed from the App Store opens on an empty list, and the only
question its first reader has is whether they can see it work. Answering it
needs data they cannot author, against a schema they do not know yet. A
welcome screen answers a question nobody asked; it can still say hello from
second place.

`DemoDataService` imports this app's generated mock register through the same
OpenRegister importer the app already uses for its real configuration. Two
decisions worth stating:

- `force: true`. OpenRegister version-gates a non-forced import and SKIPS
  silently when the version has not moved. An operator who asks for demo data
  and is told it worked, on an instance where nothing was written, has been
  lied to by a version compare. The request is explicit, so the import is.

- Its own config identity (`<app>.demo`), so the demo import and the real
  configuration import cannot mask one another's version gate.

`skip-demo-data` exists so "no thanks" is a decision the wizard can record —
otherwise the only way past the step is to install demo data, which is wrong
on a production instance. The status flag records that the step was DEALT
WITH, not that demo objects exist.

The cross-app getter returns `object`, not the OpenRegister class: naming a
class from an optional app in a native return type makes PHP resolve it on
every return, so an instance without OpenRegister fails with a TypeError
about a class nobody mentioned instead of the RuntimeException that names the
missing app.

7 unit tests, 13 assertions. phpcs clean, manifest validates against
app-manifest-v2, gate-100 passes.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ d63c251

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
app:check-code ⏭️
info.xml ⏭️
REUSE ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-27 18:24 UTC

Download the full PDF report from the workflow artifacts.

…hows

The stop itself landed on development (#1291's manifest change), but its three
user-facing strings did not reach any catalogue, so the tour renders them in
English for every locale.

Nothing would have reported it: this app's l10n checker scans src/ for t()
calls and never reads src/manifest.json, so a manifest-only string is invisible
to it in both directions. It cannot say the key is missing, and it would call
it unused if you added it. opencatalogi hit the same blindness from the other
side and fixed its checker in #1153.

Dutch is written rather than machine-produced. "Flows" stays "Flows", matching
the existing catalogue. The browser .js is rebuilt in the same change, because
l10n/*.json is the source and l10n/*.js is what the browser actually loads.
… batch 2 (#1301)

Five `operations` maps become `metrics`, now that OpenRegister #2941 gives the
engine a derived metric. They were held back in batch 1 for two reasons that
have both been removed:

  - `vatBalance` is an `expression` op, which the engine had no equivalent for.
  - The conditions were SQL-ish STRINGS ("VATLine.type = 'collected'") where
    computeMetrics() takes a filter OBJECT.

The strings convert mechanically: `= 'x'` and `= true/false` become a literal,
`IN (...)`/`IN [...]` becomes `{in: [...]}`, `IS NOT NULL` becomes `{ne: null}`.
Field names lose the schema prefix, since `from` already resolves bare names.

`AGG_NO_METRIC_BASELINE` 185 -> 180. Bare refs unchanged at 102 — nothing new
became visible this time.

## The mistake this batch made first, and the gate that now prevents it

The `operations` maps key their entries one way and name their `target`
another. Expressions were written against the KEY. Translating the metrics list
to be keyed by `target` therefore left every expression naming an alias that no
longer existed.

A first pass produced 41 such references across 13 aggregations. Every one would
have raised on first use — correctly, because the evaluator refuses an unknown
alias rather than resolving it to 0, but at RUN time, on whatever dashboard
opened it.

Two things came out of that:

1. The translation rewrites expression identifiers from the map key to the
   emitted alias, and REFUSES any aggregation whose expression still names
   something undeclared. That is why this batch is five and not thirteen: eight
   of them reference FIELDS (`eigen_rd_kosten`, `dboOpening`), which makes them
   row-level formulas rather than arithmetic over sibling metrics — a different
   capability, not this one.

2. `checkAggregationExpressionAliases()` now fails the build on a derived metric
   naming an alias that is not declared BEFORE it. Verified by injecting a typo
   into a real expression: the gate names the identifier and lists what was
   available, and clears when reverted.

`ExpenseClaimEntry.settlementTotals` stays untranslated, again. It carries a
multi-source `sources` array across three schemas; a flat metrics list cannot
express that, and the translator now refuses anything declaring `sources`
rather than producing the nonsense a previous pass did.

## Verification

- Parsed-tree diff: exactly the 5 intended aggregations, ZERO non-aggregation
  leaf changes
- Only round-trip-stable files were rewritten, so nothing was reformatted
- Two pinned tests updated — both were pins I wrote in batch 1 saying "still
  untranslated", and both fired the moment that stopped being true
- Full suite 4992 tests, 0 failures
- validate-registers exits 0 with every gate at its baseline

Refs #1261
… budget

`check:manifest-budget` failed at 1,128,882 B against a 1,128,750 B budget —
132 B over. Every byte of this JSON ships in the main webpack chunk on every
page load, so the tripwire is doing real work and is worth respecting.

🔴 THE HEADROOM WAS ALREADY GONE BEFORE THIS BRANCH. `development` had grown
to 1,128,545 B on its own, leaving 205 B — 0.018% — against the 0.58–0.88%
ratio this file argues for. The tripwire was one small edit away from firing
for whoever touched the manifest next; what it caught here was not a payload
problem so much as its own exhausted margin.

TRIMMED FIRST, then raised, which is the procedure this file's own notes set
out. ADR-111 rule 4 requires `setup.steps[0]` to be the demo-data offer, so
the step is not negotiable — its prose is. The body went from 200 characters
to 76, recovering 124 B, and still states the two things an operator needs:
what lands, and not to run it on production.

Raised to 1,135,500 B — 6,742 B of headroom, 0.59%, back inside the ratio and
close to the 0.58% the check ran with two bumps ago. Deliberately not more:
the point of restating the ratio is that the next change re-measures rather
than inheriting slack.

Also merges development, which is where the 626 B of drift came from and why
this reproduced only against the merge result, not against the branch alone.

Verified: budget PASS, gate-100 PASS, manifest validates against
app-manifest-v2 with 0 errors, l10n 0 failures, demo data 499 schemas checked.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ 3379e2d

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-27 20:19 UTC

Download the full PDF report from the workflow artifacts.

…r expects it

Adding `DemoDataService` to the constructor for ADR-111 rule 4 broke the
existing SetupController tests, and I did not grep for callers when I changed
the signature. buildiq's CI named it on all three NC versions:

    TypeError: SetupController::__construct(): Argument #6 ($demoDataService)
    must be of type OCA\Buildiq\Service\DemoDataService,
    MockObject_SettingsService_f805ce68 given

A positional construction does not fail where the argument is missing — it
fails one slot LATER, reported as a type error on whatever now lands in the
wrong place. That is why it reads as a problem with $settings.

Swept all five apps that gained the step: three construct the controller in
tests. openbuild and opencatalogi positionally (opencatalogi twice — a
match-count assertion caught the second, which a single blind replace would
have missed), shillinq by named argument, where the new parameter is simply a
missing required one rather than a shifted slot.

Verified: openbuild 3 tests, shillinq 10, opencatalogi 16 — all green.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ f23a576

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-28 21:48 UTC

Download the full PDF report from the workflow artifacts.

)

The install arm timed out at 60s. Not a defect: the same assertion PASSED at
49.6s on the previous run. The largest app seeds 1497 objects where the
smallest seeds 30, so the call sits on the default boundary and flips between
runs.

`test.slow()` rather than a blanket timeout bump — it triples the budget for
this one arm and records why, leaving every other arm strict.

🔴 THE ASSERTION IS WORTH ITS COST. It is the only check that the install
actually WROTE something; the defect it exists for is an import that reported
`register "x" imported.` while seeding ZERO objects and passed every unit test.
Making it cheap by not importing would delete the property under test.

This branch deliberately does NOT touch ci-seed.sh: another session settled the
demo-data decision on development first, via
`occ config:app:set <app> demo_data_decided --value=skipped`, which does the
same job as the `skip-demo-data` API call I had proposed. Theirs landed, so
only the timeout fix remains here.
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ ab41b2c

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-28 22:03 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ 4c75444

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-28 22:31 UTC

Download the full PDF report from the workflow artifacts.

The seed collector reads BOTH buckets, and says so:

    // ImportHandler reads BOTH buckets:
    //   foreach ([$data['components']['objects'], $data['objects']] ...
    for (const obj of asArray(doc.objects)) ...
    for (const obj of asArray(doc.components && doc.components.objects)) ...

The schema lookup two dozen lines later read only one:

    const schemas = (merged.components && merged.components.schemas) || {}

So the gate was narrower than the set it collects, and it failed in both
directions. A seed declared against a top-level schema was reported as
naming a schema "no fragment declares" — a false alarm — and, worse, its
`required` list was never checked at all. This is a RATCHET: a class it
cannot see silently lowers the number it guards, which is the failure mode
a ratchet exists to prevent.

Found by running this validator against dossiq, which declares
`stufEndpoint`, `stufMessage` and `zaaksysteemMapping` at the top level of
`register.d/80-stuf-zkn-outbound.json`. It reported that fragment's seed as
orphaned; the schema is right there.

NO EFFECT ON THIS REPO'S NUMBERS, verified rather than assumed: shillinq
declares 820 schemas under `components.schemas` and ZERO at the top level,
so the widened lookup returns the same set. Before and after, identically:

    seed objects checked: 746
    seeds naming a schema no fragment declares: 1
    seeds that cannot satisfy their schema: 53 (baseline 53)

Positive control on dossiq: the false alarm goes 1 -> 0 with the same
patch, and its 192 seeds still report 0 unsatisfiable.
…oth-schema-shapes

fix(validate-seeds): read both schema shapes, as the seed collector does
@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ c83fbac

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-29 00:37 UTC

Download the full PDF report from the workflow artifacts.

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

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

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

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ 73fca32

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-29 10:34 UTC

Download the full PDF report from the workflow artifacts.

…#1340)

gate-103 (locale-duplication) fails this app: 28 locale pairs are byte-for-byte
copies of another language. They are not "untranslated" — they are WRONG, and
a user who picks one of them is shown a language they did not choose.

Identified by reading the content, not just the hashes:

  * bs, cs, mk, sk, sl, sr are byte-identical to hr and contain CROATIAN
    ("Action" -> "Radnja", "Created" -> "Stvoreno"; Czech would be "Akce" /
    "Vytvořeno"). Kept hr.
  * be and uk are byte-identical to ru and contain RUSSIAN
    ("Action" -> "Действие"). Kept ru.
  * sv is byte-identical to da and contains DANISH
    ("Action" -> "Handling", "Created" -> "Oprettet"; Swedish would be
    "Åtgärd" / "Skapad"). Kept da.

Deleted rather than blanked, so those nine fall back to English. English a
Slovak reader can recognise as not-their-language beats Croatian they may
mistake for it.

Verified after the deletion: zero duplicate groups in both the .json and the
.js catalogues, 27 locales remain, and hr/ru/da/nl still parse.

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

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ c0596b4

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-29 12:43 UTC

Download the full PDF report from the workflow artifacts.

rubenvdlinde and others added 3 commits August 29, 2026 14:56
…ee (#1343)

My previous change deduplicated by FILE HASH and took gate-103 from 28 pairs to
3. The remainder survived because the gate does not compare bytes, despite
saying so: it reads the `.js` bundles, keeps the values that differ from
English, and flags a pair when EVERY shared translated value matches. Two files
can differ in key order or coverage — so hash differently — and still be the
same translation.

Running the gate's own script locally names them:

  ca and es: all 1523 translated values identical  -> ca holds SPANISH  ("Acción"; Catalan is "Acció")
  de and lb: all 1503 translated values identical  -> lb holds GERMAN   ("Aktion"; Luxembourgish is "Aktioun")
  it and rm: all 1511 translated values identical  -> rm holds ITALIAN  ("Azione")

Deleted the three copies, keeping es, de and it.

The gate advises translating rather than blanking, because the parity gate
refuses empty values. Removing the bundle outright is the third option and the
honest one here: nobody translated these, and English a Catalan reader
recognises as not-their-language beats Spanish they may mistake for it.

Verified with the gate's own script: "checked 24 locale(s) … no locale is a
byte-for-byte copy of another."

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

gate-103 still failed after #1340 dropped nine bundles: ca, lb and rm are
copies of es, de and it respectively — all 1523, 1503 and 1511 shared
translated values identical.

The source language of each pair was read off its own strings, not assumed
from the filename: es says "Acción"/"Creado"/"Fecha" (Catalan would be
"Acció"/"Creat"), de says "Aktion"/"Erstellt"/"Datum", it says
"Azione"/"Creato"/"Data". The genuine file is kept and the copy dropped.

Worth recording how this was missed. gate-103 does NOT compare file bytes,
despite saying "byte-for-byte": it parses `l10n/*.js`, takes the values that
differ from English, and flags a pair when every SHARED translated value
matches. Two bundles with different key sets and different bytes are still a
copy by that definition — so an md5 sweep over `l10n/*.json` reports clean
over a failing tree, which is exactly what it did here.

Verified by running the gate's own checker, not a proxy for it: 24 locales,
"no locale is a byte-for-byte copy of another."

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Two independent faults, either of which alone stops the docs site updating.

TRIGGER. This listened on a branch called `documentation`. Nobody has pushed
to one since 2026-05-25, so every docs change merged to `development` passed
review and published nothing.

SECRETS. A reusable workflow receives no secrets by default. With none mapped,
the callee's publish step finds CF_API_TOKEN empty and skips itself on its own
guard, and the run finishes GREEN having changed nothing. Fixing only the
trigger would have produced exactly that.

The worker name is now pinned. Deriving it is the documented way to get a green
run that reaches nobody: wrangler creates the derived worker and publishes
there while the custom domains keep routing to the real one.

Where the app was renamed, `canonical-host` turns the retired hostname from a
second live copy of every page into a 301 to the same path on the current one.

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

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ 30c2701

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
app:check-code ⏭️
info.xml ⏭️
REUSE ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-29 13:07 UTC

Download the full PDF report from the workflow artifacts.

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

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ e205e96

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 551/551
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-29 14:19 UTC

Download the full PDF report from the workflow artifacts.

rubenvdlinde and others added 10 commits August 29, 2026 18:11
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Bumps [pinia](https://github.com/vuejs/pinia) from 3.0.4 to 4.0.3.
- [Release notes](https://github.com/vuejs/pinia/releases)
- [Commits](vuejs/pinia@v3.0.4...v4.0.3)

---
updated-dependencies:
- dependency-name: pinia
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ruben van der Linde <rubenvdlinde@gmail.com>
* feat(nav): a Flows surface in this app, on the shared page types

ADR-110 Decision 4. A flow is app-specific — it operates on this app's objects —
so the authoring surface belongs here rather than behind a deep link to another
app's list. The ENGINE stays single (ADR-065): these pages are a scoped view
onto OpenRegister's one native flow store, not a per-app store.

Two manifest pages and one settings entry, no component files: `type: "flows"`
and `type: "flow-detail"` are shipped page types in @conduction/nextcloud-vue
2.19.0, scoped by `config.app`.

Note the layout of the diff: entries are appended textually rather than by
reserialising the manifest. A `json.dump` round-trip rewrote pipelinq's file as
a 3,950-line diff for a 20-line addition — correct output, unreviewable change.

* build(deps): @conduction/nextcloud-vue 2.19.0 for the flows page types

Required by the manifest change: `type: "flows"` / `type: "flow-detail"` are
rejected by the compiled validator in earlier versions, and CI installs with
`npm ci` — so the LOCK is what decides, not the `^2.x` range. Several of these
locks were pinned many minors back, which is why some lockfile diffs are large:
npm restructures the nested tree (mostly @esbuild platform binaries under
@nextcloud/vue) to satisfy 2.19.0's peers. No direct dependency other than
@conduction/nextcloud-vue changes.

* chore(tests): refresh the vendored manifest schema to 2.25.0

The manifest gate resolves its schema from `tests/schemas/` FIRST, ahead of
node_modules — deliberately, so the gate is self-contained and does not depend
on a fresh install. The cost is that the copy freezes: this one was 12 minors
behind, so the gate could not see any page type, widget or field the library
had added since, and reported PASS the whole time because nothing it knew about
was wrong.

Refreshed from the installed 2.19.0 (schema 2.25.0) so `type: "flows"` and
`type: "flow-detail"` validate. Worth noting for whoever owns this gate: four
apps vendor this file and all four had drifted — 2.12.0, 2.13.0, 2.13.0 and
2.22.0 against a library at 2.25.0. A copy with no refresh step is a gate with
a silent expiry date.

* test(nav): baseline FlowDetail, reachable by row click not by menu

The reachability gate reads menu[] and a fixed list of manifest link fields.
CnFlowsPage navigates with this.$router.push(`${detailRoute}/${id}`), so the
edge from the Flows index to /flows/:id lives in the shared component and is
invisible to a static gate — the same class as the runtime related-object
widgets design.md §2 scopes out.

Not an IA gap: verified in a browser on dossiq, where /flows lists flows and
clicking one opens the canvas. The entry says so, and says what would let it be
removed — CnFlowsPage accepting a route NAME in config.detailRoute, which would
make the edge declarative and visible to this gate fleet-wide.

* feat(flows): give the flow-detail canvas its sidebar

The manifest _note claimed the controls rendered in the NC app sidebar,
but the sidebarComponent field it described did not exist. Every
#/flows/:id -- and #/flows/new, the same route with the literal id -- drew
a bare canvas: savable and runnable, but with no way to name, describe,
trigger or step-edit the flow, because those controls all live in
CnFlowSidebar. Mirrors pipelinq#1490. ADR-110 Decision 4.
beta held 14 commit(s) development did not. Merged with -s ours:
development's tree is kept BYTE FOR BYTE and only the ancestry is
recorded. That is the payload -- without it the merge base never moves
and the next development -> beta promotion conflicts on the version file
exactly as before. 13 of 19 promotion PRs were CONFLICTING for this
reason.

Nothing is silently imported. What beta holds and development does not,
and which this deliberately does NOT bring over:

  .forgejo/.beta-release-trigger

Those are dead Forgejo/Codeberg CI (removed from development on
2026-08-24/25 by 'chore(ci): remove dead Forgejo/Codeberg CI
configuration'), generated Docusaurus build output, and community-health
files that never existed on development. Each can be added deliberately
if wanted; resurrecting them as a side effect of a sync is how a merge
silently undoes a decision.
…0260830084308

chore(sync): carry beta back into development
…00841

chore(sync): record beta's ancestry on development
pinia 4.0.3 (#1243) landed on development with three checks red — Frontend
Build, Frontend Tests (unit) and the E2E run downstream of the build. Two
independent resolution regressions, both mechanical:

1. `@vue/devtools-api` moved from pinia's `dependencies` (^7.7.7 in 3.0.4)
   to a NON-optional `peerDependency` (^8.1.5 in 4.0.3). This repo's
   .npmrc sets `legacy-peer-deps=true`, so npm installs no peers and the
   package was simply absent — the lockfile had zero top-level entries for
   it. vitest died with "Cannot find package '@vue/devtools-api' imported
   from node_modules/pinia/dist/pinia.js" (1 of 22 suites). Declared it
   explicitly; the lock only hoists the 8.2.1 copy @nextcloud/vue already
   pulled in, so no version moves.

2. pinia 4 dropped `main` AND `module`, shipping only an `exports` map.
   The Vue-2-era `pinia$` alias pointed at the package DIRECTORY, which
   bypasses `exports` and then looks for a main/index.js that no longer
   exists — every import failed with "Can't resolve 'pinia'" (5 webpack
   errors). This is the exact trap the neighbouring @nextcloud/vue,
   @nextcloud/dialogs and vue-router comments in this file already
   document; aliased to the absolute FILE the same way.

Verified locally: vitest 22/22 files, 256/256 tests (was 1 failed / 21
passed); webpack production build 0 errors (was 5). The remaining
sax/'stream' warning is pre-existing and present before the bump.

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

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ 7c925ae

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
app:check-code ⏭️
info.xml ⏭️
REUSE ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-30 09:07 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/shillinq @ eb8564a

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-manifest-shell
format
check-l10n-js
check-schema-l10n
check-manifest-budget
check-markers
check-registers
check-seeds
check-fragment-required
check-nav-reachability
check-job-registration
test-l10n
composer ✅ 106/106
npm ✅ 541/541
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-30 10:21 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 6ccf875 into beta Aug 30, 2026
110 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants