fix(apphost): register OpenRegister's autoloader before referencing AppHost (gate-64 / ADR-040) - #138
Merged
Merged
Conversation
…ppHost `openbuild` sorts before `openregister`, and 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 OCA\OpenRegister\ was NOT autoloadable inside OpenBuild's own register() — on a perfectly healthy instance, with OpenRegister enabled. Measured, not theorised: `OpenBuild: OpenRegister AppHost\Bootstrap is not autoloadable` was logged 3x in the E2E job (every occ call in ci-seed.sh) while lib/AppHost/Bootstrap.php existed on OpenRegister the whole time. Because the reference sits behind a class_exists() guard, this degraded SILENTLY: Bootstrap::register() apparently never ran, so the generic dashboard/settings/preferences controllers, the observability controllers, the install repair steps and the manifest-driven deep-link listener were absent, with nothing in the UI to say so. Fix: put OpenRegister's PSR-4 prefix on the autoloader ourselves before the guard. registerAutoloading() touches only the autoloader and is idempotent. IAppManager::loadApp() is deliberately NOT used: it marks OpenRegister loaded and calls Coordinator::bootApp(), booting it before its own register() has run. The prelude lives in its own class so its 'never throws' contract is reachable from a unit test — Application cannot be constructed without a NC DI container. Caught by hydra gate-64 (apphost-autoload-prelude), ADR-040.
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.
Contributor
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-06 08:10 UTC
Download the full PDF report from the workflow artifacts.
…p the exclusions The spec scenarios for the ADR-040 prelude carried '@e2e exclude composition-root load order'. That was wrong twice over: an exclusion is not evidence, and the load order HAS a user-visible symptom that Playwright can reach. OpenBuild ships no concrete HealthController or MetricsController. Their route targets health#index and metrics#index — declared by \OCA\OpenRegister\AppHost\Routes::standard() in appinfo/routes.php — exist ONLY as DI aliases created by Bootstrap::register(). Without the prelude, class_exists(Bootstrap::class) answered false, Bootstrap::register() never ran, those aliases were never created, and the routes resolved to a class with no binding: HTTP 500, not 404. tests/e2e/apphost-observability.spec.ts asserts /api/health returns 200 with the engine's {status, app, version, checks} shape and app = 'openbuild', and that /api/metrics does not 5xx. It FAILS on the code before the prelude and passes after it — which is exactly what an exclusion cannot do. Verified locally: gate-19 PASSES with the @e2e annotation on the scenario, and FAILS with the annotation removed, so the traceability is real and not a tag that would be counted whatever happened. The absent-OpenRegister scenario is removed rather than excluded: it is not reachable from an HTTP client at all, so it is stated in the requirement prose and asserted in tests/Unit/AppInfo/OpenRegisterAutoloaderTest.php.
Contributor
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-06 08:18 UTC
Download the full PDF report from the workflow artifacts.
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.
Contributor
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-06 08:37 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-06 09:02 UTC
Download the full PDF report from the workflow artifacts.
… at specs, and correct the blast-radius claim Three things, all forced by measurement rather than taste. 1. gate-2 forbidden-patterns FAILS on this branch and PASSED on the baseline run (31081906401). Nothing new was added: `error_log()` has been in Application.php all along, and gate-2 is diff-scoped, so touching the surrounding lines is what put it in scope. The else-branch is removed rather than suppressed. With the prelude above it, a false `class_exists()` no longer means "not autoloadable YET" — it means OpenRegister genuinely is absent, which is a whole-instance condition an app cannot usefully narrate from its composition root with no PSR logger resolvable. /api/health, bound by `observability => true`, is where that state is readable. Same choice doriath made. 2. gate-46 spec-anchor-existence FAILS on two `@spec` tags pointing at `openspec/changes/archive/2026-05-12-openbuild-rbac/tasks.md`. An archived change dir is never a valid @SPEC target; the canonical home is `openspec/specs/openbuild-rbac/spec.md`, which exists. 3. The prose overclaimed, and the overclaim is the interesting part. Comparing the pre-prelude run 31081906401 with this branch's run 31085597692: - `OpenBuild: OpenRegister AppHost\Bootstrap is not autoloadable` appeared 3x before and 0x after — once per `occ` call in ci-seed.sh. So under the CLI SAPI the guard really was answering false on a healthy instance, and every occ command, background job and repair step lost the whole generic AppHost plumbing. - But in that SAME pre-prelude run, `/api/health` returned 200 with `status: ok` and `/api/metrics` rendered the manifest gauges. OpenBuild ships no concrete HealthController/MetricsController and `health#index` exists ONLY as a `Bootstrap::register()` DI alias — so under the web SAPI the guard was answering TRUE and Bootstrap::register() did run. The CLI/web divergence is not explained. So the claims "on every single request" and "had apparently never run" are withdrawn from Application.php, OpenRegisterAutoloader.php, REQ-OBS-006 and the e2e docblock, and replaced with what was actually measured. In particular tests/e2e/apphost-observability.spec.ts is relabelled: it did NOT fail before the prelude (both routes already answered 200), so it is a regression guard on the aliases, not the before/after evidence. The before/after evidence for the prelude's own contract is the unit test. Verified locally against this tree: - tests/Unit/AppInfo/OpenRegisterAutoloaderTest.php: OK (3 tests). - Negative control: deleting the try/catch from the prelude turns all 3 into errors ("Class \"OC\" not found"), so the never-throws contract is a check that can fail, not a check that always passes.
…ment that follows Removing the else branch left an inline comment block followed by a blank line, which Squiz.Commenting.InlineComment rejects: phpcs went 0 ERRORS -> 1 ERROR at Application.php:191 while every other job held. Verified locally against this tree — full-tree phpcs now exits 0 with 0 errors. Also drops 'on every single request' from that same note. The error_log line was measured on occ calls (3 per E2E run); whether web requests reached it was never established, and the comment should not claim more than the measurement does.
Contributor
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-06 11:40 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ❌ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-06 11:47 UTC
Download the full PDF report from the workflow artifacts.
quality / Security (composer) went success -> failure between run 31095490811 (11:11Z) and run 31097941416 (11:42Z) on this branch. Nothing in the branch changed in between: the advisory was published 2026-08-05T23:53Z and reached the Packagist advisory database in that window. Package: squizlabs/php_codesniffer CVE-2026-67434 — OS Command injection Affected: <3.13.6 | >=4.0.0,<4.0.2 The composer.json constraint (^3.9) already permitted the patched release, so this is a lock-only change: one package, 5 lines. Verified afterwards with 'composer audit' -> 'No security vulnerability advisories found', full-tree phpcs exit 0 / 0 errors, and the prelude unit suite still OK (3 tests). This is a dev dependency and unrelated to the ADR-040 fix, but it blocks the merge and the fix is a version bump rather than a suppression, so it lands here instead of behind an ignore entry.
Contributor
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-06 12:56 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #140.
The defect
lib/AppInfo/Application.phpreferencedOCA\OpenRegister\AppHost\Bootstrapbehind aclass_exists()guard, with no autoload prelude anywhere inlib/— zeroregisterAutoloadingcalls in the repo.OC_App::getEnabledApps()sorts the app list, andCoordinator::registerApps()walks that sorted list callingOC_App::registerAutoloading($appId, $path)and then$app->register()one app at a time. So every app'sregister()runs before the PSR-4 prefix of every alphabetically-later app exists — andopenbuildsorts beforeopenregister.What was actually measured (and what was not)
Baseline = the last pull_request run without the prelude, run
31081906401(adevelopmentpush run is not a baseline: the gates runner auto-detects baseorigin/development, which is HEAD, and exits 99 "COULD NOT RUN").apphost-autoload-preludeOpenBuild: … Bootstrap is not autoloadablein the E2E logocccall intests/e2e/ci-seed.shGET /api/health,GET /api/metricsSo under the CLI SAPI the guard really was answering
falseon a healthy instance with OpenRegister installed and enabled throughout: everyocccommand, background job and repair step lost the whole generic AppHost plumbing, silently./api/healthreturned 200 withstatus: okand/api/metricsrendered the manifest's gauges. OpenBuild ships no concreteHealthController/MetricsController;health#indexexists only as aBootstrap::register()DI alias — so under the web SAPI the guard was answeringtrueandBootstrap::register()did run. The CLI/web divergence is not explained, and no claim is made here that web requests were degraded. The prelude removes the dependence on whatever causes the difference; that is the argument, not "it was 500ing".Every overclaim has been walked back in the code and the spec accordingly, including in
tests/e2e/apphost-observability.spec.ts, which is relabelled as a regression guard on the aliases rather than before/after evidence — it did not fail before the prelude.The fix
lib/AppInfo/OpenRegisterAutoloader.php, called fromApplication::register()before theclass_exists()guard. Same shape already merged in procest (#752) and scholiq (#288).OC_App::registerAutoloading()touches only the autoloader and is idempotent (early-returns on an$alreadyRegisteredkey).IAppManager::loadApp('openregister')— that marks OpenRegister loaded and callsCoordinator::bootApp(), booting it before its ownregister()has run.register()— strictly worse than the silent skip it replaces.Applicationcannot be constructed without a Nextcloud DI container, so an inline prelude has no reachable unit test. (That is why #139, the inline variant, was closed in favour of this one.)Proof, both directions
.github@mainmatcher: prelude present →rc=0apphost-autoload-prelude: OK;lib/AppInfo/OpenRegisterAutoloader.phpmoved away →rc=1FAIL openbuild: … nothing under lib/AppInfo/ registers OpenRegister's autoloader first. The check can still fail.tests/Unit/AppInfo/OpenRegisterAutoloaderTest.php→OK (3 tests, 3 assertions). Delete thetry/catchfrom the prelude and all three become errors —Error: Class "OC" not found— so the never-throws contract is an assertion that can fail, not one that always passes. The third test (testRegisterSwallowsAnAppThatCannotResolve) exists specifically to reach the catch branch, which is otherwise dead on any instance that has OpenRegister.Also in this PR, because touching
register()pulled them into diff scopeelse { error_log(…) }branch is removed, not suppressed. It has been in the file all along and gate-2 is diff-scoped, which is why it passed at baseline. With the prelude, a falseclass_exists()no longer means "not autoloadable yet" — it means OpenRegister genuinely is absent, a whole-instance condition an app cannot usefully narrate from its composition root with no PSR logger resolvable./api/healthis where that state is readable. Same choice doriath made.@spectags pointed atopenspec/changes/archive/2026-05-12-openbuild-rbac/tasks.md. An archived change dir is never a valid@spectarget; they now point atopenspec/specs/openbuild-rbac/spec.md.Spec
New
REQ-OBS-006inopenspec/specs/settings-and-observability/spec.md, with the measurement above recorded in its Notes including the unexplained CLI/web divergence.REQ-OBS-005corrected: it claimed the lazy factories "survive the app load order in which OpenRegister is not yet autoloadable", but those factories are registered byBootstrap::register().Known-unrelated CI state
E2E carries pre-existing failures on
development: 3 ×docudesk-document-templatesand 1 ×page-editor-coveragewiki page, plus a flakybuilder-host. Baseline was 5 failed / 168 passed; this branch 3 failed / 172 passed. None of them touch this change.Ref: ADR-040, hydra gate-64 (ConductionNL/.github#174, false-positive fix #184/#185).
Final CI state (run
31101513785, head29cac98a3)ALL 59 APPLICABLE GATES PASSED — and all 59 of them ran.gate-2, gate-46 and gate-64 all PASS.developmentand fail identically on the pre-fix baseline run31081906401(which was 5 failed / 168 passed): 2 ×docudesk-document-templatesand 1 ×page-editor-coveragewiki page. None touches this change.OpenBuild: … Bootstrap is not autoloadablein the E2E log: 0 (baseline 3).Two commits in this PR are not the ADR-040 fix and are labelled as such: the phpcs inline-comment style fix that removing the else-branch made necessary, and
squizlabs/php_codesniffer3.13.5 → 3.13.6 for CVE-2026-67434 (OS command injection), which turnedSecurity (composer)red mid-PR when the advisory reached Packagist. That is a version bump, not an ignore entry;composer auditnow reports no advisories.