homelabby/.planning/phases/01-foundation/01-04-PLAN.md

29 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation 04 execute 2
01-02-PLAN.md
internal/netbox/hwid.go
internal/netbox/hwid_test.go
internal/inventory/quality_gate.go
internal/inventory/quality_gate_test.go
internal/inventory/catalog_updater.go
internal/inventory/types.go
internal/netbox/tags.go
internal/netbox/tags_test.go
true
INF-03
NB-06
NB-07
truths artifacts key_links
AllocateNextHWID returns HW-00001 on first call against an empty NetBox
AllocateNextHWID returns HW-00002 on subsequent calls (increments)
CatalogStatus.CanTransitionTo enforces valid transitions (draft→indexed allowed, indexed→draft rejected)
Invalid transitions return an error with the exact invalid transition described
SyncTags creates new NetBox tags for AI-suggested tags not yet present
path provides exports
internal/netbox/hwid.go AllocateNextHWID: optimistic-lock sequential ID allocation from NetBox
AllocateNextHWID
path provides exports
internal/inventory/quality_gate.go CatalogStatus type with Transition() enforcing valid state machine
CatalogStatus
StatusDraft
StatusIndexed
StatusNeedsResearch
StatusResearched
StatusComplete
Transition
path provides exports
internal/inventory/types.go HardwareRecord domain type composing NetBox device with HWLab semantics
HardwareRecord
path provides exports
internal/netbox/tags.go SyncTags: creates NetBox tags from AI-suggested string slice, returns IDs
SyncTags
from to via pattern
internal/netbox/hwid.go http://10.5.0.130:8000/api DcimDevicesList filtered by asset_tag pattern DcimDevicesList.*asset_tag|asset_tag.*DcimDevicesList
from to via pattern
internal/inventory/quality_gate.go internal/netbox/client.go CatalogStatus value stored as NetBox custom field via PatchCustomFields PatchCustomFields.*catalog_status
Implement three distinct capabilities that all depend on the NetBox client (Plan 02): HW-XXXXX sequential ID allocation, catalog quality gate state machine, and AI tag sync to NetBox.

Purpose: These three capabilities are the behavioral core of Phase 1. HW-ID is required at intake time (Phase 2). Quality gate drives all lifecycle operations. Tag sync links AI output to NetBox taxonomy. Output: Three packages — netbox/hwid.go, inventory/quality_gate.go, netbox/tags.go — all tested independently.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.env @.planning/phases/01-foundation/01-RESEARCH.md @.planning/phases/01-foundation/01-02-SUMMARY.md From internal/netbox/client.go: ```go func NewClient(url, token string) (*Client, error) func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error) func (c *Client) PatchCustomFields(ctx context.Context, deviceID int, patch map[string]interface{}) error // c.api is *nb.APIClient — can call DcimAPI, ExtrasAPI directly from hwid.go and tags.go ```

From internal/netbox/types.go:

type Device struct {
    ID          int
    Name        string
    AssetTag    string // HW-XXXXX
    CustomFields CustomFields
    Created     time.Time
    LastUpdated time.Time
}
type CustomFields struct {
    HWID, CatalogStatus, ProductURL, FirmwareVersion string
    TestDate, TestData, AINotes string
    PhotoURLs []string
}

From internal/netbox/custom_fields.go:

func BuildCustomFieldsPatch(hwID, catalogStatus string, photoURLs []string) map[string]interface{}
Task 1: HW-XXXXX sequential ID allocation (INF-03) internal/netbox/hwid.go, internal/netbox/hwid_test.go

<read_first> - /home/mikkel/homelabby/internal/netbox/client.go (Client struct, api access) - /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 4: HW-XXXXX Sequential ID Allocation, lines 238-265) </read_first>

- Test 1: parseHWID("HW-00042") returns 42, nil - Test 2: parseHWID("HW-99999") returns 99999, nil - Test 3: parseHWID("not-a-hw-id") returns 0, error - Test 4: parseHWID("") returns 0, error - Test 5: formatHWID(1) returns "HW-00001" - Test 6: formatHWID(99999) returns "HW-99999" - Test 7 (INTEGRATION — skip if no real token): AllocateNextHWID returns a string matching ^HW-\d{5}$ Create `internal/netbox/hwid.go`: ```go package netbox
import (
    "context"
    "errors"
    "fmt"
    "regexp"
    "strconv"
    "strings"
)

var hwIDPattern = regexp.MustCompile(`^HW-(\d{5})$`)

// formatHWID formats an integer as a HW-XXXXX string.
func formatHWID(n int) string {
    return fmt.Sprintf("HW-%05d", n)
}

// parseHWID parses a HW-XXXXX string to an integer.
// Returns error if the format does not match.
func parseHWID(s string) (int, error) {
    m := hwIDPattern.FindStringSubmatch(s)
    if m == nil {
        return 0, fmt.Errorf("invalid HW-ID format: %q (expected HW-NNNNN)", s)
    }
    n, err := strconv.Atoi(m[1])
    if err != nil {
        return 0, err
    }
    return n, nil
}

// AllocateNextHWID allocates the next available HW-XXXXX identifier.
// Strategy: optimistic locking — query the highest existing asset_tag, increment by 1,
// attempt to reserve it. Retry up to 3 times on conflict.
//
// The reservation is a placeholder NetBox device with name "__hwid_reservation__"
// that the caller MUST immediately replace with the real device data.
// In practice, Phase 2 will create the real device in a single atomic step,
// so the placeholder device is never committed separately.
//
// For Phase 1, AllocateNextHWID returns the ID string without creating a device.
// The caller is responsible for creating the device record and setting asset_tag.
func (c *Client) AllocateNextHWID(ctx context.Context) (string, error) {
    const maxAttempts = 3

    for attempt := 0; attempt < maxAttempts; attempt++ {
        highest, err := c.getHighestHWIDNumber(ctx)
        if err != nil {
            return "", fmt.Errorf("get highest HW-ID: %w", err)
        }
        candidate := formatHWID(highest + 1)
        // Check that this candidate is not already taken
        // (handles concurrent allocation if ever needed)
        taken, err := c.hwIDExists(ctx, candidate)
        if err != nil {
            return "", fmt.Errorf("check HW-ID %s: %w", candidate, err)
        }
        if !taken {
            return candidate, nil
        }
        // Candidate is taken — loop and try highest+2, etc.
    }
    return "", errors.New("HW-ID allocation failed after 3 attempts — concurrent allocation conflict")
}

// getHighestHWIDNumber queries NetBox for the highest existing HW-XXXXX asset_tag number.
// Returns 0 if no HW-XXXXX asset_tags exist (first allocation will be HW-00001).
func (c *Client) getHighestHWIDNumber(ctx context.Context) (int, error) {
    // Query all devices, paginate if needed — in Phase 1 this is small
    // Filter by asset_tag starting with "HW-" to limit results
    // NOTE: go-netbox v4 DcimDevicesList supports AssetTag filter
    // Use limit=1000 and sort by asset_tag descending to find the highest efficiently
    // If NetBox v4 supports ordering by asset_tag: use .Ordering("-asset_tag")

    res, _, err := c.api.DcimAPI.DcimDevicesList(ctx).
        Limit(1000).
        Execute()
    if err != nil {
        return 0, fmt.Errorf("list devices for HW-ID query: %w", err)
    }

    highest := 0
    for _, d := range res.Results {
        tag := d.GetAssetTag()
        if !strings.HasPrefix(tag, "HW-") {
            continue
        }
        n, err := parseHWID(tag)
        if err != nil {
            continue // non-HWLab asset tag — skip
        }
        if n > highest {
            highest = n
        }
    }
    return highest, nil
}

// hwIDExists checks if a given HW-XXXXX asset_tag is already used in NetBox.
func (c *Client) hwIDExists(ctx context.Context, hwid string) (bool, error) {
    res, _, err := c.api.DcimAPI.DcimDevicesList(ctx).
        AssetTag([]string{hwid}).
        Limit(1).
        Execute()
    if err != nil {
        return false, err
    }
    return res.GetCount() > 0, nil
}
```

Create `internal/netbox/hwid_test.go`:
```go
package netbox

import (
    "testing"
)

func TestFormatHWID(t *testing.T) {
    tests := []struct {
        n    int
        want string
    }{
        {1, "HW-00001"},
        {42, "HW-00042"},
        {99999, "HW-99999"},
    }
    for _, tt := range tests {
        got := formatHWID(tt.n)
        if got != tt.want {
            t.Errorf("formatHWID(%d) = %q, want %q", tt.n, got, tt.want)
        }
    }
}

func TestParseHWID(t *testing.T) {
    tests := []struct {
        s       string
        want    int
        wantErr bool
    }{
        {"HW-00001", 1, false},
        {"HW-00042", 42, false},
        {"HW-99999", 99999, false},
        {"", 0, true},
        {"not-a-hw-id", 0, true},
        {"HW-0001", 0, true},    // only 4 digits — invalid
        {"hw-00001", 0, true},   // lowercase — invalid
    }
    for _, tt := range tests {
        got, err := parseHWID(tt.s)
        if tt.wantErr && err == nil {
            t.Errorf("parseHWID(%q): expected error, got nil", tt.s)
        }
        if !tt.wantErr && err != nil {
            t.Errorf("parseHWID(%q): unexpected error: %v", tt.s, err)
        }
        if !tt.wantErr && got != tt.want {
            t.Errorf("parseHWID(%q) = %d, want %d", tt.s, got, tt.want)
        }
    }
}
```
cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run "TestFormatHWID|TestParseHWID"

<acceptance_criteria> - go test ./internal/netbox/... -run "TestFormatHWID|TestParseHWID" passes all 10 cases - grep "AllocateNextHWID" internal/netbox/hwid.go returns the exported function - grep "HW-%05d" internal/netbox/hwid.go returns the Sprintf format call - grep "getHighestHWIDNumber" internal/netbox/hwid.go returns the private helper - go build ./internal/netbox/... exits 0 </acceptance_criteria>

HW-ID format/parse unit tests pass. AllocateNextHWID implemented with optimistic-lock retry. Binary compiles.

Task 2: Quality gate state machine and AI tag sync (NB-06, NB-07) internal/inventory/types.go, internal/inventory/quality_gate.go, internal/inventory/quality_gate_test.go, internal/netbox/tags.go, internal/netbox/tags_test.go

<read_first> - /home/mikkel/homelabby/internal/netbox/client.go (Client struct for SyncTags) - /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 6: Catalog Status Quality Gate, lines 322-360) - /home/mikkel/homelabby/internal/netbox/types.go (CustomFields.CatalogStatus field) </read_first>

Quality gate tests: - Test 1: StatusDraft.CanTransitionTo(StatusIndexed) returns true - Test 2: StatusDraft.CanTransitionTo(StatusComplete) returns false - Test 3: StatusIndexed.CanTransitionTo(StatusNeedsResearch) returns true - Test 4: StatusIndexed.CanTransitionTo(StatusDraft) returns false (no backward transitions) - Test 5: StatusComplete.CanTransitionTo(anything) returns false (terminal) - Test 6: Transition(StatusDraft, StatusIndexed) returns StatusIndexed, nil - Test 7: Transition(StatusDraft, StatusComplete) returns "", error containing "invalid transition" - Test 8: ParseCatalogStatus("draft") returns StatusDraft, nil - Test 9: ParseCatalogStatus("unknown_value") returns "", error
Tag sync tests:
- Test 10: normalizeTags([]string{"  USB Cable ", "USB cable", "usb-cable"}) returns deduplicated, lowercase-trimmed slice
- Test 11 (INTEGRATION — skip if no real token): SyncTags([]string{"usb-c-cable"}) creates tag in NetBox and returns its ID
1. Create `internal/inventory/types.go`: ```go package inventory
   import "git.georgsen.dk/hwlab/internal/netbox"

   // HardwareRecord is the HWLab domain representation of a cataloged item.
   // It wraps a NetBox device with HWLab-specific fields and lifecycle state.
   type HardwareRecord struct {
       HWID          string              // HW-XXXXX from asset_tag
       NetBoxID      int                 // NetBox device internal ID
       Name          string              // Device name in NetBox
       CatalogStatus CatalogStatus       // Quality gate lifecycle status
       CustomFields  netbox.CustomFields // All HWLab custom fields
       AITags        []string            // AI-suggested tags (synced to NetBox)
   }
   ```

2. Create `internal/inventory/quality_gate.go`:
   ```go
   package inventory

   import "fmt"

   // CatalogStatus represents the lifecycle stage of a cataloged hardware item.
   // Stored as the catalog_status custom field value in NetBox.
   type CatalogStatus string

   const (
       StatusDraft         CatalogStatus = "draft"
       StatusIndexed       CatalogStatus = "indexed"
       StatusNeedsResearch CatalogStatus = "needs_research"
       StatusResearched    CatalogStatus = "researched"
       StatusComplete      CatalogStatus = "complete"
   )

   // validTransitions defines the allowed state machine transitions.
   // No backward transitions are permitted (lifecycle is forward-only).
   var validTransitions = map[CatalogStatus][]CatalogStatus{
       StatusDraft:         {StatusIndexed},
       StatusIndexed:       {StatusNeedsResearch, StatusResearched},
       StatusNeedsResearch: {StatusResearched},
       StatusResearched:    {StatusComplete},
       StatusComplete:      {}, // terminal — no further transitions
   }

   // CanTransitionTo returns true if transitioning from s to next is permitted.
   func (s CatalogStatus) CanTransitionTo(next CatalogStatus) bool {
       allowed, ok := validTransitions[s]
       if !ok {
           return false
       }
       for _, a := range allowed {
           if a == next {
               return true
           }
       }
       return false
   }

   // Transition attempts to move from current to next status.
   // Returns the new status on success, or an error describing the invalid transition.
   func Transition(current, next CatalogStatus) (CatalogStatus, error) {
       if !current.CanTransitionTo(next) {
           return "", fmt.Errorf("invalid transition: %s → %s (not in valid transitions map)", current, next)
       }
       return next, nil
   }

   // ParseCatalogStatus parses a string to a CatalogStatus.
   // Returns error for unknown status values.
   func ParseCatalogStatus(s string) (CatalogStatus, error) {
       cs := CatalogStatus(s)
       if _, ok := validTransitions[cs]; ok {
           return cs, nil
       }
       return "", fmt.Errorf("unknown catalog status: %q (valid: draft, indexed, needs_research, researched, complete)", s)
   }

   // AllStatuses returns all valid catalog statuses in lifecycle order.
   func AllStatuses() []CatalogStatus {
       return []CatalogStatus{
           StatusDraft, StatusIndexed, StatusNeedsResearch, StatusResearched, StatusComplete,
       }
   }
   ```

2b. Create `internal/inventory/catalog_updater.go` — wires quality gate transitions to NetBox persistence:
   ```go
   package inventory

   import (
       "context"
       "fmt"

       "git.georgsen.dk/hwlab/internal/netbox"
   )

   // CatalogUpdater persists quality gate transitions to NetBox.
   type CatalogUpdater struct {
       client *netbox.Client
   }

   // NewCatalogUpdater creates a CatalogUpdater backed by the given NetBox client.
   func NewCatalogUpdater(client *netbox.Client) *CatalogUpdater {
       return &CatalogUpdater{client: client}
   }

   // UpdateCatalogStatus validates the transition from current to next status
   // and persists the result to NetBox via PatchCustomFields.
   // Returns the new status on success.
   func (u *CatalogUpdater) UpdateCatalogStatus(ctx context.Context, deviceID int, current, next CatalogStatus) (CatalogStatus, error) {
       newStatus, err := Transition(current, next)
       if err != nil {
           return "", err
       }
       patch := map[string]interface{}{
           "catalog_status": string(newStatus),
       }
       if err := u.client.PatchCustomFields(ctx, deviceID, patch); err != nil {
           return "", fmt.Errorf("persist catalog_status to NetBox: %w", err)
       }
       return newStatus, nil
   }
   ```

3. Create `internal/inventory/quality_gate_test.go`:
   ```go
   package inventory_test

   import (
       "strings"
       "testing"

       "git.georgsen.dk/hwlab/internal/inventory"
   )

   func TestCanTransitionTo(t *testing.T) {
       tests := []struct {
           from    inventory.CatalogStatus
           to      inventory.CatalogStatus
           allowed bool
       }{
           {inventory.StatusDraft, inventory.StatusIndexed, true},
           {inventory.StatusDraft, inventory.StatusComplete, false},
           {inventory.StatusDraft, inventory.StatusDraft, false},
           {inventory.StatusIndexed, inventory.StatusNeedsResearch, true},
           {inventory.StatusIndexed, inventory.StatusResearched, true},
           {inventory.StatusIndexed, inventory.StatusDraft, false},
           {inventory.StatusNeedsResearch, inventory.StatusResearched, true},
           {inventory.StatusNeedsResearch, inventory.StatusIndexed, false},
           {inventory.StatusResearched, inventory.StatusComplete, true},
           {inventory.StatusResearched, inventory.StatusDraft, false},
           {inventory.StatusComplete, inventory.StatusDraft, false},
           {inventory.StatusComplete, inventory.StatusResearched, false},
       }
       for _, tt := range tests {
           got := tt.from.CanTransitionTo(tt.to)
           if got != tt.allowed {
               t.Errorf("%s → %s: want %v, got %v", tt.from, tt.to, tt.allowed, got)
           }
       }
   }

   func TestTransitionValid(t *testing.T) {
       got, err := inventory.Transition(inventory.StatusDraft, inventory.StatusIndexed)
       if err != nil {
           t.Fatalf("unexpected error: %v", err)
       }
       if got != inventory.StatusIndexed {
           t.Errorf("want indexed, got %s", got)
       }
   }

   func TestTransitionInvalid(t *testing.T) {
       _, err := inventory.Transition(inventory.StatusDraft, inventory.StatusComplete)
       if err == nil {
           t.Fatal("expected error for invalid transition")
       }
       if !strings.Contains(err.Error(), "invalid transition") {
           t.Errorf("error should mention 'invalid transition', got: %v", err)
       }
   }

   func TestParseCatalogStatus(t *testing.T) {
       for _, s := range []string{"draft", "indexed", "needs_research", "researched", "complete"} {
           cs, err := inventory.ParseCatalogStatus(s)
           if err != nil {
               t.Errorf("ParseCatalogStatus(%q): unexpected error: %v", s, err)
           }
           if string(cs) != s {
               t.Errorf("ParseCatalogStatus(%q) = %q, want %q", s, cs, s)
           }
       }
       _, err := inventory.ParseCatalogStatus("unknown_status")
       if err == nil {
           t.Error("expected error for unknown status")
       }
   }
   ```

4. Create `internal/netbox/tags.go` (NB-07 — AI tags synced to NetBox):
   ```go
   package netbox

   import (
       "context"
       "fmt"
       "strings"
   )

   // normalizeTags deduplicates and normalizes a slice of tag strings:
   // trims whitespace, lowercases, removes empty strings.
   func normalizeTags(tags []string) []string {
       seen := make(map[string]struct{})
       out := make([]string, 0, len(tags))
       for _, t := range tags {
           t = strings.ToLower(strings.TrimSpace(t))
           if t == "" {
               continue
           }
           if _, ok := seen[t]; ok {
               continue
           }
           seen[t] = struct{}{}
           out = append(out, t)
       }
       return out
   }

   // TagRef holds a NetBox tag name and its internal ID.
   type TagRef struct {
       ID   int32
       Name string
       Slug string
   }

   // SyncTags ensures all tags in the provided slice exist in NetBox.
   // Tags are normalized before sync (lowercase, trimmed, deduplicated).
   // Returns the TagRef list for all tags (existing + newly created).
   func (c *Client) SyncTags(ctx context.Context, tags []string) ([]TagRef, error) {
       normalized := normalizeTags(tags)
       if len(normalized) == 0 {
           return nil, nil
       }

       result := make([]TagRef, 0, len(normalized))
       for _, name := range normalized {
           slug := tagNameToSlug(name)
           ref, err := c.ensureTag(ctx, name, slug)
           if err != nil {
               return result, fmt.Errorf("sync tag %q: %w", name, err)
           }
           result = append(result, ref)
       }
       return result, nil
   }

   // tagNameToSlug converts a tag name to a NetBox-compatible slug.
   // NetBox slugs: lowercase, hyphens instead of spaces, only [a-z0-9-_].
   func tagNameToSlug(name string) string {
       s := strings.ToLower(strings.TrimSpace(name))
       s = strings.ReplaceAll(s, " ", "-")
       // Remove characters not in [a-z0-9-_]
       var out []byte
       for _, c := range []byte(s) {
           if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
               out = append(out, c)
           }
       }
       return string(out)
   }

   // ensureTag returns an existing tag or creates a new one.
   func (c *Client) ensureTag(ctx context.Context, name, slug string) (TagRef, error) {
       // Check for existing tag by slug
       res, _, err := c.api.ExtrasAPI.ExtrasTagsList(ctx).Slug([]string{slug}).Execute()
       if err != nil {
           return TagRef{}, fmt.Errorf("list tags: %w", err)
       }
       if res.GetCount() > 0 {
           t := res.Results[0]
           return TagRef{ID: t.GetId(), Name: t.GetName(), Slug: t.GetSlug()}, nil
       }
       // Create new tag
       // NOTE: Executor must use go-netbox v4 TagRequest type
       // Pattern: c.api.ExtrasAPI.ExtrasTagsCreate(ctx).TagRequest(req).Execute()
       // Required fields: Name (string), Slug (string)
       // Optional: Color (hex string, e.g. "faff69" for volt yellow)
       return TagRef{}, fmt.Errorf("ensureTag: implement using go-netbox v4 TagRequest — grep TagRequest $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/")
   }
   ```

   EXECUTOR: Replace the `ensureTag` stub with real go-netbox v4 TagRequest call. Locate:
   ```
   ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/model_tag_request.go
   ```

5. Create `internal/netbox/tags_test.go`:
   ```go
   package netbox

   import "testing"

   func TestNormalizeTags(t *testing.T) {
       in := []string{"  USB Cable ", "USB cable", "usb-cable", "", "  "}
       out := normalizeTags(in)
       // "USB Cable", "USB cable", "usb-cable" all normalize to "usb-cable" — only 1 unique
       if len(out) != 1 {
           t.Errorf("want 1 unique normalized tag, got %d: %v", len(out), out)
       }
       if out[0] != "usb-cable" {
           t.Errorf("want usb-cable, got %s", out[0])
       }
   }

   func TestTagNameToSlug(t *testing.T) {
       tests := []struct {
           name string
           slug string
       }{
           {"USB Cable", "usb-cable"},
           {"10GbE NIC", "10gbe-nic"},
           {"SFP+ Transceiver", "sfp-transceiver"},
           {"  spaces  ", "spaces"},
       }
       for _, tt := range tests {
           got := tagNameToSlug(tt.name)
           if got != tt.slug {
               t.Errorf("tagNameToSlug(%q) = %q, want %q", tt.name, got, tt.slug)
           }
       }
   }
   ```
cd /home/mikkel/homelabby && go test ./internal/inventory/... ./internal/netbox/... -v -run "TestCanTransitionTo|TestTransitionValid|TestTransitionInvalid|TestParseCatalogStatus|TestNormalizeTags|TestTagNameToSlug"

<acceptance_criteria> - go test ./internal/inventory/... passes all 12 table-driven cases for CanTransitionTo - go test ./internal/inventory/... passes TestTransitionValid, TestTransitionInvalid, TestParseCatalogStatus - go test ./internal/netbox/... -run "TestNormalizeTags|TestTagNameToSlug" passes all cases - grep "StatusComplete.*{}" internal/inventory/quality_gate.go returns the terminal state entry with empty transitions - grep "invalid transition" internal/inventory/quality_gate.go returns error string in Transition() - ensureTag in internal/netbox/tags.go is implemented with real go-netbox v4 TagRequest (not stub returning error string) - grep "UpdateCatalogStatus" internal/inventory/catalog_updater.go returns the function that calls Transition() then PatchCustomFields - grep "PatchCustomFields" internal/inventory/catalog_updater.go confirms NetBox persistence wiring - go build ./internal/inventory/... ./internal/netbox/... exits 0 </acceptance_criteria>

Quality gate state machine fully tested. Tag normalization and slug conversion tested. ensureTag implemented with real go-netbox v4 API. All packages build cleanly.

<threat_model>

Trust Boundaries

Boundary Description
AI output → SyncTags AI-suggested tag strings enter normalizeTags before any NetBox write
Quality gate transitions → NetBox writes Transition validation in Go; NetBox stores the result

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-04-01 Tampering Quality gate bypass mitigate All status changes MUST go through Transition() — no direct NetBox PATCH of catalog_status without Transition validation
T-04-02 Tampering AI tag injection mitigate normalizeTags strips whitespace, lowercases, deduplicates — limits injection surface before NetBox write
T-04-03 Denial of Service AllocateNextHWID scanning all devices accept Phase 1 inventory is small; getHighestHWIDNumber scans all devices; acceptable until inventory exceeds ~10k items
T-04-04 Information Disclosure HW-ID sequential enumeration accept IDs are not secret — they appear on printed labels; sequential is intentional for readability
</threat_model>
After both tasks complete: - `go test ./internal/inventory/... ./internal/netbox/...` all green (unit tests) - `go build ./...` exits 0 (all packages compile together) - Quality gate: `draft → indexed → needs_research → researched → complete` is the only fully valid path - `curl -s -H "Authorization: Token $HWLAB_NETBOX_TOKEN" "http://10.5.0.130:8000/api/extras/tags/" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['count'])"` (verify tags endpoint reachable, if real token)

<success_criteria>

  1. All 12 state machine transition cases tested and correct
  2. Transition() returns error containing "invalid transition" for bad transitions
  3. ParseCatalogStatus rejects unknown status strings
  4. normalizeTags handles deduplication across case/whitespace variants
  5. AllocateNextHWID implemented with optimistic retry loop
  6. ensureTag uses real go-netbox v4 API (not stub)
  7. go build ./... and go test ./... both clean </success_criteria>
After completion, create `.planning/phases/01-foundation/01-04-SUMMARY.md` with: - All test results (pass counts) - Whether ensureTag integration test ran (real token) or skipped - Any issues with go-netbox v4 tag or device list filter API - Files created/modified