docs(01-04): complete HW-ID, quality gate, tag sync plan

- SUMMARY.md: 8 files created, 16 unit tests pass, 3 integration skipped
- normalizeTags deviation documented (slug dedup fix)
- T-04-01/T-04-02 mitigations confirmed in catalog_updater and tags
This commit is contained in:
Mikkel Georgsen 2026-04-10 05:23:13 +00:00
parent 1f9621fcaa
commit 1707496027

View file

@ -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