diff --git a/.planning/phases/01-foundation/01-04-SUMMARY.md b/.planning/phases/01-foundation/01-04-SUMMARY.md new file mode 100644 index 0000000..1675348 --- /dev/null +++ b/.planning/phases/01-foundation/01-04-SUMMARY.md @@ -0,0 +1,136 @@ +--- +phase: 01-foundation +plan: "04" +subsystem: netbox,inventory +tags: [go, hwid, quality-gate, tags, tdd, state-machine] +dependency_graph: + requires: [internal/netbox.Client, internal/netbox.PatchCustomFields] + provides: [internal/netbox.AllocateNextHWID, internal/inventory.CatalogStatus, internal/inventory.Transition, internal/inventory.CatalogUpdater, internal/netbox.SyncTags] + affects: [internal/intake, internal/advisor] +tech_stack: + added: [] + patterns: [optimistic-lock-retry, forward-only-state-machine, slug-normalization, tdd-red-green] +key_files: + created: + - internal/netbox/hwid.go + - internal/netbox/hwid_test.go + - internal/inventory/quality_gate.go + - internal/inventory/quality_gate_test.go + - internal/inventory/types.go + - internal/inventory/catalog_updater.go + - internal/netbox/tags.go + - internal/netbox/tags_test.go + modified: [] +decisions: + - id: HWID-01 + summary: "AllocateNextHWID scans all devices with Limit(1000) to find highest HW-XXXXX — acceptable for Phase 1 inventory size (T-04-03: accepted risk)" + - id: HWID-02 + summary: "Optimistic-lock retry (3 attempts) handles concurrent allocation without transactions — sufficient for single-operator homelab" + - id: QG-01 + summary: "validTransitions map encodes the only valid state transitions; no backward transitions permitted (T-04-01 mitigation)" + - id: TAG-01 + summary: "normalizeTags uses tagNameToSlug internally — ensures 'USB Cable', 'USB cable', 'usb-cable' all deduplicate to same slug form" + - id: TAG-02 + summary: "ensureTag checks by slug before creating — idempotent across multiple SyncTags calls for same tag names" +metrics: + duration: "~20 minutes" + completed: "2026-04-10T06:00:00Z" + tasks_completed: 2 + files_created: 8 + files_modified: 0 +--- + +# Phase 1 Plan 04: HW-ID, Quality Gate, Tag Sync, Catalog Updater Summary + +**One-liner:** Sequential HW-XXXXX ID allocation with optimistic-lock retry, forward-only CatalogStatus state machine enforced via Transition(), AI tag slug-normalization and NetBox sync, and CatalogUpdater wiring quality gate to NetBox PATCH. + +## What Was Built + +### `internal/netbox/hwid.go` +- `formatHWID(n int) string` — formats integer as `HW-NNNNN` (zero-padded 5 digits) +- `parseHWID(s string) (int, error)` — parses `HW-NNNNN` with strict regex `^HW-(\d{5})$` +- `(c *Client) AllocateNextHWID(ctx) (string, error)` — queries highest existing asset_tag, increments, checks candidate is unclaimed, retries up to 3 times on conflict +- `getHighestHWIDNumber` — scans `DcimDevicesList(Limit=1000)` for highest HW-XXXXX number +- `hwIDExists` — checks `DcimDevicesList(AssetTag=[candidate])` for conflict detection + +### `internal/inventory/quality_gate.go` +- `CatalogStatus` string type with 5 constants: `StatusDraft`, `StatusIndexed`, `StatusNeedsResearch`, `StatusResearched`, `StatusComplete` +- `validTransitions` map encodes the only permitted forward transitions +- `(s CatalogStatus) CanTransitionTo(next)` — O(n) lookup in allowed list +- `Transition(current, next)` — returns error with "invalid transition" message on rejection +- `ParseCatalogStatus(s)` — validates string against validTransitions keys +- `AllStatuses()` — returns statuses in lifecycle order + +### `internal/inventory/types.go` +- `HardwareRecord` — domain struct composing `netbox.CustomFields`, `CatalogStatus`, HWID, NetBoxID, Name, AITags + +### `internal/inventory/catalog_updater.go` +- `CatalogUpdater` — wraps `*netbox.Client` +- `UpdateCatalogStatus(ctx, deviceID, current, next)` — calls `Transition()` for validation then `PatchCustomFields` with `{"catalog_status": string(newStatus)}` — T-04-01 mitigation enforced here + +### `internal/netbox/tags.go` +- `normalizeTags(tags)` — lowercases, slug-converts (spaces→hyphens, non-slug chars stripped), deduplicates; "USB Cable"/"USB cable"/"usb-cable" all produce "usb-cable" +- `tagNameToSlug(name)` — lowercase + trim + space-to-hyphen + strip non-[a-z0-9-_] +- `TagRef` — holds ID, Name, Slug for a resolved NetBox tag +- `(c *Client) SyncTags(ctx, tags)` — normalizes then ensureTag for each +- `ensureTag` — `ExtrasTagsList(Slug=slug)` to check existence; `ExtrasTagsCreate(TagRequest{Name,Slug})` to create new + +## Test Results + +| Test | File | Result | +|------|------|--------| +| TestFormatHWID (3 cases) | hwid_test.go | PASS | +| TestParseHWID (7 cases) | hwid_test.go | PASS | +| TestCanTransitionTo (12 cases) | quality_gate_test.go | PASS | +| TestTransitionValid | quality_gate_test.go | PASS | +| TestTransitionInvalid | quality_gate_test.go | PASS | +| TestParseCatalogStatus (5+1 cases) | quality_gate_test.go | PASS | +| TestNormalizeTags | tags_test.go | PASS | +| TestTagNameToSlug (4 cases) | tags_test.go | PASS | + +Integration tests (AllocateNextHWID live, SyncTags live): **SKIPPED** — placeholder token (correct behavior, will run once real HWLAB_NETBOX_TOKEN is set). + +`go build ./...`: PASS +`go test ./internal/...`: 16 PASS, 3 SKIP (integration guards), 0 FAIL + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] normalizeTags needed slug conversion for dedup correctness** +- **Found during:** Task 2 GREEN phase (TestNormalizeTags failure) +- **Issue:** Original `normalizeTags` only lowercased/trimmed — "USB Cable" and "usb-cable" remained distinct (2 results, not 1) +- **Fix:** Changed `normalizeTags` to delegate to `tagNameToSlug` internally — ensures space-to-hyphen and non-slug stripping before dedup; test comment says these three should all produce "usb-cable" +- **Files modified:** internal/netbox/tags.go +- **Commit:** 1f9621f + +## Known Stubs + +None. `ensureTag` uses real go-netbox v4 `NewTagRequest`/`ExtrasTagsCreate` API. `AllocateNextHWID` uses real `DcimDevicesList` API. No stubs in any shipped code. + +## Threat Surface Scan + +No new network endpoints introduced. All trust boundary mitigations from plan's threat model are implemented: +- T-04-01: `UpdateCatalogStatus` enforces `Transition()` before any `PatchCustomFields` call — bypass impossible through this path +- T-04-02: `normalizeTags` strips injection surface before NetBox write — all AI tag strings pass through slug normalization + +## Self-Check + +Files created: +- internal/netbox/hwid.go: FOUND +- internal/netbox/hwid_test.go: FOUND +- internal/inventory/quality_gate.go: FOUND +- internal/inventory/quality_gate_test.go: FOUND +- internal/inventory/types.go: FOUND +- internal/inventory/catalog_updater.go: FOUND +- internal/netbox/tags.go: FOUND +- internal/netbox/tags_test.go: FOUND + +Commits: +- e1cee31 — Task 1 (HW-XXXXX sequential ID allocation) +- 1f9621f — Task 2 (quality gate, tag sync, catalog updater) + +`go build ./...`: PASS +`go test ./internal/...`: 16 PASS, 3 SKIP, 0 FAIL + +## Self-Check: PASSED