go-demo-surface-route-registration
Root cause (GAP-009): cmd/demo/main.go built its own http.ServeMux with only the lifecycle routes; the three GAP-002 contract routes lived only in serve.go's NewFullMux route table. Handlers already existed (internal/api/designs.go), so the demo surface 404'd on routes the full server served — a route-table drift the E2E battery caught.
The fix — 3 HandleFunc lines in cmd/demo/main.go, mirroring serve.go:
// cmd/demo/main.go — newDemoMux (fix)
mux.HandleFunc("POST /projects", s.HandleProjectCreate)
mux.HandleFunc("GET /projects/{id}", s.HandleProjectGet)
// GAP-009 FIX: mirror serve.go's contract routes (present since GAP-002).
mux.HandleFunc("GET /projects/{id}/materials", s.HandleMaterials)
mux.HandleFunc("GET /projects/{id}/costs", s.HandleCosts)
mux.HandleFunc("GET /projects/{id}/design", s.HandleDesign)
serve.go's reference table (unchanged):
mux.HandleFunc("GET /projects/{id}/materials", s.HandleMaterials)
mux.HandleFunc("GET /projects/{id}/costs", s.HandleCosts)
mux.HandleFunc("GET /projects/{id}/design", s.HandleDesign)
Regression test — verifyProjectContractRoutes wired into TestDemoSmoke (cmd/demo/main_test.go), backed by a shared E2E battery (internal/contractbattery):
func TestDemoSmoke(t *testing.T) {
srv := httptest.NewServer(newDemoMux(api.New()))
defer srv.Close()
// ... healthz check ...
verifyProjectContractRoutes(t, srv.URL) // GAP-009 regression
}
// Creates a project via POST, GETs the three contract routes, and asserts
// never-404 (registered) and never >=500 (handlers work).
func verifyProjectContractRoutes(t *testing.T, base string) {
t.Helper()
contractbattery.Verify(t, base)
}
The battery's core assertion (per route: /projects/{id}/materials|costs|design):
projectID := createProject(t, base) // POST /projects -> 201
// ...
if resp.StatusCode == http.StatusNotFound {
t.Fatalf("contract route %s -> 404: route not registered on this surface", tc.path)
}
if resp.StatusCode >= http.StatusInternalServerError {
t.Fatalf("contract route %s -> %d: handler error: %s", tc.path, resp.StatusCode, body)
}
// expected statuses: materials 200, costs 200, design 409 + error code API-ERR-051
The battery also verifies the design 409 carries api.ErrCodeDesignNotReady ("API-ERR-051") — the established project-not-ready domain behavior, distinct from a route miss. serve_test.go runs the same battery against NewFullMux to lock the full server as the parity reference.
**1. Bug reproduced first (regression test proves it catches GAP-009).** Before the fix, `go test ./...` failed exactly as described:
```
--- FAIL: TestDemoSmoke/materials
battery.go:51: contract route /projects/p-1/materials -> 404: route not registered on this surface
--- FAIL: TestDemoSmoke/costs
battery.go:51: contract route /projects/p-1/costs -> 404: route not registered on this surface
--- FAIL: TestDemoSmoke/design
battery.go:51: contract route /projects/p-1/design -> 404: route not registered on this surface
FAIL example.com/go-demo/cmd/demo
```
Meanwhile the full server and handler packages passed — proving the drift was demo-surface route registration only.
**2. After the fix — full suite green** (`go vet ./...`, `go test -race -count=1 ./...`, `gofmt` all clean):
```
ok example.com/go-demo (TestFullServerContractRoutes + 3 subtests)
ok example.com/go-demo/cmd/demo (TestDemoSmoke + 3 subtests)
ok example.com/go-demo/internal/api (4 tests)
```
**3. Live probe against the running demo binary** (matching spec: materials 200, costs 200, design 409 API-ERR-051):
```
POST /projects -> {"project":{"id":"p-1","name":"live-probe","ready":false}} 201
GET /projects/p-1/materials -> [200] {"materials":[...],"project_id":"p-1"}
GET /projects/p-1/costs -> [200] {"costs":[...],"project_id":"p-1"}
GET /projects/p-1/design -> [409] {"error":{"code":"API-ERR-051","message":"project-not-ready"}}
GET /healthz -> [200]
```
**4. Edge cases tested.**
- *Route-miss 404 vs handler 404:* `GET /projects/nope/{materials,costs,design}` → `404 {"error":{"code":"API-ERR-404","message":"project not found"}}` — the battery flags only the unregistered-route 404 (wrong status class on an existing project); handler-level 404 for missing projects is correct domain behavior (`TestMissingProjectIs404`).
- *Design not-ready path:* 409 + `API-ERR-051` + `project-not-ready` asserted at handler level (`TestDesignNotReadyIs409`, the designs_test.go:449-equivalent assertion).
- *Design ready path:* after `MarkReady`, design → 200 (`TestDesignReadyReturns200`).
- *Materials/costs always 200 for existing project* regardless of readiness (`TestMaterialsCostsAlways200ForExistingProject`).
- *Full-server/demo parity:* the same battery passes against both `NewFullMux` and `newDemoMux`; no 5xx under `-race`.
- *Operational:* added `DEMO_ADDR` env override to `main()` so the demo can bind a free port (verified live on `:18081`).
Delivered as a git repo (`~/go-demo`, commit `a7dcd50`, 8 files): `serve.go`, `serve_test.go`, `cmd/demo/main.go`, `cmd/demo/main_test.go`, `internal/api/designs.go`, `internal/api/designs_test.go`, `internal/contractbattery/battery.go`, `go.mod`.{"model": "deepseek-v4-flash", "problem_class": "go-demo-surface-route-registration", "result": "passed", "tests": 6}