Skip to content
7 changes: 7 additions & 0 deletions COMPENDIUM.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ Arcanum's small mental model comes from a handful of conventions that show up ev
- `_guestbook-form.html` — files starting with `_` are **partials**: include-only templates skipped by `PageDiscovery`, reachable via `{{ include }}`. Works in `app/Pages/`, `app/Templates/`, or any template directory.
- `AddEntry.422.html` — **status-specific template** co-located with the DTO. Resolution chain: co-located `{Dto}.{status}.{format}` → app-wide `app/Templates/errors/{status}.{format}` → framework default. Works for any status code and format (`PlaceOrder.201.html`, `Health.500.json`). Error templates receive `$code`, `$title`, `$message`, `$errors` (validation), `$suggestion` (ArcanumException). For htmx requests, error templates render as fragments (no layout).

### Convention routing edges

Convention routing handles the common case — but two patterns need a one-line custom route in `config/routes.php`:

- **Root path (`/`):** Conventions need at least one path segment, so `GET /` requires a custom route or `app/Pages/Index.html` (which `PageDiscovery` maps to `/` automatically).
- **Domain name = DTO name:** `App\Domain\TaskLists\Query\TaskLists` maps to `/task-lists/task-lists` (not `/task-lists`). This is correct — `/task-lists` is the root-level `App\Domain\Query\TaskLists`, a different class. For the clean URL, add a custom route: `'/task-lists' => ['class' => TaskLists::class, 'methods' => ['GET']]`.

### What's inside `{{ }}`

Shodo templates have one rule for what's inside the delimiters, distinguished by the first character:
Expand Down
18 changes: 9 additions & 9 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ Migrations currently live at `migrations/` in the project root. Both builds expe

Two bugs in the routing and handler resolution pipeline, both surfaced by the dogfood.

#### Convention routing path doubling (retro 1.7)
#### Convention routing path doubling (retro 1.7) — resolved as working-as-designed

When a domain name matches the DTO class name, `UrlResolver::resolveConvention()` produces doubled paths. `App\Domain\TaskLists\Query\TaskLists` → `/task-lists/task-lists` instead of `/task-lists`. The reverse resolver strips the `Query`/`Command` type segment but leaves both the domain segment and the identically-named class segment. Build B had to fall back to custom routes for everything.
When a domain name matches the DTO class name, convention routing produces "doubled" paths: `App\Domain\TaskLists\Query\TaskLists` → `/task-lists/task-lists`. Both builds found this surprising and fell back to custom routes.

**Fix:** In `UrlResolver::resolveConvention()`, after removing the type segment, check if the last remaining segment equals the segment immediately before the type (the domain). If they match, drop the duplicate. Must also handle deeper nesting: `App\Domain\Shop\Products\Query\Products` → `/shop/products`, not `/shop/products/products`.
**Decision:** This is correct behavior, not a bug. The forward resolver maps `/task-lists` to root-level `App\Domain\Query\TaskLists` and `/task-lists/task-lists` to domain-level `App\Domain\TaskLists\Query\TaskLists`. Collapsing the reverse direction breaks the round-trip (the generated URL would route to a different class). Devs wanting clean URLs like `/task-lists` use a custom route in `config/routes.php` — a one-liner. Document this clearly rather than adding magic path collapsing.

#### Handler resolution misreports dependency failures (retro 1.5, 1.6)

Expand All @@ -122,12 +122,12 @@ When a domain name matches the DTO class name, `UrlResolver::resolveConvention()

##### Checklist

- [ ] **Fix `UrlResolver::resolveConvention()` path doubling** — After removing the `Query`/`Command` type segment from the namespace segments, check if the last segment duplicates the segment that preceded the type. If so, remove the duplicate. Test cases: `Domain\TaskLists\Query\TaskLists` → `/task-lists`, `Domain\Shop\Products\Query\Products` → `/shop/products`, `Domain\Shop\Query\FeaturedProducts` → `/shop/featured-products` (no duplication, unchanged), `Domain\Shop\Query\Shop` → `/shop` (edge case: DTO name matches domain two levels up).
- [ ] **Fix `handlerFor()` prefixed lookup** — Replace `$this->container->has($prefixedName)` with `class_exists($prefixedName)` at the prefixed handler check. This correctly discovers handlers that exist and autoload, regardless of explicit container registration. Test: prefixed handler class exists but is not registered → found and auto-wired. Prefixed handler class does not exist → falls through to unprefixed.
- [ ] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors.
- [ ] **Tests** — UrlResolver tests for collapsed paths (single-segment domain = class name, deeper nesting, no-duplication cases). MiddlewareBus tests: prefixed handler found via auto-wiring, dependency failure propagates instead of becoming HandlerNotFound.
- [ ] **Document root path (`/`) convention limitation** — Atlas convention routing can't produce an empty path — `GET /` always needs a custom route. Both dogfood builds hit this. Document in the Atlas README and the from-scratch guide that `'/' => DtoClass::class` in `config/routes.php` is required.
- [ ] **Update COMPENDIUM.md** — Document the path collapsing behavior and the root path limitation in the routing conventions section.
- [x] ~~**Fix `UrlResolver::resolveConvention()` path doubling**~~ — Resolved as working-as-designed. The doubled path is the correct convention URL; collapsing breaks the forward/reverse round-trip. Added a test confirming the behavior. Documentation items below cover communicating this to devs.
- [x] **Fix `handlerFor()` prefixed lookup** — Replace `$this->container->has($prefixedName)` with `class_exists($prefixedName)` at the prefixed handler check. This correctly discovers handlers that exist and autoload, regardless of explicit container registration. Test: prefixed handler class exists but is not registered → found and auto-wired. Prefixed handler class does not exist → falls through to unprefixed.
- [x] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors.
- [x] **Tests** — UrlResolver: added test asserting non-collapsed behavior (working-as-designed). MiddlewareBus: added tests for prefixed handler auto-wiring and dependency failure propagation.
- [x] **Document root path (`/`) convention limitation** — Atlas convention routing can't produce an empty path — `GET /` always needs a custom route. Both dogfood builds hit this. Document in the Atlas README and the from-scratch guide that `'/' => DtoClass::class` in `config/routes.php` is required.
- [x] **Update COMPENDIUM.md** — Document the convention routing behavior (domain = class name produces doubled path, use custom routes for clean URLs) and the root path limitation in the routing conventions section.

### Dogfood: auth config reform (retro 4.1, orchestrator observation)

Expand Down
19 changes: 19 additions & 0 deletions src/Atlas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,25 @@ app/

This is useful for Conveyor-level concerns (validation, audit logging) that should run for every dispatch but aren't HTTP-specific.

## When to use custom routes

Convention routing maps path segments to namespaces — it needs at least one segment to work with. Two common cases require custom routes:

**Root path (`/`):** Convention routing can't produce an empty path, so `GET /` always needs a custom route (or a page — `app/Pages/Index.html` maps to `/` automatically via `PageDiscovery`).

**Domain name matches DTO name:** When a domain's "list all" query has the same name as the domain, convention routing produces a doubled path. `App\Domain\TaskLists\Query\TaskLists` maps to `/task-lists/task-lists`, not `/task-lists`. This is correct — `/task-lists` maps to the root-level `App\Domain\Query\TaskLists`, a different class. If you want the clean URL, add one line to `config/routes.php`:

```php
'custom' => [
'/task-lists' => [
'class' => 'App\\Domain\\TaskLists\\Query\\TaskLists',
'methods' => ['GET'],
],
],
```

Both cases are one-liners. Convention handles the rest.

## Custom routes

Custom routes are explicit path → class mappings that bypass convention-based resolution. Use them for paths that don't fit the convention system:
Expand Down
5 changes: 3 additions & 2 deletions src/Flow/Conveyor/MiddlewareBus.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Arcanum\Cabinet\ServiceNotFound;
use Arcanum\Flow\Continuum\Continuum;
use Arcanum\Flow\Continuum\Continuation;
use Arcanum\Flow\Continuum\Progression;
Expand Down Expand Up @@ -92,7 +93,7 @@ protected function handlerFor(object $object, string $prefix = ''): callable
{
if ($prefix !== '') {
$prefixedName = $this->handlerNameFor($object, $prefix);
if ($this->container->has($prefixedName)) {
if (class_exists($prefixedName)) {
/** @var callable */
return $this->container->get($prefixedName);
}
Expand All @@ -108,7 +109,7 @@ protected function handlerFor(object $object, string $prefix = ''): callable
try {
/** @var callable */
return $this->container->get($handlerName);
} catch (\Throwable $e) {
} catch (ServiceNotFound $e) {
throw new HandlerNotFound(
get_class($object),
$handlerName,
Expand Down
14 changes: 14 additions & 0 deletions tests/Atlas/UrlResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ public function testPagesRootLevel(): void
$this->assertSame('/index', $result);
}

public function testDomainMatchingClassNameProducesFullPath(): void
{
// Arrange — TaskLists\Query\TaskLists produces /task-lists/task-lists (not collapsed).
// Convention routing is symmetric: the forward resolver maps /task-lists/task-lists
// to this class. Devs wanting /task-lists use a custom route in config/routes.php.
$resolver = new UrlResolver('App\\Domain');

// Act
$result = $resolver->resolve('App\\Domain\\TaskLists\\Query\\TaskLists');

// Assert
$this->assertSame('/task-lists/task-lists', $result);
}

public function testThrowsForUnknownNamespace(): void
{
// Arrange
Expand Down
9 changes: 9 additions & 0 deletions tests/Flow/Conveyor/Fixture/BrokenDep.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Arcanum\Test\Flow\Conveyor\Fixture;

final class BrokenDep
{
}
22 changes: 22 additions & 0 deletions tests/Flow/Conveyor/Fixture/BrokenDepHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Arcanum\Test\Flow\Conveyor\Fixture;

/**
* Handler with an unresolvable dependency — an interface with no binding.
*/
final class BrokenDepHandler
{
public function __construct(
private readonly \Psr\Log\LoggerInterface $logger,
) {
}

public function __invoke(BrokenDep $dto): BrokenDep
{
$this->logger->info('invoked');
return $dto;
}
}
34 changes: 34 additions & 0 deletions tests/Flow/Conveyor/MiddlewareBusTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use Arcanum\Flow\Conveyor\MiddlewareBus;
use Arcanum\Flow\Conveyor\EmptyDTO;
use Arcanum\Cabinet\Container;
use Arcanum\Codex\Error\Unresolvable;
use Arcanum\Test\Flow\Conveyor\Fixture\BrokenDep;
use Arcanum\Test\Flow\Conveyor\Fixture\DoSomething;
use Arcanum\Test\Flow\Conveyor\Fixture\DoSomethingHandler;
use Arcanum\Test\Flow\Conveyor\Fixture\DoSomethingResult;
Expand Down Expand Up @@ -53,6 +55,7 @@
#[UsesClass(\Arcanum\Quill\Channel::class)]
#[UsesClass(EmptyDTO::class)]
#[UsesClass(AcceptedDTO::class)]
#[UsesClass(\Arcanum\Cabinet\ServiceNotFound::class)]
#[UsesClass(\Arcanum\Toolkit\Strings::class)]
final class MiddlewareBusTest extends TestCase
{
Expand Down Expand Up @@ -142,6 +145,21 @@ public function testDispatchWithPrefixResolvesToPrefixedHandler(): void
$this->assertSame('post:test', $result->name);
}

public function testDispatchWithPrefixAutoWiresUnregisteredHandler(): void
{
// Arrange — PostDoSomethingHandler exists as a class but is NOT registered
// in the container. The old has() check would miss it; class_exists() finds it.
$container = new Container();
$bus = new MiddlewareBus($container);

// Act
$result = $bus->dispatch(new DoSomething('test'), prefix: 'Post');

// Assert — auto-wired PostDoSomethingHandler prepends "post:"
$this->assertInstanceOf(DoSomethingResult::class, $result);
$this->assertSame('post:test', $result->name);
}

// ---------------------------------------------------------------
// Prefix falling back to unprefixed handler
// ---------------------------------------------------------------
Expand Down Expand Up @@ -323,6 +341,22 @@ public function testHandlerWithNoReturnTypeDeclarationReturnsEmptyDTO(): void
$this->assertInstanceOf(EmptyDTO::class, $result);
}

// ---------------------------------------------------------------
// Dependency failure propagation (not swallowed as HandlerNotFound)
// ---------------------------------------------------------------

public function testDependencyFailurePropagatesInsteadOfHandlerNotFound(): void
{
// Arrange — BrokenDepHandler exists but requires LoggerInterface,
// which is not registered. The old catch(\Throwable) would wrap this
// as HandlerNotFound; now it propagates as the real resolution error.
$bus = new MiddlewareBus(new Container());

// Act & Assert — should throw an Unresolvable error, NOT HandlerNotFound
$this->expectException(Unresolvable::class);
$bus->dispatch(new BrokenDep());
}

// -----------------------------------------------------------
// Validation guard detection
// -----------------------------------------------------------
Expand Down
Loading