Skip to content

fix(gate-5,14,30,9): route names, delegated registrars, and a monitoring gate that passed on nothing (#213, #218, #221, #223, #237) - #264

Merged
rubenvdlinde merged 1 commit into
mainfrom
fix/route-registration-detection-gates
Aug 8, 2026
Merged

fix(gate-5,14,30,9): route names, delegated registrars, and a monitoring gate that passed on nothing (#213, #218, #221, #223, #237)#264
rubenvdlinde merged 1 commit into
mainfrom
fix/route-registration-detection-gates

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes #213. Closes #218. Closes #221. Closes #223. Closes #237.

Rebased on #217, which landed the read -r fix for gates 5 and 14 and explicitly left one shape open (see §1b). Nothing here duplicates it.

Two defects, five issues, three gates

(a) A shell or regex detail corrupted the input, so the gate measured something other than the code.
(b) The gates modelled ONE registration idiom and flagged every other legitimate one.


1a. gate-30 selected NOTHING and printed PASS (#213)

The selector alternation is lowercase-only under grep -E:

'(metrics|health|liveness|readiness|probe)'

AppHost\Controller\GenericMetrics#index, genericHealth#index, chatHealth#health — no match. openregister, which owns the fleet's health/metrics engine, reported [gate-30] public-monitoring: PASS over a 0-byte log on every run. Confirmed silently green in four repos.

gate-30 also still carried while IFS='#' read _ctrl _method — the line #217 fixed in gates 5 and 14 — so a namespaced monitoring route was resolved to a path that cannot exist and skipped without a word.

1b. The relative DI key #217 flagged and left open (#213)

openregister names two routes with a relative namespace and binds them verbatim:

// appinfo/routes.php
['name' => 'AppHost\Controller\GenericHealth#index', 'url' => '/api/health', 'verb' => 'GET'],
// lib/AppInfo/Application.php::registerAppHostObservability()
$context->registerService('AppHost\\Controller\\GenericHealthController', …);

RouteParser::buildControllerName() appends Controller to the route-name segment verbatim and does not prefix OCA\<App>\Controller\ when the name already contains a backslash — openregister's own comment records the 503 that taught it. _di_binds_fq_controller is scoped to OCA\<App>\… on purpose, so these two were still reported. _di_binds_controller now accepts the bare key for a namespaced name only: for a plain slug the needle would be WidgetController, which any stray WidgetController::class near a registerService call would satisfy.

openregister gate-14: 2 → 0.

2. Routes supplied by Routes::standard() are not in routes.php (#223)

An ADR-040 adopter returns \OCA\OpenRegister\AppHost\Routes::standard($extra) and receives ten canonical entries it never spells out. gate-14 invariant 1 asked grep -qF "'slug#method'" appinfo/routes.php — the wrong file, answer always no. Every app shipping its own DashboardController / SettingsController (the supported shape; aliasControllerUnlessLeafDefinesIt exists to allow it) was told its working / and /api/settings 404.

The ten names are an explicit list mirroring openregister lib/AppHost/Routes.php::canonicalRoutes() + ::catchAllRoute() (verified at openregister@1dcc92cf9, lines 110–126 + 165–176). Not "AppHost adopters are exempt from invariant 1" — see the gadget#run half of the fixture.

doriath gate-14: 5 → 0.

3. The AppHost call is not required to be in Application.php (#237)

_HYDRA_APPHOST grepped lib/AppInfo/Application.php and nothing else. procest's Bootstrap::register() lives in lib/AppInfo/Registrar/AppHostRegistrar.phpbecause phpmd asked for the decomposition (procest#717). The evidence is now "some tracked file under lib/ calls it", with both conditions required in the same file. #199 fixed a neighbouring symptom and this one kept firing; the fixture pair below is what makes that not repeatable.

procest gate-14: 4 → 0.

⚠️ The first cut of this widening was two raw greps, and the very first fixture written to disprove it — delegated-registrar-absent/, whose docblock reads "NOTHING in this app calls \OCA\OpenRegister\AppHost\Bootstrap::register()"exempted itself with its own explanation. Same failure as gate-64 (#184): a checker that greps a string matches every comment. Comment lines are now stripped before matching, in the AppHost detector and in _app_php_binds_class. The trap is kept in the fixture on purpose.

4. A monitoring word in the name is not a monitoring endpoint (#218)

launchpad's HealthPingController is a per-placement badge: 401 anonymous, then canViewPlacement() before any work. gate-30's only remedy — add #[PublicPage] — would have published an outbound-ping oracle to anonymous callers.

The discriminator is not the name, it is the shape of the route. A Prometheus scraper, a kubelet probe or an uptime monitor has no session and no object in mind: it issues a GET against a fixed URL. So a monitoring endpoint here is a name-matching route that is also an unparameterised GET.

  • healthPing#show/api/health-ping/{placementId}, per-object → not judged, reason logged
  • healthPing#validate — POST, submits a candidate config → not judged, reason logged
  • health#index/api/health GET → still enforced strictly

launchpad gate-30: FAIL 2 → PASS, with 2 endpoints genuinely inspected.

5. The credential is often one frame down (#221)

#[PublicPage] in Nextcloud means a login is not requirednot there is no session. Two shapes were being reported:

  • Progressive session-then-policy. doriath ApplicationController::create: admin auto-approves, an authenticated non-admin gets a pending row, an anonymous caller is admitted only when the admin has set anonymous_application_registration_enabled. The 401 is a stated policy, not a check against something the annotation forbids.
  • A private helper. A controller authenticating several actions the same way writes the resolution once. The credential surface now inlines sibling helper bodies — one frame, same file. Not transitive: following the call graph deeper would eventually reach a service that touches a token for unrelated reasons and exempt everything.

⚠️ This does not soften rule 1 (_PUBLIC_SESSION_AUTH_RE), which is tested first and still owns the genuine contradiction — requireAdmin() under #[PublicPage], decidesk#44.

doriath gate-9: FAIL 1 → PASS.

6. A pre-existing window defect, surfaced by fixing #213

The moment openregister's Settings\… routes resolved at all, gate-5 reported

lib/Controller/Settings/FileSettingsController.php:323 method=getFileExtractionStats rule=missing-auth-attribute

⚠️ #217's body calls this "a true finding the gate was blind to". Re-measured: it is false. @NoCSRFRequired sits on line 301 and the declaration on 323 — a @psalm-return shape in between — so the 20-line lookback started at 303 and the tag was outside it. Same for FileExtractionController::stats (tags at 559/563, declaration at 587). Both are correctly annotated by gate-5's own accepted-attribute set.

New shared _head_block takes the contiguous annotation run OR the 20-line slice, whichever starts earlier, and keeps the previous-member clamp from #153. The window can only grow, so nothing that passes today can start failing; and it still cannot borrow a neighbour's attribute. Used by gate-5 and gate-30.

7. gate-30 may no longer print PASS having opened nothing

[ fail -eq 0 ] && PASS had an empty findings log as its only input, and an empty log has two causes that mean opposite things. Now:

outcome condition
FAIL a finding
PASS ≥1 monitoring method actually opened — and an info line states how many, of how many candidates
NOT APPLICABLE no routes.php/lib/Controller · zero name-matched candidates · zero scrape targets (with the count) · all targets are AppHost generics absent from this repo · none touched by this diff (ADR-020)
SKIPPED (structural) targets found, controller files opened, not one routed method located — visible to --require-full-coverage

Every candidate the run did not judge is written to hydra-gate-public-monitoring-notes.log with its reason.


Measured — full-tree, both arms, same 5 repos

origin/main (7511fb2, i.e. with #217) vs this branch. Numbers re-read from the runner's own stdout, not from memory.

repo gate-5 gate-9 gate-14 gate-30
openregister FAIL 12 → FAIL 10 FAIL 3 → FAIL 3 FAIL 2 → PASS PASS (0 inputs)PASS, 1 of 3 inspected
launchpad PASS → PASS PASS → PASS PASS → PASS FAIL 2 → PASS, 2 of 4 inspected
procest FAIL 5 → FAIL 5 FAIL 2 → FAIL 2 FAIL 4 → PASS PASS (0 inputs)NOT APPLICABLE (2 AppHost generics, named)
doriath PASS → PASS FAIL 1 → PASS FAIL 5 → PASS PASS (0 inputs)NOT APPLICABLE (no monitoring-shaped name)
openconnector PASS → PASS PASS → PASS PASS → PASS PASS → PASS, 2 of 2 inspected

Runner exit: openregister 21→20, launchpad 14→13, procest 21→20, doriath 14→12, openconnector 4→4.

Against the pre-#217 baseline the openregister route pair reads 64 → 10: gate-14 53→0 and gate-5's phantom UNRESOLVED list 53→2 (the two remaining are the real AppHost generics, correctly stated as NOT JUDGED).

Every removal verified on the real file, named:

  • launchpad lib/Controller/HealthPingController.phpshow() 401s + canViewPlacement(); validate() is a POST
  • doriath lib/Controller/ApplicationController.php::create$this->session->getUser(), then the app-config opt-in
  • procest lib/AppInfo/Registrar/AppHostRegistrar.php — the delegated Bootstrap::register()
  • openregister lib/AppInfo/Application.php::registerAppHostObservability — the two verbatim DI keys
  • openregister lib/Controller/Settings/FileSettingsController.php:301/323 and lib/Controller/FileExtractionController.php:559/587 — the docblock window
  • the Routes::standard() set — openregister lib/AppHost/Routes.php:110–126 + 165–176

Tests — 30 new shell assertions, 9 new Python, mutation-checked

scripts/lib/test_gate_route_registration.sh (30 assertions, 7 fixtures, auto-discovered by tests/run-helper-suites.sh) + 9 cases in test_check_semantic_auth.py.

Every fixture carries its own anti-widening half — a sibling differing by the ONE thing the widening accepts, which must still fail. delegated-registrar/ and delegated-registrar-absent/ are byte-identical apart from a single registrar file.

11 mutants, 11 killed, none survived:

mutant assertions that go red
gate-30 selector back to lowercase-only 3 — capitalised names become invisible again
gate-30 read -r reverted 1 — the namespaced route stops resolving
gate-30 shape filter removed (pre-#218) 6 — healthPing returns as a finding
_apphost_supplies_route always true 3 — gadget#run goes quiet
_apphost_serves always true 6 — every absent controller goes quiet
_head_block back to a blind 20-line slice 2 — a long docblock hides a stated posture
gate-30 verdict back to "empty log = PASS" 4 — both loud skips revert to green
comment-stripping removed from AppHost detection 2 — the fixture exempts itself by its docblock
gate-9: session patterns removed 2
gate-9: helper inlining removed 2
gate-9: credential surface = the whole file 1 — the depth-1 bound

Regression, exit codes read directly (not through a pipe):

bash hydra-gates/tests/run-helper-suites.sh    -> EXIT=0  (29 passed, 2 quarantined, 0 failed)
bash hydra-gates/tests/test-hydra-gates-bin.sh -> EXIT=0  (59 passed, 0 failed)
docker run koalaman/shellcheck:stable …        -> EXIT=0

Found, NOT fixed here — filed separately

Routes::standard() supplies settings#update (PUT /api/settings), and doriath's SettingsController has no update(). Because doriath ships its own controller the AppHost alias is skipped, so that route resolves to a method that does not exist. Feeding the canonical ten into gate-5's judging stream and gate-14 invariant 2 would catch it — but that direction adds findings across 16 apps and belongs in its own change with its own measurement, not in a PR whose purpose is removing false positives. Flagging rather than smuggling.

…ing gate that passed on nothing

Closes #213. Closes #218. Closes #221. Closes #223. Closes #237.

Two defects, five issues, three gates:

(a) A shell/regex detail corrupted the input, so the gate measured
    something other than the code. gate-30's selector alternation is
    lowercase-only, so GenericMetrics/GenericHealth/chatHealth matched
    ZERO and it printed PASS over a 0-byte log; it also still carried the
    `read` without -r that #217 fixed in gates 5 and 14.

(b) The gates modelled ONE registration idiom and flagged every other
    legitimate one: routes supplied by Routes::standard(), a
    Bootstrap::register() call moved into a registrar, a credential
    resolved one frame down, a controller whose name merely contains
    "health".

gate-30 can no longer print PASS without having opened a monitoring
method: every other outcome is a stated NOT APPLICABLE / SKIPPED with
counts, and PASS states how many endpoints it inspected.
@rubenvdlinde
rubenvdlinde merged commit 846baed into main Aug 8, 2026
31 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/route-registration-detection-gates branch August 8, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment