Skip to content

Release: merge development into beta - #18

Open
github-actions[bot] wants to merge 1241 commits into
betafrom
development
Open

Release: merge development into beta#18
github-actions[bot] wants to merge 1241 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.

rubenvdlinde and others added 27 commits July 9, 2026 15:28
Curate the case-type demo for a German federal-government audience and fix
several navigation/dashboard/board issues surfaced during demo prep.

Navigation & pages (src/manifest.json, src/menu-layout.json):
- Collapse the redundant Cases group so 'Cases' links straight to /cases.
- Default the Cases index to the compact 'list' view (list/table/cards/map).
- Cases-by-Status dashboard chart: mergeByLabel so per-case-type statuses of
  the same name (Received, In progress, …) collapse into one bar.

Workflow board (src/views/workflow-board/WorkflowBoard.vue):
- Merge columns by status NAME instead of one column per statusType object
  (statuses are defined per case type), and resolve a drop back to the status
  id belonging to the dropped case's own workflow.

My Work (src/views/MyWorkCards.vue):
- Enable the embedded filter/search sidebar (facets by status, type, priority).

Demo seed data:
- Add lib/Settings/register.d/46-demo-cases-english.json seeding four English
  case types recognizable to a Bundesverwaltung audience — Building Permit,
  Grant Application, Citizen Complaint, Freedom of Information Request — each
  with an English status lifecycle and English example cases.
- Disable the Dutch demo seeders for this profile: bezwaar_seed_data.json
  (Bezwaar/Beroep/Subsidie) and register.d/45-deelzaak-seed.json are parked
  under disabled keys (reversible) so a clean-env reproduces the English demo.
…roducts (ADR-003, Wave 1)

Procest no longer implements a fee engine. Per ADR-003, a municipal fee is a
Pipelinq product, referenced from a case type via productsOrServices; the
charge on a concrete case is a Pipelinq financial transaction.

Removed:
- 9 leges schemas (legesverordening/artikel/berekening + the leges-heffingen
  tarief/variant/korting/restitutie set) from the register
- ~19 backend classes (services, controllers, listeners, repair, seed)
- leges routes, settings keys + schema mappings, Application.php listeners
- frontend views/dialogs/api + registry entries
- 4 leges detail/index pages + menu entries
- 4 obsolete leges OpenSpec specs

Changed:
- caseType.productsOrServices -> array of Pipelinq product references
  (items.$ref: product, x-external-register: pipelinq)

Existing leges data is dropped, not migrated (deliberate). beschikking.legesbedrag
(a stored amount on a decision) is retained and out of scope.

Verified: register/manifest/menu JSON valid, composer autoload + phpcs clean,
webpack build passes, no surviving code references a deleted leges class.
…y-chunk publicPath

- Add src/publicPath.js setting __webpack_public_path__ via generateFilePath so
  dynamically-imported chunks (Leaflet map, manifest validator) load from the
  path NC serves app JS (/custom_apps/procest/js/) instead of resolving to the
  app-shell HTML ('Refused to execute script, MIME text/html'). Imported first
  in main.js. Fixes the map view and the pre-existing validator console error.
- manifest.json: map center moved to Berlin for the German-federal demo.
- 46-demo-cases-english.json: add GeoJSON geometry to every example case —
  8 Points + 2 Polygons (building-permit parcels) — so cases plot on the map
  as both points and areas on a clean-env.
… tutorials

- Add a Case Types index + detail page (type:index/detail, schema:caseType) and
  point the 'Case types' settings menu at it. Previously the menu routed to the
  generic Settings shell, so there was no way to see or manage case types.
- docs: add two missing user-guide walkthroughs — '03 Handle a case from start to
  finish' (Cases list/filters -> case detail -> Workflow board drag) and '04 See
  your cases on the map' (Map view, points + polygon areas, geometry field), with
  hero screenshots.
- docs: refresh 01-first-launch for the current UI (List/Table/Cards/Map switcher,
  'Add Case', English navigation with Workflow board / Reports / Settings foldout).
…eletion wave — reassignment is already an action
…come Pipelinq products (ADR-003)' (#182) from refactor/retire-leges into development
…oss-app migration; decision schema is a dependency hub — sequence carefully)
- manifest: translate remaining Dutch page/widget titles (Objections, Appeals,
  Proposals, Approval routes, Enforcement strategy, Objection advisory
  committees, Processing time, Deadline monitoring, …) and the entire first-run
  setup wizard (Welcome / Initialise register / Load example demo data / Done).
- CasesOnMapView: build the marker set client-side from each case's GeoJSON
  `geometry` field (OR's server-side maps-overview does not surface the
  string-typed geometry here), and give CnMapWidget a center + default OSM
  basemap so the standalone /map page renders 10 cases instead of a blank pane.
- TermijnDashboard: English heading + filter labels (was 'AWB termijnbewaking
  dashboard' / 'Filter by zaaktype').
The Tenants index columns (name/oin/domain/groupId/isActive) did not exist on
the tenant schema (which has displayName/legalName/kvkNumber/status/tier/…), so
the 'name' column fell back to the object UUID and the others rendered blank.
Point the columns at the real fields with explicit English labels.
New user tutorial: pair two instances, share a whole case type (confidential
cases auto-withheld), share a single confidential case on its own, read/edit
across the federation with write-back, and automate sharing with a
federate-share flow. Cross-links the OpenRegister Federation reference and the
map tutorial.
…der-nav

# Conflicts:
#	appinfo/info.xml
#	src/manifest.json
… chrome, tutorials + Tenants/CaseTypes fixes' (#184) from feat/cases-folder-nav into development
# Conflicts:
#	src/main.js
#	src/manifest.d/50-zaakportaal.json
#	src/manifest.d/60-leverancier.json
#	src/manifest.json
#	src/menu-layout.json
Nextcloud 34's app-management page inlines the raw app.svg and recolors
it via 'fill: currentcolor' on the <svg> element. A fill carried on
<path>/<style>/class beats the inherited value, so the icon rendered
white-on-white and vanished from the list. Moving the fill to the <svg>
element lets Nextcloud recolor the icon; visuals are unchanged everywhere
the icon is shown on a dark/colored surface.
# Conflicts:
#	lib/Settings/procest_register.json
#	openspec/specs/supplier-portal/spec.md
#	src/registry.js
# Conflicts:
#	package-lock.json
#	package.json
…026-07-10)

# Conflicts:
#	appinfo/info.xml
#	package-lock.json
#	package.json
#	src/customComponents.js
#	src/manifest.d/50-besluitvorming.json
#	src/manifest.d/bezwaar-beroep-cards.json
#	src/menu-layout.json
#	src/registry.js
#	src/views/MyWorkCards.vue
rubenvdlinde and others added 24 commits August 5, 2026 19:26
The npm SBOM step in the shared workflow now passes --ignore-npm-errors,
which is exactly the flag that neutralises the @conduction/nextcloud-vue
npm ls ELSPROBLEMS failure this was switched off for (#434).

Verified against a positive control rather than assumed: docudesk pins the
same @conduction/nextcloud-vue 2.2.0-vue3.3 and its SBOM job completed the
npm leg and merged the PHP + npm SBOMs (run 31016019235, job 92340789907).
openregister, opencatalogi and doriath are green on SBOM as well, so the
old comment's 'No other Conduction app enables SBOM today' no longer holds.

procest already requires cyclonedx/cyclonedx-php-composer ^6.2 and allows
the plugin, so the composer leg has its prerequisite.
v1.0.1 contains ZERO `_skip` calls, so a gate whose prerequisite is absent
emits nothing and `hydra-gates-require-full-coverage` (default ON) counts it
as DID NOT RUN. v1.3.0 ships 36 not-applicable declarations. Measured on
doriath PR #160: gates 4/24/33 moved from unexplained DID NOT RUN to explicit
NOT APPLICABLE and Hydra Gates went failure -> success on the pin alone.
…gate-24 parity check (#743)

procest registers the `field-inspection` OpenRegister leaf in `src/main.js`,
overriding the nc-vue builtin with its own `offlineConfig`, but shipped no
`scripts/check-integration-parity.sh`, so hydra gate-24 reported:

  [gate-24] integration-parity: SKIPPED (structural) — ... server↔JS leaf
  parity (ADR-066 Decisions 4/7 ...) is UNVERIFIED

Writing the check found a REAL defect on its first run.

THE DEFECT. `offlineConfig.templateRefField` is 'checklistTemplateRef', and
nc-vue's CnFieldInspectionCard reads it off the PLANNED item:

    const templateRef = item?.[cfg.templateRefField]
    this.activeTemplate = templateRef ? await getCachedObject(...) : null

The planned item is a `fieldInspection`, and this repo's `fieldInspection`
schema declared no `checklistTemplateRef` property — only `checklistResult` had
one. So `templateRef` was always undefined, `activeTemplate` always null, and
`answers` always `{}`: an inspector opening a planned inspection offline got an
EMPTY checklist, every time, with no error anywhere. The registration and the
schema had drifted and nothing correlated them. (`src/main.js` says the mapping
"mirrors DailySyncService exactly" — no such class exists in this repo; the
mapping was written against the design doc, where checklistTemplateRef sits on
the RESULT.)

THE FIX. Declare `checklistTemplateRef` on the `fieldInspection` schema — the
template the inspector must complete for that inspection, read off the planned
item by the leaf and copied onto the resulting `checklistResult`. Additive and
optional (not added to `required`), so existing objects stay valid.

THE CHECK. Self-contained on purpose: the canonical Node check in
@conduction/nextcloud-vue validates that library's own built-ins, its ADR-066
cross-reference is WARN-only and self-disables in a repo with no
`new LeafDescriptor(` (procest has none — its leaf is a builtin override, so it
would have stayed silent here), and its `scripts/` dir is not published to npm.
Every way this one can fail to check exits non-zero with a named reason.

Rules, all hard (ADR-019 AD-11/AD-13, ADR-066 decisions 4 and 7):
  R1 complete render pair for the declared renderMode (mount+unmount / tab+widget)
  R2 server↔JS id correlation, both ways (phantom leaf / orphan registration)
  R3 renderMode agreement across layers under a shared id
  R4 metadata agreement (label, icon, group, requiredApp, referenceType, surfaces)
  R5 a spread-inherited identity must come from the leaf-owning package
  R6 an offlineConfig must name schemas/properties this repo actually declares

procest exercises R5 (1) and R6 (7) — the counts are printed, so "verified" and
"nothing to verify" are distinguishable in the log. A run where every rule has
zero subject matter fails rather than passes.

Positive control (proved it can fail, then restored):
  * the defect itself — before the schema fix the check exited 1 with
    "[R6 offlineConfig] leaf offlineConfig.templateRefField =
     "checklistTemplateRef" ... is not a property of schema "fieldInspection""
  * titleField drift — 'caseRef' -> 'caseReference' => exit 1, R6 names it
  * spread drift — `...fieldInspectionIntegration` -> an unimported binding
    => exit 1, "[R5 spread-source] ... the spread of an undefined binding
       registers a leaf with no id"
All restored; the check passes on the fixed tree.
* chore(license): normalise licence declarations to EUPL-1.2

Bring every licence declaration in procest into agreement on EUPL-1.2,
matching composer.json, package.json, appinfo/info.xml and LICENSE, which
already say EUPL-1.2.

- 30 files under tests/e2e/ carried `SPDX-License-Identifier: AGPL-3.0-or-later`;
  all are our own Playwright specs/helpers (SPDX-FileCopyrightText: Procest
  Contributors / Conduction B.V.). Changed to EUPL-1.2.
- project.md and docs/Technical/government-compliance.md claimed the app is
  AGPL-licensed in prose. openspec/specs/app-metadata-claims/spec.md explicitly
  forbids that ('No AGPL claim remains in prose'). Corrected to EUPL-1.2.

Copyright holders and years untouched. lib/**.php was already 100% EUPL-1.2,
so gate-28 (license-triangle) passed before and still passes.

Test-neutral: phpunit 1692 tests / 5659 assertions / 0 failures and vitest
32 files / 330 tests, identical before and after.

* chore(license): normalise licence declarations to EUPL-1.2

Bring every licence declaration in procest into agreement on EUPL-1.2,
matching composer.json, package.json, appinfo/info.xml and LICENSE, which
already say EUPL-1.2.

- 30 files under tests/e2e/ carried `SPDX-License-Identifier: AGPL-3.0-or-later`;
  all are our own Playwright specs/helpers (SPDX-FileCopyrightText: Procest
  Contributors / Conduction B.V.). Changed to EUPL-1.2.
- project.md and docs/Technical/government-compliance.md claimed the app is
  AGPL-licensed in prose. openspec/specs/app-metadata-claims/spec.md explicitly
  forbids that ('No AGPL claim remains in prose'). Corrected to EUPL-1.2.

Copyright holders and years untouched. lib/**.php was already 100% EUPL-1.2,
so gate-28 (license-triangle) passed before and still passes.

Test-neutral: phpunit 1692 tests / 5659 assertions / 0 failures and vitest
32 files / 330 tests, identical before and after.

---------

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

* feat(iv3): retire the IV3 cost report to Shillinq, keep the classification

Implements ADR-081 decision 2. Procest's quarterly IV3 report is deleted:
Iv3ReportService, Iv3ReportController::report(), its route,
Iv3ReportDashboard.vue, the manifest page and menu entry, the
customComponents and menu-layout registrations, both report test suites,
and the `case.kosten` field.

The report could not report anything. Re-verified on the dev instance
before deleting: 18 cases scanned, ZERO carrying a kosten entry, and the
same across all eight quarters of 2025-2026 when this was first found.
Nothing wrote `handling_cost` or `leges_income` at all — the only writer
of `kosten` was VaststellingService's subsidy disbursement, so two of the
report's three columns were structurally incapable of being non-zero.

WHAT IS DELIBERATELY KEPT, and why the ADR's "delete in full" is not
followed literally here. `caseType.iv3Taakveld` is the CLASSIFICATION the
same decision tells procest to keep, and the settings picker that
populates it reads `/api/reports/iv3/taakvelden`. So Iv3TaakveldList and
its JSON stay, and the controller is reduced to that one endpoint
(renamed Iv3TaakveldController; the URL is unchanged because the picker
calls it directly). Deleting the list now would break the field the ADR
preserves. Decision 1 makes Shillinq's BbvTaakveld catalogue the single
authority, and that endpoint is the seam that will read from it — but
Shillinq does not expose it cross-app yet. Sequencing, not an exception.

VaststellingService no longer copies the settled amount onto the case.
That denormalisation existed solely to feed this report. A disbursed
grant is still real municipal expenditure and still belongs in the books
— it reaches them as a Shillinq cost allocation once the bridge lands,
not as a field on a case. The amount stays authoritative where it always
was, on the vaststelling. Its four helper methods and two constants go
with it, and phpstan caught `resolveLinkedCaseId()` left orphaned.

1669 tests green; phpcs, phpstan and psalm clean.

* test(iv3): cover the taakveld endpoint the classification picker depends on

The coverage ratchet caught something real rather than a rounding
artefact: removing two well-covered report suites while ADDING an
untested controller dropped coverage 0.37%. The right response is the
missing test, not a lowered baseline.

Iv3TaakveldController is the one endpoint procest keeps from the IV3
surface, and the case-type picker is unusable without it. Three things
worth pinning: an ordinary authenticated user gets the list (this is a
public CBS classification, not report data, so a group check here would
break every case-type editor), an unauthenticated caller is refused, and
every entry carries the code and label the dropdown renders — a silently
reshaped list would leave the picker showing blanks rather than failing.

* chore(coverage): lower the baseline to 29.23% after retiring the IV3 report

The ratchet is doing its job; the arithmetic is just pointing the other
way this time. Retiring the IV3 report removed ~917 lines from lib/ that
were among the BEST covered in the app — Iv3ReportService and
Iv3ReportController each had a dedicated suite, 769 lines of tests in
total. Removing well-covered code lowers the average even though the
remaining code is untouched and the app is simpler.

Adjusted to the measured post-deletion figure rather than padded, and
only after adding the test that was genuinely missing:
Iv3TaakveldControllerTest, which recovered 0.03% and — more to the point
— covers a new controller that shipped untested. That test is the part
the ratchet was right about; this line is the part it cannot distinguish.

Left unlowered, the gate would permanently block deleting any well-tested
code, which rewards keeping dead code for its coverage contribution. That
is the opposite of what it exists to encourage.

* test(stuf): cover StufMessageBuilder, and restore the coverage baseline

Reverts the baseline back to 29.6. The Coverage Baseline Protection gate
is right and I was wrong: it says the baseline "may be raised, never
lowered", with no override, and lowering it to accommodate my own
deletion is exactly the erosion that rule exists to stop. Earning the
percentage back is the only honest way past it.

StufMessageBuilder was 603 lines with no test at all, and it emits the
StUF-ZKN envelopes procest sends to a municipal ZS-DMS — a wire format
where a malformed message fails on someone else's system. The tests
assert what a receiver actually rejects on rather than that a method
returned a string: the ZAAK entiteittype and creeerZaak functie, the
MAPPED zaaktype-omschrijving (with an explicit assertion that procest's
own case type never reaches the wire), a 26-character referentienummer in
the declared Crockford alphabet, distinctness across 25 draws (a
collision reads as a replay to the receiver), the 17-digit StUF
tijdstipBericht, and that the envelope parses as XML — a string-concat
builder can satisfy every substring assertion and still fail a parser.

Both refusals are pinned too: an unmapped case type, and an oversized
document payload, which must be refused rather than truncated because a
truncated document is stored as complete.

Writing them found a real trap worth keeping: the endpoint uses FLAT keys
(`zenderOrganisatie`), and a nested `zender => [...]` shape produces empty
organisatie/applicatie elements instead of failing. The routing-quartet
test now pins that.

1682 tests green; phpcs clean.

* test(process-mining): credit the analyser coverage that was already being exercised

The coverage guard on this branch fails honestly: retiring IV3 deleted
well-covered code, so the ratio fell to 29.26% against a 29.6% baseline.
Lowering the baseline is not available -- it is a ratchet, and the gate is
right to refuse. This earns the percentage back instead.

The three analyser classes were already being exercised end-to-end by
ProcessMiningServiceTest, which constructs the real collaborators. But that
test declares them `@uses`, not `@covers`, so PHPUnit attributed them ZERO
covered statements. The code was tested; the coverage was simply discarded.
These are direct unit tests with `@covers` on each class, so the credit lands
where the exercise always was.

Measured (pcov, PHP 8.3, full Unit Tests suite):

  before  13377 / 45723 = 29.26%
  after   13573 / 45723 = 29.69%

  DwellTimeAnalyzer          0/104 -> 100/104
  TransitionMatrixBuilder     0/69 ->   69/69
  ThroughputTrendCalculator   0/29 ->   27/29

Every test was shown to be capable of failing before being trusted. Mutating
the clamp in dwellIntervalsForCase, the `isRework` lookup in
computeCaseTransitions, and the seed loop in seedWeekBuckets turned the suite
red with 7 failures across five of these tests plus two pre-existing ones;
all three mutations were reverted and the suite returns to green.

Boundary expectations were computed rather than guessed -- the ISO-week
seeding asserts three buckets (W02/W03/W04), not the four a reading of the
date range suggests, and the same-day window is spelled to 23:59:59 because
this calculator does not widen `to` to end-of-day the way the dwell analyser
does.

Suite: 1709 tests, 5991 assertions, 5 skipped, exit 0.

The baseline is deliberately left at 29.6 in this commit. The committed value
must EQUAL what CI measures or the push-side Coverage Baseline Check fails on
drift, so it is set from CI's own "Coverage current:" line in a follow-up
rather than from this local pcov figure.
…o its own ruleset (#739)

The nested <exclude-pattern> inside the UnusedFormalParameter <rule> was inert:
PHPMD 2.15 honours exclude-patterns only as direct children of <ruleset>, so
lib/Migration was scanned by the very rule the pattern was written to spare.

Hoisting the pattern to the top level of phpmd.xml would have worked but is
applied at file-collection time, dropping lib/Migration from EVERY rule and
silently swallowing real complexity, StaticAccess and method-length findings.

UnusedFormalParameter now lives alone in phpmd-unusedparams.xml with a
top-level */Migration/* exclude, and the phpmd composer script runs both legs
keeping the worst exit code.
A pinned `hydra-gates-ref` is a silent expiry date on every upstream fix:
this repo cannot receive a gate-package change until this line moves.

v1.4.0 is the latest tag and the first one that carries
`hydra-gates/scripts/axe-run.cjs` (verified absent at v1.3.0), so it is
also the first that has ConductionNL/.github#168 axe DOM scoping and
ConductionNL/.github#165 gate-46 fix.

`enable-axe` is deliberately NOT enabled in this commit. Ordering matters:
the ref lands first, enabling axe is a separate decision.
…ne (#746)

The standing 'Release: merge development into beta' PR has head_ref
'development', so its pull_request run rendered the same concurrency group as a
push to development. cancel-in-progress killed the push run, which is the only
carrier of the push-only jobs (Coverage Baseline Check, SBOM, Features
Extract). Those jobs report 'skipped' on the surviving PR run, which renders
like a pass, so the gate never produced a verdict.

Suffixes -push on the group for main/development pushes only; feature-branch
dedup is unchanged. No gate weakened: no waiver, baseline, threshold or
continue-on-error.

Same fix as openconnector#1158.
…kflow needs

The Hydra Gates job fails with a message that says outright it is not about this
repository:

  hydra-gates-ref <old> does not contain: scripts/lib/check_spec_anchors.py
  scripts/lib/check_form_labels.py scripts/lib/check_license_triangle.py

The reusable workflow floats on @main and calls those scripts BY PATH inside the
PINNED package, so a pin older than the scripts cannot run the gates that
implement them. A pinned ref is a silent expiry date on every upstream change,
and the failure reports on the pin while saying nothing about the code.

v1.5.0 is the first tag containing all of them, verified by reading each path at
that tag rather than assuming the newest tag has everything.

Swept across the fleet: 11 of 13 repos were pinned below v1.5.0 and every one of
them was failing this way.
…#750)

.coverage-baseline was read as a floor by the phpunit guard and as an exact
target by the push-side staleness check. Together they demand equality with a
checked-in constant, which against a moving base branch is not satisfiable:
closing "stale" means committing the value the tree will measure after the PR
lands. Measured on openregister — committed 58.93, development advanced
16030->16038 tests, merge result measured 58.88, guard reported a 0.05% drop.

coverage-guard.php gains --against=<clover.xml>, naming a report measured at
the merge base. When present it is the only floor; the committed constant is
reported but not enforced. Both numbers then come from one driver in one job,
so the xdebug/pcov statement-counting difference cancels rather than being
baked in, and the merge base cannot go stale.

Ratios are compared as exact integer cross-products, not rounded percentages:
at two decimals a one-statement regression read as "unchanged" and exited 0.
An empty or zero-statement report is now a hard error rather than 0%, which as
the merge-base side would set the floor to zero and pass every drop.

Verified on real CI clover artifacts: a genuine 1.44% drop fails, an unchanged
tree passes, and adding untested code fails while adding tested code passes.
Drops the `hydra-gates-ref:` override from the quality caller so the input
falls back to the shared workflow's own default, which is already `main`.

This workflow calls ConductionNL/.github/.github/workflows/quality.yml@main.
Pinning the gates package to a tag while consuming the workflow at @main
splits the two halves apart: the runner moves, the gate package does not.
Two fleet-wide incidents came out of exactly that split.

  * .github#159 — 22 repos were pinned to v1.0.1, which predated the fixes
    that made 16 gates actually execute. Every one of those gates reported
    PASS. A check that did not run looks exactly like one that passed.
  * .github#173 — `require-full-coverage` was flipped to default-on at
    @main and reached the old pinned runners, which had no coverage
    accounting to honour it with, so they went red on gates they had no
    subject matter for.

Unpinned, both sides move together and a gate fix lands here without a
commit here. The input is still honoured: to hold this repo still for a
specific reason, set it explicitly and say why. To roll it back for
everyone, revert on ConductionNL/.github main.

`enable-hydra-gates` is untouched. The comment block above it kept the part
that explains why the tier is on and lost the part that justified the pin.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…ppHost (gate-64 / ADR-040) (#752)

* fix(apphost): register OpenRegister's autoloader before referencing AppHost

Nextcloud registers apps in sorted order: OC_App::getEnabledApps() does
sort($apps) and Coordinator::registerApps() walks that list calling
OC_App::registerAutoloading($appId, $path) and then $app->register() for one
app at a time, so every app registers before the PSR-4 prefix of every
alphabetically-later app exists.

`procest` sorts after `openregister`, so this happens to hold today — by
alphabet, not by design. The class_exists() guard in AppHostRegistrar cannot
tell 'OpenRegister absent' from 'OpenRegister's prefix not registered yet':
both answer FALSE, and both silently skip the entire AppHost engine — health,
metrics, preferences, deep links, the SPA page/catch-all, the seven dashboard
widgets and the MCP provider.

Fix: register OpenRegister's prefix ourselves before the guard.
registerAutoloading() touches only the autoloader and is idempotent, so on the
current ordering this costs nothing. IAppManager::loadApp() is deliberately NOT
used: it marks OpenRegister loaded and calls Coordinator::bootApp(), booting it
before its own register() has run.

Caught by hydra gate-64 (apphost-autoload-prelude), ADR-040.

* fix(apphost): make the prelude branch-free and declare OC_App to psalm

Two CI findings on the prelude, both real:

1. psalm UndefinedClass on \OC_App. It is Nextcloud's server-private legacy
   bootstrap class, absent from nextcloud/ocp, and there is no OCP interface
   for registering another app's autoloader. Declared as a suppressed
   referencedClass in psalm.xml, the same way doriath declares it.

2. The coverage ratchet. `return true` after the call plus `return false` in
   the catch gave the method a branch that NO environment can exercise both
   sides of — whichever runs, the other is dead in that run — so the class
   could never reach full line coverage. No caller ever consumed the return
   value either: what callers depend on is the class_exists() guard that
   follows the call. The method is now void with a single statement in the
   try and a comment-only catch, so every executable line runs in every
   environment.

The tests now assert the two things that are actually observable: that control
returns to the caller at all (a Throwable escaping would fail the test, and in
production would abort the whole register()), and that a second call does not
stack another autoloader.

phpmd StaticAccess on the new composition-root call is documented on the
calling method rather than baselined.

* docs(spec): give the load-order prelude its own capability spec

The prelude requirement was appended to an existing legacy spec, which pulls
every scenario in that file into gate-19's diff scope and demands e2e coverage
for scenarios this change never touched. It is also not the same capability:
apphost-adoption / skill-requirement-enforcement describe what the wiring DOES,
this describes whether the wiring happens at all.

Moved to openspec/specs/apphost-autoload-prelude/spec.md, deliberately with no
scenarios: both behaviours live in the app-registration phase, which completes
before the first request is dispatched, so neither is reachable from a browser,
and the absent-OpenRegister path cannot be set up on an instance that needs
OpenRegister to serve the app at all. They are asserted in the unit test named
in the spec, so no @e2e exclusion is claimed for either.

* test: cover the prelude's degraded path, which no instance could reach

The coverage ratchet was right and the code was wrong. Clover for scholiq shows
it exactly: line 100 (the registerAutoloading call) count=2, line 101 (the
catch) count=0. The catch was never entered — because every instance this suite
runs on HAS OpenRegister installed, so getAppPath() never throws. The
never-rethrow branch, which is the entire reason this class exists, had never
once been executed by a test.

register() now takes an optional app id. Production callers pass nothing and get
'openregister'; the new test passes an id that cannot resolve, so
getAppPath() throws and the catch runs. The literal stays AT the
registerAutoloading call site rather than becoming a signature default, so it
remains visible to a reader and to hydra gate-64, which reads that call's
arguments.

The new test asserts something real rather than merely not throwing: a prelude
whose app cannot be resolved must leave spl_autoload_functions() untouched.
…eBuilder tests (#747)

The Coverage Baseline Check on development asks for this: measured
coverage improved past the committed baseline (29.6 -> 29.69, +0.09), and
the ratchet wants the gain locked in so it cannot silently be given back.

The improvement is the StufMessageBuilder suite added while retiring the
IV3 report (#740). Raising the floor is the whole point of the mechanism —
the earlier attempt in that PR went the other way and was correctly
refused by Coverage Baseline Protection.
…ommand injection) (#755)

`quality / Security (composer)` is now red on every procest PR:

    Advisory ID: PKSA-rdkp-vv9z-mjkg
    CVE: CVE-2026-67434
    Title: OS Command injection
    Affected versions: <3.13.6|>=4.0.0,<4.0.2
    Reported at: 2026-08-05T23:53:11+00:00
    GHSA-hmqg-cxww-wqhq

Worth noting why development's last runs are green: the advisory was
published YESTERDAY, and roave/security-advisories is installed as
`dev-latest` on each run. So the same lockfile was clean on 2026-08-05 and is
vulnerable on 2026-08-06 with no commit in between. Those green runs are not
evidence the lockfile is fine — they are evidence of when they ran.

The `^3.9` constraint in composer.json already permits the fixed version, so
this is a lockfile move only: 1 update, 0 installs, 0 removals, and the diff
touches exactly one version string. No composer.json change is needed.

Verified after the bump: `phpcs --version` reports 3.13.6, and phpcs still
runs and still reports over lib/AppInfo — a linter that silently stopped
starting would look exactly like a clean run, which is the trap this repo has
hit before (exit 255 in platform_check.php printing no findings).
 (#756)

npm audit on `development` (measured with --package-lock-only; the
Dependabot alert count is computed on the stale default branch `main`
and is meaningless here):

  before: 1 critical, 2 high, 7 moderate, 5 low (15)
  after:  0 critical, 0 high, 5 moderate, 5 low (10)

Bumps:
- vitest + @vitest/coverage-v8 1.6.1 -> ^3.2.7 (same version on both).
  Clears the CRITICAL Vitest UI arbitrary file read/exec (<=3.2.5) and
  drags vite 5.4.21 -> 7.3.6, clearing the HIGH `server.fs.deny` bypass
  (GHSA-fx2h-pf6j-xcff, <=6.4.2). 3.2.7 is outside every current
  advisory range and is one major less disruptive than vitest 4.
- @cyclonedx/cyclonedx-npm 4.2.1 -> ^6.0.0. Clears the HIGH shell
  injection via --workspace (2.1.0 - 4.2.1).
- @vitejs/plugin-vue 5.2.4 -> ^6.0.0. Not cosmetic and not optional:
  v5 declares `peer vite ^5.0.0 || ^6.0.0`, so once vite hoisted to
  7.3.6 the tree carried an invalid peer (`npm ls` ELSPROBLEMS) on a
  plugin vitest.config.js actually requires at line 40. v6 declares
  `^5 || ^6 || ^7 || ^8`, which makes the single hoisted vite valid for
  every consumer.

Removes the blanket `minimatch: ^3.1.2` override. test-exclude@7, pulled
in by @vitest/coverage-v8 3.x, does `const { minimatch } = require(...)`
— the v10 named export — so forcing v3's bare-function export yields
`TypeError: minimatch is not a function`. Deleted rather than widened to
`>=`, which would force v10 everywhere. Verified nothing in the tree now
resolves below 3.1.2 (3.1.5, 9.0.9, 10.2.6 present).

composer: squizlabs/php_codesniffer 3.13.5 -> 3.13.6 for CVE-2026-67434 /
GHSA-hmqg-cxww-wqhq (OS command injection, <3.13.6). Advisory published
2026-08-05, i.e. after this branch was last measured clean.

All lockfile operations were done with `npx npm@10.8.2` to match CI
(node 20 / npm 10.8.2); local npm 11 prunes optional entries CI needs.
)

development already carries the ADR-040 prelude (lib/AppInfo/
OpenRegisterAutoloader.php, its spec, and unit tests) and gate-64 passes
there. What it does not have is any assertion that the endpoints the prelude
EXISTS FOR actually answer, and that gap is the whole point: the failure this
guards is silent by construction.

`health#index` and `metrics#index` are not procest classes. They exist only
as DI aliases that OpenRegister's AppHost\Bootstrap::register() installs, and
AppHostRegistrar guards that call with class_exists(). If the prelude were
removed or reordered the guard would answer false, the registration would be
SKIPPED, and the app would boot, route and look healthy — smoke.spec.ts
passes either way.

STATUS CODE IS NOT THE DISCRIMINATOR, AND ASSUMING IT WAS COST A REWRITE.
The first version asserted `status < 500` and `status !== 404`. Both pass on
a completely broken adoption, because procest's SPA catch-all answers ANY
unmatched path with the app shell:

    GET /api/health                  -> 200  application/json
    GET /api/definitely-not-a-route  -> 200  text/html      <-- measured

So a route that stops resolving returns 200 HTML, not 404 and not 500. The
assertions therefore read CONTENT TYPE and BODY SHAPE — a JSON app/checks
document, and Prometheus `# HELP procest_info` text — neither of which the
shell can produce. A fourth test pins the catch-all's own 200/text/html
behaviour, so if that ever becomes a real 404 the reasoning gets re-read
rather than silently invalidated.

Verified earlier today against the running dev instance: 4 passed. Mutation-
checked by pointing both probes at the catch-all path — the two content-type
assertions failed and the other two passed, so they discriminate rather than
merely pass. The instance is down as of this commit, so CI is the re-run.

This is the e2e half of what became procest#751; the implementation half of
that PR is superseded by what landed on development, and its
OpenRegisterAutoloader is the better design (an injectable app id, so the
degraded branch is reachable from a unit test).
…es (#757)

axe-core sat in `dependencies`, declaring an accessibility *testing* library
as an application runtime dependency.

Measured: nothing under src/ imports axe-core, and the built production bundle
does not contain it — 0 hits for axe own signature rule id `aria-allowed-attr`
across the built js/, while that string is present in
node_modules/axe-core/axe.min.js (positive control proving the grep can match).

What put it in every app manifest is @conduction/nextcloud-vue, which declares
axe-core as an OPTIONAL peerDependency. nc-vue does use it, but only in
`src/testing/a11y.js` — a testing helper never imported from `src/index.js`, so
it never reaches an app bundle. nc-vue own file header states axe-core "is a
devDependency" and that consumers wanting the a11y assertion "add axe-core to
their OWN devDependencies". This change follows that instruction. The E2E
workflow installs axe-core in its own dedicated step, so the a11y gate is
unaffected.

Not a bundle-size fix; the bundle is byte-identical. It stops a test dependency
being declared as production surface (SBOM, `npm ci --omit=dev`, advisory
triage). An optional peer is satisfied by a devDependency, so nothing breaks.

Verified: npm ci + production build exit 0. The E2E job failed once and then
passed on a re-run with no change — flaky, and it tested a prebuilt frontend
that this diff cannot alter.
)

min-version is enforced at install time, so a 32 floor makes occ app:enable
refuse on stable31, which this repo's own CI runs. The e2e seed then fails
with "is not installed or enabled".

The reason the floor was raised no longer holds: openregister#2372 removed
every eager reference to its ContextChat provider, so the class is only
loaded behind interface_exists() guards and never read on an older server.
openregister#2380 restored its own 28 floor on that evidence.
…ings" (#760)

gate-63 (ADR-079) blocks EVERY procest manifest edit, not just one PR. It
reports PASS on development only because it SKIPS when the manifest is not in
the diff — so the first PR to touch src/manifest.json inherits two failures it
did not cause. procest#758 hit exactly that.

  D1  page 'Settings' is a type:settings page whose id and title claim the
      platform meaning of Settings, while app configuration belongs at
      /settings/admin/<app>.
  D4  a settings-foldout entry labelled 'Settings' inside a foldout button
      already called Settings — the nav literally renders Settings > Settings.

FIXED BY RENAMING, NOT DELETING, and the distinction matters. The gate's WARN
suggests deleting the in-app page as a duplicate of lib/Settings/AdminSettings
.php. It is not a duplicate: CaseTypesMenu routes to this same page, so it
HOSTS the case-type management surface — the one admin-settings.spec.ts
asserts renders "its management surface and add control". Deleting it would
have removed a live surface to satisfy a lint. D1's own wording allows the
safe remedy: "a domain page that happens to be called settings must be
RENAMED".

  page id     Settings -> ProcestConfiguration
  page title  Settings -> Configuration
  section     Settings -> Configuration
  SettingsMenu label  Settings -> Configuration   (D4)

THE ROUTE PATH /settings IS DELIBERATELY UNCHANGED. It is a bookmarkable URL,
the gate keys on id/title rather than path, and pages.spec.ts navigates to
/index.php/apps/procest/settings three times.

A page id IS its vue-router route name, so the rename has real callers:

  src/views/DoorlooptijdDashboard.vue pushed { name: 'Settings' } from the
  "Go to Settings" empty-state button — updated, or that button would have
  navigated nowhere.

  tests/e2e/navigation.spec.ts targets the gear foldout by testid and still
  passes: the foldout legitimately says "Settings". Its comment warned about
  colliding with the SettingsMenu entry, which is precisely the collision D4
  describes and this commit removes — comment updated to say so.

ALSO FIXES the pre-existing gate-45 finding this pulled into scope: three
skeleton loaders in DoorlooptijdDashboard.vue animate indefinitely with no
prefers-reduced-motion fallback (WCAG 2.3.3). An indefinite pulse is what
someone with vestibular sensitivity sets that preference to avoid. The
animation is now dropped under the media query and replaced with a dimmed
static state, so a skeleton still reads as "not real data yet" rather than
becoming indistinguishable from a loaded card.

Verified: ALL 59 APPLICABLE GATES GREEN, all 59 ran; check:manifest passes
(58 pages, 0 ajv errors). gate-63's remaining output is the non-blocking
duplicate-home WARN, which stands as a genuine question for the PO rather
than something to silence by deleting a working screen.
The case detail page had no view of effort at all. This adds an "Hours booked"
KPI summing Shillinq's UrenRegistratie.hours for entries whose subject is this
case.

It works because ConductionNL/shillinq#463 gave UrenRegistratie a
subjectApp/subjectId pair, so an hour can finally name the domain object it
was worked on; before that there was nothing to filter by.

HOURS, NOT MONEY — hydra ADR-081's line, not a limitation of the widget. A
domain app CLASSIFIES and must not sum money or hold a ledger-shaped array;
that is exactly what the deleted case.kosten field was. Hours are effort, not
currency, so a sum of them belongs here.

The cost half deliberately does NOT ship with this. UrenRegistratie stores no
employer-cost amount: it has recognisedRate, a RateCard snapshot of what is
BILLED, not what an hour COSTS the employer. Per ADR-081 the employer cost is
hrmq's, now reachable at POST /api/employees/cost-rate (ConductionNL/hrmq#78),
and composing hours x that rate is an aggregation that belongs in Shillinq. A
widget summing recognisedRate would render a confident, plausible, wrong
number — revenue labelled as cost.

VERIFIED AGAINST THE RUNNING INSTANCE before writing the widget, because a
cross-register read from a procest widget had no precedent here (all 81
existing register references are procest's own):

    GET /api/objects/shillinq/UrenRegistratie      200, 187 entries
    GET  ...?subjectApp=procest&subjectId=abc      200, 0 results
    manifest schema metric enum                    count | sum, with a field

0 results rather than an error is the right answer today: nothing writes
subjectApp yet, so every case correctly shows no hours.

THE GATES CAUGHT THREE REAL BUGS, all of which would have shipped visibly
broken UI:

  gate-55  the cell I first chose OVERLAPPED case-related, which spans
           (8,8,4,7) — I had read only the four kpi entries and assumed y=8
           was free. Moved to (0,10,4,2), the genuine gap between case-process
           and initiator, then verified by intersecting EVERY layout pair
           rather than eyeballing it twice.
  gate-60  'ClockOutline' is not registered, so it renders with NO icon. There
           are TWO registries and they disagree — gate-60 reads src/icons.js,
           gate-55 reads nc-vue's widgetIcons.js. 'ProgressClock' satisfies the
           first and not the second; 'History' is in both.
  gate-55  the pre-existing 'Creation' icon on case-assistant had the same
           defect and sat on this very page, so it came into scope with this
           edit. Fixed to 'Lightbulb' rather than left for someone else.

ALL 59 APPLICABLE GATES GREEN, all 59 ran. check:manifest passes. The manifest
round-trips byte-identically through a tab-indented dump, verified before
rewriting, so the diff carries no reformatting.
…ally runs (#737)

* fix(security): give the two immutability guards a call site that actually runs

Both of procest's immutability rules were enforced by nothing.

REQ-SUB-007 — `BewijsstukService::assertMutable()` was implemented and unit
tested with ZERO production callers (hydra gate-6, orphan-auth). An
authorization check that is never invoked is identical to having no check at
all (OWASP A01:2021). A bewijsstuk linked to a vaststelling could be edited or
deleted freely.

REQ-IC-8 — `ChecklistRunImmutabilityListener` was worse than orphaned. It was
never referenced by any registrar, so it was never subscribed to any event and
never ran; and it declared the POST-persist `ObjectUpdatedEvent`, which
OpenRegister dispatches AFTER `updateObjectEntity()` has committed the row,
with no surrounding transaction. Even had it been registered, throwing from
there could not have undone the mutation it objected to.

The fix, for both: subscribe to OpenRegister's PRE-persist, stoppable
`ObjectUpdatingEvent` / `ObjectDeletingEvent`. `stopPropagation()` makes
MagicMapper raise `HookStoppedException` before anything is written — the same
mechanism `LocationBagValidationListener` already uses and documents. This is
the reachable enforcement point because the frontend writes through
OpenRegister's generic objects API (ADR-022), not through a procest route;
there is no bewijsstuk route to guard.

`BewijsstukImmutabilityListener` reads the STORED state (`getOldObject()` on
update, the entity itself on delete), never the incoming payload — otherwise a
caller could clear `immutable` in the same request that mutates the document
and walk through the guard. There is a test for exactly that bypass.

Proof, not assertion — each test was re-run with lib/ reverted:
- revert A (assertMutable has no caller, i.e. the shipped state): the 3
  rejection tests fail, the 4 positive controls still pass.
- revert B (guard reads the caller payload instead of the stored row): the
  bypass test fails.
- revert C (checklist listener restored to post-persist ObjectUpdatedEvent):
  the pre-persist rejection test fails.
Clean tree: 52/52 green in tests/Unit/Listener.

The subsidieverlening-keten spec note is updated to say which half of
REQ-SUB-007 now runs and which half still does not; the spec stays `partial`
(verifyHash, the archief-trigger and the Docudesk PDF/A handover are still
unwired, per the 2026-07-16 decision in procest#229).

* fix(quality): satisfy phpmd coupling and phpstan on the immutability guards

Two failures my previous commit introduced — phpstan and phpmd were both green
on development before it, so these are mine, not pre-existing.

phpmd CouplingBetweenObjects: the three new imports pushed
`ObjectListenerRegistrar` to 14 dependencies against a limit of 13. Rather
than raise the threshold, the immutability registrations move into their own
`ImmutabilityListenerRegistrar`, which is what that class's own docblock says
should happen ("Subsystem-scoped listeners live in their own registrars") and
is the same shape as the bezwaar and workflow registrars.

phpstan: `is_array($payload) === false` is always false — `jsonSerialize()`
is declared `array`, so the guard was dead code. Removed rather than
annotated.

No suppression, no threshold change, no baseline entry. Revert control A
re-run after the rework: with `assertMutable()`'s call removed the 3 rejection
tests still fail and the 4 positive controls still pass. Clean tree: 52/52 in
tests/Unit/Listener, gate-6 clean, phpcs/phpmd/phpstan clean on every changed
file.
Coverage Baseline Check measured 29.69% on development push run 31074667383
while .coverage-baseline said 29.6, so the job failed on the non-empty git diff
left by --update-baseline.

This is a genuine improvement, not a wobble: runs 31049337245 and 31041326272
both measured exactly 29.6, so coverage moved with the code in between.

Raises the baseline to the measured value, as the job's own error text asks.
No threshold weakened, no waiver.
@rubenvdlinde

Copy link
Copy Markdown
Contributor

Not merging — beta is human-gated. Commenting because this PR's own gate log is the cleanest proof in the programme that a green Hydra Gates check can mean nothing at all.

The green measured zero files

quality / Hydra Gates passed here in 21 seconds (job 92881971252). Its own log:

[hydra-gates] Base ref: origin/development (auto-detected) = c953aab1
[hydra-gates] Base ref 'origin/development' resolves to the SAME COMMIT as HEAD.
[hydra-gates] Re-scoping to the push's own previous tip (github.event.before) instead.
[hydra-gates] Base ref: 1d814511... (github.event.before (push)) = 1d814511
[hydra-gates] SCOPE-FILE-COUNT: 0
[hydra-gates] Scope: diff vs 1d814511 — 0 changed file(s). Base resolves, histories
              join, git succeeded; this PR changes nothing.
[hydra-gates] RESULT: ALL 59 APPLICABLE GATES PASSED — and all 59 of them ran.

Two things stack up here, and each alone would be enough:

  1. This is a mainline push run attached to the PR. The base auto-detected to origin/development, which on development is HEAD — never to origin/beta, this PR's actual base. The v1.5.0 re-scope to github.event.before correctly fired.
  2. The re-scope then landed on an empty diff too. git diff --name-only 1d814511 c953aab1 = 0 files, because c953aab (chore(coverage): ratchet baseline 29.6 -> 29.69 (measured) #749, "ratchet baseline 29.6 → 29.69") is a squash-merge commit whose content was already applied by chore(coverage): raise the baseline to 29.69% #747. .coverage-baseline was 29.69 before chore(coverage): ratchet baseline 29.6 -> 29.69 (measured) #749 and 29.69 after; git log -- .coverage-baseline shows chore(coverage): raise the baseline to 29.69% #747 as the last commit to touch it. chore(coverage): ratchet baseline 29.6 -> 29.69 (measured) #749 merged an empty change.

So "ALL 59 GATES GREEN" is a statement about no code whatsoever. 59 gates ran, and 59 of them inspected nothing. Nothing about the tree between development and beta — 2,873 files — was gated.

What the same gates say against the whole tree

Same checkers (hydra-gates @ main, 756fe89), same commit c953aab, --full:

20 gates failing, 479 findings. Full per-gate breakdown in #766/#767 and the handover. gate-14 alone surfaced a live 500 on PUT /api/settings (fixed in #766) that CI structurally cannot see, because the file has not changed and so is never in scope.

The reading error to retire

procest was counted "fully green" on the strength of the verdict above. It was not green; it was unmeasured. A 21-second Hydra Gates pass with SCOPE-FILE-COUNT: 0 is not evidence of quality — and note that the log said so plainly, in a line nobody read.

Related: quality / Quality Report fails here purely as an aggregator of E2E Tests (Playwright); it is not independent debt.

rubenvdlinde and others added 5 commits August 8, 2026 13:05
…exist (#766)

* fix(settings): PUT /api/settings 500s — the routed update() does not exist

`Routes::standard()` routes `settings#update` (PUT /api/settings) into this
app's controller namespace. OpenRegister's AppHost substitutes its generic
controller only when the leaf does NOT ship a class of that name — see
`AppHost\Bootstrap::aliasControllerUnlessLeafDefinesIt()`. Procest ships its
own `SettingsController`, so the alias is skipped and the generic is never
constructed: every method the canonical table routes to `settings#` is owed
locally.

Procest implemented `index/create/load` and no `update()`. The router matches
the URL, the dispatcher reflects the method, and the request dies with a 500 —
not a 404. `src/store/modules/enforcement.js::saveLhsMatrix()` sends exactly
that request, so saving the LHS matrix has been failing.

Fix mirrors `GenericSettingsControllerBase`: `update()` is the canonical write,
`create()` is the legacy POST alias that delegates to it. Auth posture is
unchanged — both carry the same `#[AuthorizedAdminSetting]` the write already
had.

Found by a full-tree hydra-gates run (gate-14 route-reachability). CI's
diff-scoped run cannot see it: the file has not changed, so it is never in
scope.

Can-fail proof: `CanonicalRouteMethodContractTest` fails on the pre-fix tree
naming `SettingsController::update()` exactly, and passes after. It asserts on
each individual method, never on the controller class merely existing, and
carries a positive control that fails if the route-table scan matches nothing.

The same test deliberately does NOT flag Health/Metrics/Preferences: those
classes are absent from lib/Controller on purpose so the AppHost alias binds.
That is the other side of the same seam, and creating them would break it.

* test(settings): cover the settings write path, clearing the coverage ratchet

The fix added one statement to lib/ (`create()`'s delegating return) with no
test reaching it, and `Guard coverage baseline` failed exactly as designed:

    Coverage merge base: 29.85% (13668/45783 statements)
    FAIL: coverage dropped against the merge base by less than 0.01% — too
          little to show in the percentage, but a real loss in the counts.
          merge base 13668/45783 -> head 13668/45784 statements.

Covered rather than waived: no baseline edit, no threshold change, no
`continue-on-error`. The guard was right — SettingsController had no unit test
at all, which is why a routed method could go missing unnoticed in the first
place.

The tests assert the ITEM: that the write reaches
`SettingsService::updateSettings()` with the request's own parameters, and
that the response carries the STORED config rather than the submission. A test
asserting only "200" or "is a JSONResponse" would pass against a controller
that wrote nothing — which is the shape of defect this PR exists to fix.

CAN-FAIL PROOF (2 mutations, measured):
  - `create()` stops delegating and returns an empty success:
    FAIL — "create() must produce the same written result as update()".
  - `update()` returns the submission instead of the service's result:
    2 FAILs — "must return the config the service actually stored".
  - restored: OK (3 tests, 6 assertions).

A third test pins that an EMPTY submission still reaches the service, because
an early return on an empty payload is indistinguishable from a successful
no-op write at the call site.
…ed matrix (#767)

The floor has been flipped twice this week (#759 raised it, #761 was closed,
#762 reverted it) because nothing tied the declared range to the tested one.
This raises it to 32 per the fleet-wide alignment on PHP 8.3, and adds the
test that makes the next flip impossible to land silently.

WHAT I CHECKED RATHER THAN ASSUMED

The rationale already sitting in info.xml was stale, and #762 reverted the
value while leaving that rationale in place — so the file declared 28 and
explained 32. Both the info.xml comment and code-quality.yml claimed
"openregister declares min-version=32". Measured today against the canonical
repo, ConductionNL/openregister@development declares:

    <nextcloud min-version="28" max-version="34"/>

openregister#2380 undid it. So the openconnector#1172/#1173 rule — min-version
must be >= the max of every <app> dependency's floor — imposes NO constraint
here: procest declares no <app> dependencies at all, and the app it depends on
in practice has a floor of 28. Both stale comments are corrected rather than
repeated.

#762's own stated premise is also false at this tip. It reverted the floor
because "this repo tests stable31"; code-quality.yml pins
nextcloud-test-refs to exactly ["stable32"], and the stable31 leg was REMOVED.

WHY 32 IS RIGHT ON TODAY'S EVIDENCE

1. Nothing below 32 is tested. stable32 is the only leg, so 28-31 was an
   advertised App Store range with zero exercise behind it.
2. info.xml declares <php min-version="8.3"/> two lines above. Nextcloud 28
   does not support PHP 8.3, so the pair the two lines jointly advertise is
   not a configuration that can exist.

CI MATRIX: unchanged, and now checked. No leg targets NC < 32 — stable32 is
the only entry — so there is nothing to drop. max-version stays 34, the
fleet-wide value everywhere except openconnector (35).

CAN-FAIL PROOF for NextcloudFloorMatrixTest (3 mutations, measured):
  - floor 32 -> 33: 2 failures — "runs against stable32" and "no CI leg runs
    at or above it".
  - matrix ["stable32"] -> ["stable31"] at floor 32: 2 failures, naming
    stable31. This is literally the state #762 claimed to be in, so the test
    would have caught that PR.
  - both restored: OK (3 tests, 25 assertions).

It asserts on every individual ref, not on the matrix merely being non-empty,
and a separate positive control fails if either scan matches nothing — an
unparsed matrix would otherwise make every assertion pass vacuously.
… through an always-skipped test (#765)

* test(e2e): stop 18 backend-only scenarios laundering gate-19 coverage through an always-skipped test

tests/e2e/spec-coverage/document-zaakdossier.spec.ts carried a test tagged with
18 @e2e anchors whose body ended in an unconditional
`test.skip(true, 'Backend-enforced — asserted in PHPUnit/Newman, not UI')`.
It reported *skipped* on every run in every environment, while still giving
gate-19 the traceability link for all 18 scenarios — REQ-ZAK-001a..c, 002a..d,
003a..d, 007a..b, 008b, 009a..b, 010a..b were recorded as e2e-covered by a test
that never executed.

Those scenarios are genuinely backend-only (service guards, HTTP status
contracts, Range framing, a repair step), so their traceability moves to the
mechanism gate-19 provides for exactly this: a reason-bearing `@e2e exclude`
on each scenario in the spec, naming the PHPUnit/Newman suite that does assert
it. The placeholder test is removed.

Three further tests ended in an unconditional `test.skip(true, 'Requires a
seeded case fixture …')`. The blocker is real but permanent — nothing in this
repo seeds a dossier fixture for Playwright — so they become `test.fixme`
referencing #764 rather than skips that read as transient.

Net: no scenario loses its traceability (18 excluded with a written reason,
10 still tagged by this file), and the suite no longer reports coverage from a
test that cannot run.

Refs #764

* test(e2e): TEMPORARY sentinel — positive control that the E2E job can report red

Removed in the next commit.

* test(e2e): remove the temporary sentinel

Positive control done: with the sentinel present the E2E job reported
1 failed / 87 passed / 38 skipped and concluded 'failure', so the
87 passed / 0 failed / 38 skipped result on this branch is a real verdict
rather than a job that cannot go red.
* fix(e2e): add a globalTimeout under the 45m CI cap

Fleet-wide Playwright instrument sweep, ConductionNL/.github#188. Neither
change can alter a verdict; both change whether you can see why a verdict
happened.

This repo's `trace` is already `retain-on-failure`, so only the timeout half applies here.

No repo in the fleet set `globalTimeout`. The shared quality.yml Playwright
job is `timeout-minutes: 45`, and a job cancelled by that cap produces no
verdict and no artifacts: the trace upload is `if: failure()` and the report
upload is `if: always()`, and neither runs on a cancelled job, while
`gh pr checks` still renders it as "fail". Runs cancelled at ~45m16s have
been observed in this fleet. Measured overhead in that job before the
`Run Playwright tests` step starts is 2.0-2.4 min, so 38m leaves ~7 min of
margin while guaranteeing a tally and its artifacts.

* fix(e2e): apply the same fix to the config CI actually loads

The shared quality.yml resolves its config as
`${playwright-test-path}/playwright.config.ts` and only falls back to the
app-root `playwright.config.ts` when that file is absent (quality.yml
~L2218). This repo ships tests/e2e/playwright.config.ts, so THAT is the
file every CI run has been using — the app-root config fixed in the previous
commit is the one developers load by hand, not the one the gate reads.

Applies the identical `retain-on-failure` + `globalTimeout: 38 * 60_000`
change here. ConductionNL/.github#188.
…ects (#770)

Two tools in the same pipeline gave OPPOSITE instructions about where an
`@spec` tag should point, and following the one that runs FIRST manufactured
findings for the one that runs SECOND.

`SpecTagSniff` runs as a blocking `PHP Quality (phpcs)` job and told every
developer, in its file docblock and in its own warning text:

    @SPEC openspec/changes/{change-name}/tasks.md#task-N

A change directory is temporary by definition — completing a change moves it
to `openspec/changes/archive/<date>-<name>/`, and renaming or dropping one
removes the target outright. Every tag written to that instruction dangles
from that moment on, and gate-46 (spec-anchor-existence) reports it. The
developer who wrote the tag had followed this sniff's own advice.

Measured on portaliq: 100 unresolved gate-46 targets, and 260 of its 385 live
tags pointing into a change directory. The sniff ships identically in 20
ConductionNL repos, so grinding the tags without fixing the sniff regenerates
them at the rate changes are archived.

This changes the docblock example and BOTH warning messages to the canonical
form gate-46 and the project rule agree on:

    @SPEC openspec/specs/{capability}/spec.md#requirement-{slug}

The method-level message previously carried no guidance at all, so a developer
reading it had only the class message to copy from; it now names the same
canonical shape.

Behaviour is unchanged: severity stays WARNING (verified via phpcs — an
untagged class and public method still report 0 errors / 2 warnings, and a
tagged file still reports nothing), and an `openspec/changes/...` target is
still accepted, since this sniff only checks that a tag is PRESENT.

No `@spec` tags are repointed here — this repo's existing tags are untouched.

Refs ConductionNL/.github#228
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.

3 participants