Skip to content

chore(daemon): collapse the HTTP router onto one deep request adapter + route table#323

Merged
sethvoltz merged 2 commits into
mainfrom
chore/daemon-router-table
Jun 26, 2026
Merged

chore(daemon): collapse the HTTP router onto one deep request adapter + route table#323
sethvoltz merged 2 commits into
mainfrom
chore/daemon-router-table

Conversation

@sethvoltz

Copy link
Copy Markdown
Owner

What & why

The daemon's handle() was a 2545-line shell routing via a hand-written if/else cascade of 83 branches, each re-implementing the same mechanics: read body → validate → call subsystem → shape an ad-hoc { error } envelope → pick a status. Apply the deletion test and the behavior doesn't vanish — the tell of a shallow shell carrying no logic of its own.

This introduces one deep request adapter (router.ts: handleRequest) plus a route table of declarative data rows (method, match) → { auth?, schema?, handler }. The adapter owns the cross-cutting mechanics — auth gate → body parse → schema validation → handler → error envelope — so handle() shrinks to a 3-line dispatch and each of the 83 branches becomes one row. Modeled on the repo's own blessed exemplar (inbox/route-registry.ts): the schema field is the producer-agnostic { safeParse } structural contract, so the router never imports zod's full surface.

Faithfulness — structural, not a contract change

Verified by 3 independent adversarial passes diffing the new table against the original cascade (git show HEAD):

  • 83 original branches → 83 table rows, 1:1 — none dropped, none added.
  • Auth split preserved exactly: the 21 inline-authorizeSameHost routes carry auth: true; the other 62 rely on the 127.0.0.1 bind as before. No gated→public regression.
  • Declaration order preserves every specific-before-broad case (/api/memory/search before /<id>; habits checkin/archive before bare /<id>; schedules action-paths before bare; etc.).
  • The 5 streaming/binary/seam routes (events SSE, uploads POST/GET, evolve scan, commands dispatch) are raw rows that own req/res — no body parse, no serialize. The ADR-036 evolve-scan daemonEffects object (config-gated triage/builder spawn IO + the un-forgeable evolveEscalation carve-out) moves byte-for-byte, as do the helper tail + createAgent.

Three intentional improvements (each test-pinned)

  1. POST /api/memory's inline title/content check becomes a zod schema run by the adapter (the one demonstration of the schema path) — 400 message text changes and non-array tags now 400s instead of being silently mis-stored.
  2. A malformed JSON body becomes one clean 400 instead of an uncaught readJson throw (previously an unhandled rejection / hung connection).
  3. An uncaught handler throw becomes one centralized 500 instead of a hang.

Tests

  • router.test.ts unit-tests the adapter's 4 cross-cutting behaviors + the matcher once each (14 tests, fake req/res, real zod schema so validation genuinely runs).
  • Added round-trip endpoint tests for habits / memory / uploads — families that had no daemon HTTP coverage and would otherwise have migrated without a net.
  • pnpm --filter @friday/daemon exec vitest run src/api14 files / 83 tests green; daemon tsc --noEmit clean; prettier clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PGvF96oXJeppyc7PvhMPen

sethvoltz and others added 2 commits June 25, 2026 18:35
… + route table

The daemon's `handle()` was a 2545-line shell routing via a hand-written
if/else cascade of 83 branches, each re-implementing the same mechanics:
read body → validate → call subsystem → shape an ad-hoc { error } envelope →
pick a status. Apply the deletion test and the behavior doesn't vanish — the
tell of a shallow shell carrying no logic of its own.

This introduces ONE deep request adapter (`router.ts: handleRequest`) plus a
route table of declarative data rows `(method, match) → { auth?, schema?,
handler }`. The adapter owns the cross-cutting mechanics — auth gate, body
parse, schema validation, the error envelope — so `handle()` shrinks to a
3-line dispatch and each of the 83 branches becomes one row. Modeled on the
repo's own blessed exemplar (`inbox/route-registry.ts`): the schema field is
the producer-agnostic `{ safeParse }` structural contract, so the router never
imports zod's full surface.

Structural deepening, NOT a contract change — verified faithful by 3 independent
adversarial passes against the original cascade:
- 83 original branches → 83 table rows, 1:1, none dropped/added.
- The 21 inline-`authorizeSameHost` routes carry `auth: true`; the other 62
  rely on the 127.0.0.1 bind exactly as before. No gated→public regression.
- Declaration order preserves every specific-before-broad case (memory/search
  before /<id>; habits checkin/archive before bare /<id>; etc.).
- The 5 streaming/binary/seam routes (events SSE, uploads POST/GET, evolve scan,
  commands dispatch) are `raw` rows that own req/res — no body parse, no
  serialize. The ADR-036 evolve-scan `daemonEffects` object (config-gated
  triage/builder spawn IO + the un-forgeable evolveEscalation carve-out) moves
  byte-for-byte. The helper tail + createAgent are byte-for-byte identical.

Three intentional, documented improvements (each pinned by a test):
- POST /api/memory's inline title/content check becomes a zod schema run by the
  adapter (the one demonstration of the schema path) — 400 message text changes
  and non-array `tags` now 400s instead of being silently mis-stored.
- A malformed JSON body becomes one clean 400 instead of an uncaught readJson
  throw (which previously became an unhandled rejection / hung connection).
- An uncaught handler throw becomes one centralized 500 instead of a hang.

Tests: `router.test.ts` unit-tests the adapter's 4 cross-cutting behaviors +
the matcher once each (14 tests, fake req/res, real zod schema). Added
round-trip endpoint tests for the habits/memory/uploads families, which had no
daemon HTTP coverage and would otherwise have migrated without a net. Full
`src/api` suite: 83 tests green; daemon typecheck + prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGvF96oXJeppyc7PvhMPen
…ing, auth, raw)

Closes the one gap the Spec-axis review flagged: the handoff's Test Strategy
asked for a route-table contract test, which was missing. `router.test.ts`
exercised the adapter against a fake 3-row table, so the REAL 83-row table had
no exhaustiveness/ordering/auth guard.

Exports `ROUTES` and adds `router-table.test.ts` asserting, against the real
table:
- unique (method, match) keys — no accidental shadow/duplicate;
- the table covers EXACTLY the golden (method, match) set the original cascade
  served — a hand-listed set, independent of the table, so any drop/add fails
  loudly (83 routes);
- every specific route precedes the broad regex that would swallow it
  (memory/search, agents/sessions, habits checkin/archive, elicitation, schedules);
- exactly the 21 cascade-gated routes carry auth:true (security-regression guard);
- exactly the 5 streaming/binary/seam routes are raw and carry no JSON
  handler/schema.

Going forward a new route must update the golden set here — that friction is the
point: every routing change stays intentional and reviewed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGvF96oXJeppyc7PvhMPen
@sethvoltz sethvoltz merged commit 861bd4e into main Jun 26, 2026
6 checks passed
@sethvoltz sethvoltz deleted the chore/daemon-router-table branch June 26, 2026 01:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant