From 16cfc48644dd1d8d22039f42648f5ad793551c26 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 05:56:50 +0000 Subject: [PATCH] docs(02-03): complete intake handler plan summary --- .../phases/02-ai-pipeline/02-03-SUMMARY.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .planning/phases/02-ai-pipeline/02-03-SUMMARY.md diff --git a/.planning/phases/02-ai-pipeline/02-03-SUMMARY.md b/.planning/phases/02-ai-pipeline/02-03-SUMMARY.md new file mode 100644 index 0000000..0b069f1 --- /dev/null +++ b/.planning/phases/02-ai-pipeline/02-03-SUMMARY.md @@ -0,0 +1,164 @@ +--- +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