fix(dispatch): narrow binding catch, cache binding misses, unify route copy semantics - #2924
Conversation
…e copy semantics - $resolveRouteModelBinding: resolve the model class in its own try and run findByKey() outside it so query errors propagate instead of being masked as a missing model; rethrow resolution failures for explicit binding names (binding="BlogPost") since they indicate configuration errors; log a dev-mode breadcrumb on conventional misses (routing:8). - Negative-cache conventional binding misses in application.wheels (cleared on reload) so a non-model-backed controller no longer repeats the app-wide model lock + base-model bootstrap + DB metadata query on every keyed request (routing:13). - $findMatchingRoute: unify static and regex match paths on a single $copyRouteForRequest helper (shallow top-level copy with non-simple members duplicated) so the static fast path can no longer leak shared nested route state and the regex path no longer deep-Duplicates the whole struct per request; Mapper comment synced (routing:12, routing:16). - Execute the route regex once per request: $findMatchingRoute stashes the sub-expression match on the per-request copy and $mergeRoutePattern reuses it, falling back to a fresh match when absent (routing:17). - $translateDatePartSubmissions: delete the ($ampm) part key during cleanup so 12-hour time submissions no longer leave a raw field($ampm) param (dispatch-core:5). Specs added for each behavioral change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This PR fixes six legitimate dispatch/route-pipeline bugs (narrowed binding catch, negative-caching of binding misses, unified route-copy semantics, regex stash, ampm key cleanup) and the overall approach is sound. All five implementation paths are functionally correct and the cross-engine checklist the author ran through in the PR body is accurate. Commenting rather than approving on two minor points: the test for the R8 query-error-propagates guarantee is missing, and findMatchingRouteSpec.cfc is missing a trailing newline.
Correctness
Benign TOCTOU on negative-cache initialization (vendor/wheels/Dispatch.cfc, lines 618-621):
Two concurrent requests can both pass the StructKeyExists check before either writes the struct, then both assign {} to the key and both add modelName = true. The race outcome is idempotent — same key, same value — so no data is corrupted. The worst case is that both requests complete the model-bootstrap they were trying to avoid. Given the write is inside a catch block that only fires on the very first miss per model name, the window is narrow. Acceptable as-is, flagging for reviewers who see the unguarded application-scope write.
Root-path special case stashes a failed match (vendor/wheels/Dispatch.cfc, lines 186-189):
When arguments.path is empty and matched via the local.route.pattern == "/" special case, local.match.pos[1] is 0 (regex did not match the empty string) but is still stashed as regexMatch on the per-request copy. In $mergeRoutePattern this stash is consumed; ArrayLen(local.matches.pos) is 1 so the variable-extraction loop does not execute. Root routes have no capture groups so this is a no-op in practice. Correct as implemented.
Tests
R8 core guarantee — query errors propagate — has no direct test (vendor/wheels/tests/specs/dispatch/routeModelBindingSpec.cfc):
The structural change of R8 moves local.modelClass.findByKey(...) outside the try block so DB-time errors propagate instead of being swallowed as missing-class exceptions. The new specs cover explicit-binding resolution failures throwing, negative-cache population, and cache hits — but none exercises a findByKey error escaping to the caller. This is the most important behavioral contract introduced by this PR. Worth a follow-up tracking item so the regression surface is explicit.
Missing trailing newline (vendor/wheels/tests/specs/dispatch/findMatchingRouteSpec.cfc, final line): the diff ends with no newline at end of file. The original had one; restoring it keeps the repo consistent with all other spec files.
afterEach coverage is complete — the PR correctly anticipates that the pre-existing "skips silently when model class does not exist" test (controller nonexistentThings -> model NonexistentThing) will now populate the negative cache, and the added cleanup block covers Post, NonexistentThing, and NonexistentWidget. No gaps found.
Docs
No CHANGELOG.md [Unreleased] entry. The PR body acknowledges this ("Entry deliberately omitted; consolidated at campaign end"). Fine for a campaign-batch PR, worth confirming the consolidation step is tracked.
Commits
fix(dispatch): narrow binding catch, cache binding misses, unify route copy semantics — 84-character header, valid fix type, valid dispatch scope, sentence-case subject. Passes commitlint.
Security
The writeLog in the catch block (line 626) interpolates e.type and e.message into the log text. Model names are derived from controller names via routing, not raw user input, so the injection surface is narrow. Gated behind != "production". No action needed.
Wheels Bot — Reviewer B (round 1)A's review is accurate and well-reasoned. Both concrete findings (missing trailing newline, missing R8 query-error-propagation test) check out against the diff. A's two technical analyses (benign TOCTOU, root-path zero-pos stash) are correct. No sycophancy, no false positives, no missed issues beyond what A already raised. Aligning on changes-needed: the trailing newline is a trivial fix, and the R8 test gap — the most important behavioral contract in this PR — warrants either a concrete spec or a tracked follow-up issue. SycophancyNone detected. A issued COMMENTED, not an approval, and backed every claim with cited lines. False positivesNone detected.
Missed issuesNone detected beyond what A already flagged. The cross-engine checklist holds up on inspection:
The negative-cache-cleared-on-reload claim in the PR body ( Verdict alignmentA's COMMENTED state is consistent with the findings: two concrete issues that are not merge-blockers on their own but represent unfinished work — a trivial newline omission and a missing regression test for the PR's most important behavioral change. ConvergenceAligned. Both findings are real: the trailing newline on |
…traints Resolved vendor/wheels/Dispatch.cfc $mergeRoutePattern conflict by composing both sides: keep #2924's per-request regexMatch reuse (stashed by $findMatchingRoute, fresh-match fallback) AND this branch's variable-count bound on the extraction loop (Min of match groups and foundVariables + 1) so an extra capturing group can never shift values or crash ListGetAt. Verified locally on Lucee 7 + SQLite: dispatch 106/106, mapper 81/81, mapperModernSpec 32/32. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
Resolves the one conflict (vendor/wheels/events/onapplicationstart.cfc) by composing both sides: keep #2930's helperFileCache/layoutFileCache struct caches from develop (consumed by Controller.cfc and controller/layouts.cfc) and keep this PR's struct-typed existingObjectFiles/nonExistingObjectFiles existence caches (consumed by Global.cfc $objectFileName). The obsolete existing/nonExisting helper- and layout-file list initializers are dropped; no consumers remain repo-wide. Auto-merged files verified semantically: Mapper.cfc (dead instance-level staticRoutes removal vs #2924 comment update — disjoint), model/miscellaneous.cfc ($parseSlashDate call vs #2931 $stampTimestampProperty helper — disjoint), Global.cfc (PR hunks at ~633-3007 vs #2912/#2943 hunks at ~3063-3181 — disjoint; both sides present in the merge). Verified locally on Lucee 7 + SQLite (dir-only docker mount): global 133/0/0, dispatch 104/0/0, mapper dir 89/0/0, mapperSpec 64/0/0, mapperModernSpec 27/0/0, routingSpec 5/0/0, events 36/0/0, controller 456/0/0 (1 skip), model 869/0/0 (11 skip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
Summary
Fixes six findings in the dispatch/route pipeline: route model binding no longer swallows finder-time query errors or silently absorbs explicit-binding configuration errors; conventional binding misses are negative-cached (cleared on reload) instead of repeating the app-wide model lock + base-model bootstrap + DB metadata probe on every keyed request; the static fast path and regex fallback in
$findMatchingRoutenow share a single$copyRouteForRequesthelper (shallow top-level copy with non-simple members duplicated) so the static path can no longer leak shared nested route state and the regex path stops deep-Duplicate-ing the whole route struct per request; the matched route regex executes once per request (stashed match reused by$mergeRoutePattern); and$translateDatePartSubmissionscleans up the orphanedfield($ampm)param key.Findings addressed
$resolveRouteModelBindingswallows all exceptions frommodel().findByKey()as "model class doesn't exist" @vendor/wheels/Dispatch.cfc:550-646— model class resolved in its owntry(Dispatch.cfc:607-630);findByKey()moved outside it (Dispatch.cfc:636) so query errors propagate; explicitbinding="BlogPost"resolution failuresrethrow; conventional misses log a dev-modewheelslog breadcrumb withe.type/e.message.vendor/wheels/Dispatch.cfc:593-604, 616-621— negative cacheapplication.wheels.unresolvableRouteBindings, written on conventional-binding misses, consulted beforemodel(), cleared on reload (onapplicationstart.cfcresetsapplication.$wheels). Explicit bindings never read or write the cache.StructCopywhile the regex path deep-Duplicates — shared nested route state leaks to request code @vendor/wheels/Dispatch.cfc:157, 163, 240-247— both paths now call$copyRouteForRequest(), which duplicates non-simple members (constraints,middleware) so middleware readingrequest.wheels.currentRoutecan never mutate the shared route table.$findMatchingRoutefallback deep-Duplicates the matched route per request while the static fast path proves a shallow copy suffices @vendor/wheels/Dispatch.cfc:188;vendor/wheels/Mapper.cfc:149-152— regex path switched to the same shallow-plus-duplicated-nested copy; Mapper comment synced to describe the new copy semantics.vendor/wheels/Dispatch.cfc:186-189, 510-514—$findMatchingRouterunsReFindNoCase(..., 1, true)once and stashes the sub-expression result asregexMatchon the per-request copy;$mergeRoutePatternreuses it, falling back to a fresh match when absent (static fast path / direct calls).pos[1] > 0is equivalent to the old two-arg truthiness, including the root-path special case.$translateDatePartSubmissionsnever deletes the($ampm)part key @vendor/wheels/Dispatch.cfc:812—StructDelete(local.rv, local.key & "($ampm)")added to the cleanup block; PM-to-24-hour conversion unchanged.Findings verified already-fixed
None — all six findings in this package were live against
origin/develop(reviewer spot-checked the pre-fix code: develop's cleanup deleted only the six year-through-second keys, and develop's catch wrapped the wholemodel().findByKey()chain in a single swallow-all).Source
Internal multi-agent framework review 2026-06-09, wave 2 package
dispatch-route-pipeline.Tests
8 new WheelsTest BDD specs across the existing dispatch spec files, each written to fail against the pre-fix code where the behavior allows it:
vendor/wheels/tests/specs/dispatch/createParamsSpec.cfc—($ampm)key removed from params; PM time still converts to 24-hour.vendor/wheels/tests/specs/dispatch/findMatchingRouteSpec.cfc— static-path nested isolation (mutatingconstraintson the returned copy doesn't touch the route table), regex-path isolation,regexMatchstash present on regex-matched copies,$mergeRoutePatternfallback for routes without the stash.vendor/wheels/tests/specs/dispatch/routeModelBindingSpec.cfc— explicit-binding resolution failure throws; conventional miss writes the negative cache; cached miss is consulted (binding skipped without re-resolution);afterEachcleans every cache name the file can create.Local verification (Lucee 7 + SQLite, prebuilt docker image, worktree dir-only mount): all three touched bundles green —
createParamsSpec13 pass / 0 fail / 0 error,findMatchingRouteSpec34 pass / 0 fail / 0 error,routeModelBindingSpec13 pass / 0 fail / 0 error. Full engine x DB coverage deferred to the CI compat-matrix (the real gate); worth watching the BoxLang and Adobe lanes for these three bundles.Cross-engine notes
$copyRouteForRequestispublicwith a$prefix (mixin invariant 7 —privatehelpers are not integrated on Lucee/Adobe).local.Xwrites insidecatchbodies are read after the catch (BoxLang invariant 11) — the catch paths only writeapplication-scope keys and return the pre-trylocal.rv;local.modelClassis assigned in thetrybody, which persists fine.rethrowin script,writeLog(file/type/text), and the 4-argReFindNoCase(..., 1, true)form all have in-file prior art on develop that already passes the CI matrix.arguments-as-attributeCollection, noLeft(str, 0).Changelog
Entry deliberately omitted; consolidated at campaign end.
🤖 Generated with Claude Code