Release: merge development into beta - #18
Conversation
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.
…ecidesk, remove bezwaar dependents
…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
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.
|
Not merging — The green measured zero files
Two things stack up here, and each alone would be enough:
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 What the same gates say against the whole treeSame checkers (hydra-gates @ 20 gates failing, 479 findings. Full per-gate breakdown in #766/#767 and the handover. The reading error to retireprocest 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 Related: |
…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
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.