--- phase: 02-ai-pipeline plan: "03" subsystem: api, ai, netbox, queue, config tags: [go, intake, multipart, orchestrator, netbox, waq, quick-add, tdd] dependency_graph: requires: - internal/ai.Orchestrator - internal/netbox.Client - internal/inventory.CatalogUpdater - internal/queue.WAQ - internal/queue.NewNetBoxOpHandler - internal/config.Config provides: - internal/api/handlers.IntakeHandler - internal/api/handlers.NewIntakeHandler - internal/api/handlers.IntakeOrchestrator (interface) - internal/api/handlers.IntakeNetBoxClient (interface) - internal/api/handlers.IntakeCatalogUpdater (interface) - internal/api/handlers.IntakeWAQ (interface) - POST /api/intake route affects: - internal/api/router.go (NewRouter signature extended) - internal/config/config.go (NetBoxDefault* fields added) - cmd/hwlab/main.go (full wiring, NoOpHandler replaced) - internal/netbox/custom_fields.go (PatchCustomFields int→int64) - internal/inventory/catalog_updater.go (UpdateCatalogStatus int→int64) tech_stack: added: [] patterns: - interface-for-testability (IntakeOrchestrator/IntakeNetBoxClient/IntakeCatalogUpdater/IntakeWAQ) - typed-interface-nil-guard (waqForHandler avoids nil-interface-wrapping bug) - graceful-degradation (WAQ enqueue on NetBox failure; 503 if both unavailable) - tdd-red-green (6 tests written before handler implemented) key_files: created: - internal/api/handlers/intake.go - internal/api/handlers/intake_test.go modified: - internal/api/router.go - internal/config/config.go - cmd/hwlab/main.go - internal/netbox/custom_fields.go - internal/netbox/custom_fields_test.go - internal/inventory/catalog_updater.go decisions: - id: INTAKE-01 summary: "IntakeHandler uses four interfaces (IntakeOrchestrator/IntakeNetBoxClient/IntakeCatalogUpdater/IntakeWAQ) rather than concrete types — allows complete unit testing without real NetBox or AI" - id: INTAKE-02 summary: "Nil WAQ passes as typed interface nil (not nil *WAQ) to avoid Go nil-interface-wrapping bug — main.go declares var waqForHandler handlers.IntakeWAQ and only assigns when WAQ init succeeds" - id: INTAKE-03 summary: "PatchCustomFields and UpdateCatalogStatus signatures changed from int to int64 to match CreateDevice return type and queue.NetBoxOpsClient interface — int was inconsistent with the rest of the NetBox ID surface" - id: INTAKE-04 summary: "Standard path and quick-add path both call CreateDevice immediately — the review step is a UI concern handled by the frontend; the handler always attempts immediate creation" metrics: duration: "~15 minutes" completed: "2026-04-10T06:08:00Z" tasks_completed: 2 files_created: 2 files_modified: 6 --- # Phase 2 Plan 03: POST /api/intake Handler, Router Wiring, Quick Add Mode Summary **One-liner:** Multipart photo upload handler wiring orchestrator → HW-ID allocation → NetBox create (or WAQ enqueue on failure), with quick-add bypass and six TDD unit tests covering all code paths. ## What Was Built ### `internal/api/handlers/intake.go` - Four interfaces (`IntakeOrchestrator`, `IntakeNetBoxClient`, `IntakeCatalogUpdater`, `IntakeWAQ`) for full testability without concrete dependencies - `IntakeHandler` struct and `NewIntakeHandler` constructor accepting all four interfaces plus NetBox default IDs and quick-add config - `ServeHTTP` flow: 1. ParseMultipartForm (32 MB cap — T-02-09 DoS mitigation) 2. Validate 1–3 photos (T-02-12 count check) 3. Read + base64-encode each photo with MIME detection 4. `orchestrator.Analyze` → `AllocateNextHWID` → `CreateDevice` 5. On NetBox failure: enqueue `OpNetBoxCreateDevice` to WAQ → 202; nil WAQ → 503 6. On success: `PatchCustomFields` + `SyncTags` + `UpdateCatalogStatus` (all non-fatal) → 201 - `IntakeResponse` JSON struct with `hw_id`, `model`, `manufacturer`, `category`, `specs`, `suggested_tags`, `ai_notes`, `confidence`, `catalog_status`, `netbox_id`, `queued` ### `internal/api/handlers/intake_test.go` Six TDD tests: | Test | Scenario | Expected | |------|----------|----------| | TestIntakeHandlerRejectsZeroPhotos | 0 files | 400 | | TestIntakeHandlerRejectsFourPhotos | 4 files | 400 | | TestIntakeHandlerHighConfidence | confidence 0.95, NetBox OK | 201, catalog_status=indexed | | TestIntakeHandlerLowConfidence | confidence 0.40 | 201, catalog_status=needs_research | | TestIntakeHandlerQuickAdd | quick_add=true, confidence 0.95 | 201, CreateDevice called once | | TestIntakeHandlerNetBoxDown | CreateDevice error | 202, queued=true, 1 WAQ op enqueued | ### `internal/api/router.go` - `NewRouter` signature extended: `NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler` - `r.Post("/intake", intakeHandler.ServeHTTP)` registered under `/api` ### `internal/config/config.go` - Three new fields: `NetBoxDefaultDeviceTypeID`, `NetBoxDefaultRoleID`, `NetBoxDefaultSiteID` (all `int32`) - Defaults: all `1` (placeholder IDs provisioned in NetBox Phase 1) - Env bindings: `HWLAB_NETBOX_DEFAULT_DEVICE_TYPE_ID`, `_ROLE_ID`, `_SITE_ID` ### `cmd/hwlab/main.go` - Creates `netbox.NewClient`, `ai.NewTierClient` (x2), `ai.NewOrchestrator`, `inventory.NewCatalogUpdater` - WAQ init: uses `var waqForHandler handlers.IntakeWAQ` typed interface variable — only assigned when WAQ init succeeds, ensuring nil interface (not nil-wrapped-in-interface) when unavailable - Replaces `queue.NoOpHandler` with `queue.NewNetBoxOpHandler(nbClient)` for WAQ worker - Passes `intakeHandler` to `api.NewRouter` ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] PatchCustomFields and UpdateCatalogStatus used `int` instead of `int64`** - **Found during:** Task 1, implementing IntakeNetBoxClient interface - **Issue:** `custom_fields.go` declared `PatchCustomFields(ctx, deviceID int, ...)` but `queue.NetBoxOpsClient` interface (from Plan 02) declared it as `int64`. The `*netbox.Client` would not satisfy `NetBoxOpsClient` at compile time. `UpdateCatalogStatus` in `catalog_updater.go` similarly used `int`, inconsistent with `CreateDevice` returning `int64`. - **Fix:** Changed both signatures to `int64`. The API call site casts to `int32(deviceID)` (fits; NetBox IDs are 32-bit). Updated `custom_fields_test.go` integration test variable type accordingly. - **Files modified:** `internal/netbox/custom_fields.go`, `internal/netbox/custom_fields_test.go`, `internal/inventory/catalog_updater.go` - **Commits:** 4fc9362 **2. [Rule 2 - Missing nil guard] Nil WAQ must not be passed as non-nil interface** - **Found during:** Task 2, wiring main.go - **Issue:** If `queue.NewWAQ` fails and `waq` remains a nil `*queue.WAQ`, passing it directly to `NewIntakeHandler` (which takes `IntakeWAQ`) creates a non-nil interface wrapping a nil pointer. The nil check in `ServeHTTP` (`if h.waq != nil`) would always pass, causing a nil pointer panic on WAQ error. - **Fix:** Declared `var waqForHandler handlers.IntakeWAQ` (interface type, zero value is true nil interface) and only assigned `waqInstance` to it when WAQ init succeeded. - **Files modified:** `cmd/hwlab/main.go` - **Commit:** 59aa89b ## Known Stubs None — all data paths are wired end-to-end. The handler calls real orchestrator, real NetBox client, and real WAQ in production. ## Threat Surface Coverage All five threats from the plan's threat register are mitigated: | Threat | Mitigation | Where | |--------|-----------|-------| | T-02-09: DoS via upload size | `r.ParseMultipartForm(32 << 20)` — 32 MB hard cap | intake.go:ServeHTTP | | T-02-10: AI-extracted device name tampering | Go string passed to go-netbox JSON marshal; no SQL path | intake.go, client.go | | T-02-11: AI-extracted tag injection | normalizeTags in SyncTags (Phase 1 T-04-02) | tags.go | | T-02-12: Photo count bypass | `len(files) == 0` and `len(files) > 3` checked before any processing | intake.go:ServeHTTP | | T-02-13: hw_id spoofing | AllocateNextHWID assigns ID server-side; caller cannot control it | hwid.go | ## Self-Check Files created: - internal/api/handlers/intake.go: FOUND - internal/api/handlers/intake_test.go: FOUND Commits: - 4fc9362: feat(02-03): POST /api/intake handler with orchestrator and NetBox wiring - 59aa89b: feat(02-03): wire POST /api/intake route, real WAQ handler, and NetBox defaults in config `go build ./...`: PASS `go test ./internal/api/... -v`: 7/7 PASS (6 intake + 1 health) `go test ./...`: all packages PASS `grep "Post.*intake" internal/api/router.go`: PRESENT `grep "NewNetBoxOpHandler" cmd/hwlab/main.go`: PRESENT `grep "NoOpHandler" cmd/hwlab/main.go`: NOT PRESENT ## Self-Check: PASSED