Files

89 KiB
Raw Permalink Blame History

Tasks: Smart DNS Server Resolution Modes

Input: Design documents from /specs/002-server-resolution-modes/ Prerequisites: plan.md, spec.md, research.md, data-model.md, contracts/cli.md, contracts/packages.md, quickstart.md

Tests: Tests follow TDD (Red-Green-Refactor) per Constitution Principle VI and copilot-instructions.md. Test tasks are included for all new code.

Organization: Tasks are grouped by user story to enable independent implementation and testing of each story.

Self-Containment: Tasks will be executed by a DIFFERENT model than the one generating them. Every non-trivial task includes an indented Context block with existing code references, target file state, expected implementation pattern, key decisions/gotchas, and acceptance signals.

Toolchain: Always use go1.20 instead of go for all Go toolchain operations (see copilot-instructions.md).

Format: [ID] [P?] [Story] Description

  • [P]: Can run in parallel (different files, no dependencies)
  • [Story]: Which user story this task belongs to (e.g., US1, US2, US3)
  • Include exact file paths in descriptions

Phase 1: Setup

Purpose: Create new files and foundational types needed before any user story implementation.

Phase Context:

  • Files modified in this phase: resolver/parse.go (new — flag parsing and label extraction), resolver/parse_test.go (new — tests for pure functions), platform/network.go (new — NetworkInfo type, NetworkDiscoverer interface, FakeNetworkDiscoverer), platform/network_test.go (new — tests for fake discoverer and NetworkInfo)
  • Key types/interfaces used: None yet — this phase creates foundational types
  • Codebase conventions to follow: Go 1.20 only. Table-driven tests preferred. Error wrapping with fmt.Errorf("context: %w", err). Package per directory, build tags for platform code. Test files use _test suffix in same package or _test package. See resolver/resolver_test.go for fake DNS test patterns and platform/platform_test.go for platform test patterns.

Parsing Utilities

  • T001 [P] Create ParseServerFlag() and ExtractLabelLevels() tests in resolver/parse_test.go

    Context:

    • Target file state: resolver/parse_test.go does not exist yet. The resolver/ directory already contains resolver.go and resolver_test.go. The test file should use package resolver_test (external test package) matching the convention in resolver/resolver_test.go.
    • Expected implementation pattern: Use table-driven tests following the existing project convention. Write tests for:
      • ParseServerFlag("")ServerMode{Mode: "default"}, no error
      • ParseServerFlag("local")ServerMode{Mode: "local"}, no error
      • ParseServerFlag("LOCAL")ServerMode{Mode: "local"}, no error (case-insensitive)
      • ParseServerFlag("gateway")ServerMode{Mode: "gateway"}, no error
      • ParseServerFlag("GATEWAY")ServerMode{Mode: "gateway"}, no error
      • ParseServerFlag("10.0.0.53")ServerMode{Mode: "explicit", ExplicitAddr: "10.0.0.53"}, no error
      • ParseServerFlag("10.0.0.53:5353")ServerMode{Mode: "explicit", ExplicitAddr: "10.0.0.53:5353"}, no error
      • ParseServerFlag("10.0.0.53:0") → error containing "port must be between 1 and 65535"
      • ParseServerFlag("10.0.0.53:99999") → error containing "port"
      • ParseServerFlag("notanip") → error
      • ExtractLabelLevels("host1.sub.domain.example.com")["host1.sub.domain.example.com", "sub.domain.example.com", "domain.example.com", "example.com"]
      • ExtractLabelLevels("www.example.com")["www.example.com", "example.com"]
      • ExtractLabelLevels("example.com")["example.com"]
      • ExtractLabelLevels("localhost")nil
      • ExtractLabelLevels("example.com.")["example.com"] (trailing dot stripped)
    • Key decisions and gotchas: ParseServerFlag keywords are case-insensitive per CLI contract. Port validation must reject 0 and >65535. ExtractLabelLevels must strip trailing dot before splitting. Single-label hostnames return nil. The types ServerMode is defined in resolver/parse.go (T002).
    • Acceptance signal: Tests compile but FAIL because resolver/parse.go does not exist yet. go1.20 test ./resolver/... -run TestParseServerFlag and go1.20 test ./resolver/... -run TestExtractLabelLevels should show compilation errors.
  • T002 [P] Create ParseServerFlag(), ExtractLabelLevels(), and types in resolver/parse.go

    Context:

    • Target file state: resolver/parse.go does not exist yet. The resolver/ package currently contains resolver.go (which defines the Resolver interface and DNSResolver struct).
    • Expected implementation pattern: Define types and implement pure functions:
      package resolver
      
      import (
          "fmt"
          "net"
          "strconv"
          "strings"
      )
      
      // ServerMode represents the resolution strategy.
      type ServerMode struct {
          Mode         string // "default", "local", "gateway", "explicit"
          ExplicitAddr string // IP or IP:port when Mode is "explicit"
      }
      
      // QueryConfig holds per-invocation DNS query settings.
      type QueryConfig struct {
          Timeout time.Duration // Per-query timeout (default 3s)
          Verbose bool          // Emit diagnostic trace to stderr
      }
      
      // ParseServerFlag parses the -server flag value into a ServerMode.
      func ParseServerFlag(value string) (ServerMode, error) {
          // empty → default
          // "local"/"gateway" (case-insensitive) → keyword modes
          // valid IP → explicit
          // valid IP:port → explicit (validate port 1-65535)
          // anything else → error
      }
      
      // ExtractLabelLevels returns all queryable domain levels from a hostname,
      // from most-specific to least-specific, excluding single-label TLDs.
      func ExtractLabelLevels(hostname string) []string {
          // Strip trailing dot, split on ".", generate suffixes with ≥2 labels
      }
      
    • Key decisions and gotchas:
      • Use strings.ToLower for case-insensitive keyword matching.
      • For IP:port parsing, use net.SplitHostPort which handles the colon splitting. If it fails (no port), try net.ParseIP directly.
      • Port validation: strconv.Atoi then check 1 ≤ port ≤ 65535.
      • For ExtractLabelLevels: strip trailing dot first, then strings.Split(hostname, "."). Generate all suffixes with ≥2 parts. Return nil for single-label hostnames.
      • Must also define BootstrapResolvers, PrivateTLDs, and maxCNAMEDepth constants here (FR-007, FR-024, FR-019):
        var BootstrapResolvers = []string{
            "1.1.1.1", "8.8.8.8", "1.0.0.1", "8.8.4.4", "9.9.9.9", "208.67.222.222",
        }
        var PrivateTLDs = map[string]bool{
            "local": true, "internal": true, "lan": true,
            "home": true, "corp": true, "private": true,
        }
        
        // maxCNAMEDepth is the maximum number of CNAME hops before aborting (FR-019).
        const maxCNAMEDepth = 10
        
    • Acceptance signal: go1.20 test ./resolver/... -run TestParseServerFlag and go1.20 test ./resolver/... -run TestExtractLabelLevels all pass. go1.20 vet ./resolver/... clean.

Platform Network Discovery Interface

  • T003 [P] Create NetworkInfo, NetworkDiscoverer interface, and FakeNetworkDiscoverer in platform/network.go

    Context:

    • Target file state: platform/network.go does not exist yet. The platform/ package currently contains platform.go (package doc only: // Package platform provides the platform-specific hosts file path.) and three platform-specific files (platform_windows.go, platform_linux.go, platform_darwin.go) that each define only GetHostsFilePath(). No build tags on network.go — it is shared across all platforms.
    • Expected implementation pattern:
      package platform
      
      // NetworkInfo holds discovered local network configuration.
      type NetworkInfo struct {
          DNSServers []string // Ordered DNS server IPs from default-route adapter
          Gateway    string   // Default gateway IP (may be empty)
          Interface  string   // Adapter name holding default route (informational)
      }
      
      // NetworkDiscoverer discovers local network configuration.
      type NetworkDiscoverer interface {
          Discover() (NetworkInfo, error)
      }
      
      // FakeNetworkDiscoverer returns predetermined NetworkInfo for testing.
      type FakeNetworkDiscoverer struct {
          Info NetworkInfo
          Err  error
      }
      
      func (f *FakeNetworkDiscoverer) Discover() (NetworkInfo, error) {
          return f.Info, f.Err
      }
      
    • Key decisions and gotchas: No build tag on this file — types are shared. FakeNetworkDiscoverer is exported so other packages (like resolver) can use it in tests. All fields in NetworkInfo may be empty (per FR-009, empty DNSServers is not an error).
    • Acceptance signal: go1.20 build ./platform/... succeeds. go1.20 vet ./platform/... clean.
  • T004 [P] Create tests for FakeNetworkDiscoverer in platform/network_test.go

    Context:

    • Target file state: platform/network_test.go does not exist yet. platform/platform_test.go already exists with tests for GetHostsFilePath() using package platform_test.
    • Expected implementation pattern: Simple tests verifying the fake returns expected values:
      package platform_test
      
      import (
          "errors"
          "testing"
          "dns-helper/platform"
      )
      
      func TestFakeNetworkDiscoverer_ReturnsInfo(t *testing.T) {
          fake := &platform.FakeNetworkDiscoverer{
              Info: platform.NetworkInfo{
                  DNSServers: []string{"10.0.0.1", "10.0.0.2"},
                  Gateway:    "10.0.0.1",
                  Interface:  "eth0",
              },
          }
          info, err := fake.Discover()
          if err != nil { t.Fatalf("unexpected error: %v", err) }
          if len(info.DNSServers) != 2 { t.Errorf("expected 2 DNS servers, got %d", len(info.DNSServers)) }
          // ... verify gateway, interface
      }
      
      func TestFakeNetworkDiscoverer_ReturnsError(t *testing.T) {
          fake := &platform.FakeNetworkDiscoverer{
              Err: errors.New("discovery failed"),
          }
          _, err := fake.Discover()
          if err == nil { t.Fatal("expected error") }
      }
      
    • Acceptance signal: go1.20 test ./platform/... -run TestFakeNetworkDiscoverer passes.

Checkpoint: Foundational types (ServerMode, QueryConfig, NetworkInfo, NetworkDiscoverer, FakeNetworkDiscoverer, ParseServerFlag, ExtractLabelLevels, BootstrapResolvers, PrivateTLDs, maxCNAMEDepth) are defined and tested. All subsequent phases depend on these.


Platform Constructor (Stub)

  • T004b [P] Create stub NewNetworkDiscoverer() constructors in platform/discoverer_<os>.go

    Context:

    • Target file state: Three new files needed, one per platform, each with a build tag:
      • platform/discoverer_windows.go (//go:build windows)
      • platform/discoverer_linux.go (//go:build linux)
      • platform/discoverer_darwin.go (//go:build darwin)
    • Expected implementation pattern: Each file returns a stub discoverer that returns empty NetworkInfo. This enables main.go (T019) to compile in Phase 3 before the real platform implementations arrive in Phase 4.
      //go:build windows  // (or linux, darwin)
      
      package platform
      
      // stubNetworkDiscoverer is a temporary placeholder until the real
      // platform-specific discoverer is implemented in Phase 4.
      type stubNetworkDiscoverer struct{}
      
      func (s *stubNetworkDiscoverer) Discover() (NetworkInfo, error) {
          return NetworkInfo{}, nil
      }
      
      // NewNetworkDiscoverer returns the platform-specific NetworkDiscoverer.
      // This stub returns empty NetworkInfo; replaced by real implementation in T020-T022.
      func NewNetworkDiscoverer() NetworkDiscoverer {
          return &stubNetworkDiscoverer{}
      }
      
    • Key decisions and gotchas: The stub returns empty NetworkInfo with no error. Per FR-009, the resolver pool construction handles empty local resolvers gracefully (falls back to bootstrap-only pool). The real implementations in T020-T022 will replace stubNetworkDiscoverer with WindowsNetworkDiscoverer etc., and T023 will update NewNetworkDiscoverer() to return the real type.
    • Acceptance signal: go1.20 build . succeeds on the current platform. main.go can call platform.NewNetworkDiscoverer().

Phase 2: Foundational (Blocking Prerequisites)

Purpose: Shared transport layer and resolver pool construction that ALL user stories depend on. MUST complete before any user story work begins.

⚠️ CRITICAL: No user story work can begin until this phase is complete.

Phase Context:

  • Files modified in this phase: resolver/transport.go (new — shared UDP query function), resolver/transport_test.go (new — tests for UDP transport), resolver/pool.go (new — BuildResolverPool() function), resolver/pool_test.go (new — tests for pool construction)
  • Key types/interfaces used:
    • ServerMode from resolver/parse.go: type ServerMode struct { Mode string; ExplicitAddr string }
    • NetworkInfo from platform/network.go: type NetworkInfo struct { DNSServers []string; Gateway string; Interface string }
    • BootstrapResolvers from resolver/parse.go: var BootstrapResolvers = []string{"1.1.1.1", "8.8.8.8", ...}
  • Codebase conventions to follow: Existing resolver.go uses net.DialTimeout("udp", addr, defaultTimeout) for connections and dnsmessage for DNS wire protocol. See resolver.go lines 74-93 for the UDP dial+write+read pattern. Tests use startFakeDNS/startFakeDNSMulti helpers from resolver_test.go.

Shared Transport Layer

  • T005 [P] Create tests for shared UDP query transport in resolver/transport_test.go

    Context:

    • Target file state: resolver/transport_test.go does not exist. The existing resolver/resolver_test.go contains startFakeDNS, startFakeDNSMulti, queryID, buildAResponse, buildCNAMEResponse, buildNXDOMAINResponse, and mustNewName helpers. These helpers are in package resolver_test (external test package). The new transport test file should also use package resolver_test.
    • Expected implementation pattern: Test UDPQuery function that sends a raw DNS query and returns raw bytes:
      func TestUDPQuery_Success(t *testing.T) {
          // Start a fake DNS server that echoes back a valid A response
          // Call resolver.UDPQuery(ctx, addr, queryBytes, timeout)
          // Assert: response bytes received, no error
      }
      
      func TestUDPQuery_Timeout(t *testing.T) {
          // Start a fake DNS server that never responds
          // Call UDPQuery with a short timeout
          // Assert: error contains timeout information
      }
      
      func TestUDPQuery_InvalidAddress(t *testing.T) {
          // Call UDPQuery with an unreachable address
          // Assert: error returned
      }
      
    • Key decisions and gotchas: The UDPQuery function accepts a context.Context for cancellation. It uses net.Dialer.DialContext() (available since Go 1.7) instead of net.DialTimeout so the context can propagate cancellation. Timeout is set via conn.SetDeadline() derived from context deadline. Must reuse the startFakeDNS pattern from resolver_test.go — but since that's in package resolver_test, the new test file can access those helpers directly if in the same package, or they need to be duplicated/extracted. Since both files are package resolver_test, helpers are shared.
    • Acceptance signal: Tests compile but FAIL because resolver/transport.go does not exist yet.
  • T006 [P] Implement shared UDP query transport in resolver/transport.go

    Context:

    • Existing code references: The existing UDP transport pattern in resolver/resolver.go (lines 74-93):
      addr := server
      if !strings.Contains(server, ":") {
          addr = server + ":53"
      }
      conn, err := net.DialTimeout("udp", addr, defaultTimeout)
      if err != nil {
          return nil, fmt.Errorf("connecting to DNS server %s: %w", server, err)
      }
      defer conn.Close()
      conn.SetDeadline(time.Now().Add(defaultTimeout))
      
      if _, err := conn.Write(packed); err != nil {
          return nil, fmt.Errorf("sending DNS query to %s: %w", server, err)
      }
      
      buf := make([]byte, udpBufSize)
      n, err := conn.Read(buf)
      if err != nil {
          return nil, fmt.Errorf("reading DNS response from %s: %w", server, err)
      }
      
    • Target file state: resolver/transport.go does not exist. This extracts the UDP dial/write/read pattern into a reusable function.
    • Expected implementation pattern:
      package resolver
      
      import (
          "context"
          "fmt"
          "net"
          "strings"
          "time"
      )
      
      // udpQuery sends a raw DNS query to addr over UDP and returns the raw response.
      // addr may be "ip" or "ip:port" — ":53" is appended if no port specified.
      // The timeout parameter sets the per-query deadline. The ctx allows cancellation.
      func udpQuery(ctx context.Context, addr string, query []byte, timeout time.Duration) ([]byte, error) {
          if !strings.Contains(addr, ":") {
              addr = addr + ":53"
          }
          dialer := net.Dialer{Timeout: timeout}
          conn, err := dialer.DialContext(ctx, "udp", addr)
          if err != nil {
              return nil, fmt.Errorf("connecting to %s: %w", addr, err)
          }
          defer conn.Close()
      
          deadline, ok := ctx.Deadline()
          if !ok {
              deadline = time.Now().Add(timeout)
          }
          conn.SetDeadline(deadline)
      
          if _, err := conn.Write(query); err != nil {
              return nil, fmt.Errorf("sending query to %s: %w", addr, err)
          }
      
          buf := make([]byte, udpBufSize) // udpBufSize = 1232 from resolver.go
          n, err := conn.Read(buf)
          if err != nil {
              return nil, fmt.Errorf("reading response from %s: %w", addr, err)
          }
          return buf[:n], nil
      }
      
    • Key decisions and gotchas: Use net.Dialer.DialContext() (Go 1.7+) for context-aware dialing. The function is unexported (udpQuery) since it's internal to the resolver package. Uses udpBufSize constant already defined in resolver.go (1232 bytes). The existing lookupIPWithDepth in resolver.go is NOT refactored to use this yet — that would be a separate task if desired, but keeping existing code unchanged is safer.
    • Acceptance signal: go1.20 test ./resolver/... -run TestUDPQuery passes.

Resolver Pool Construction

  • T007 [P] Create tests for BuildResolverPool() in resolver/pool_test.go

    Context:

    • Target file state: resolver/pool_test.go does not exist.
    • Expected implementation pattern: Table-driven tests covering all modes:
      func TestBuildResolverPool(t *testing.T) {
          tests := []struct {
              name     string
              mode     resolver.ServerMode
              info     platform.NetworkInfo
              expected []string
          }{
              {
                  name: "default mode with local resolvers",
                  mode: resolver.ServerMode{Mode: "default"},
                  info: platform.NetworkInfo{DNSServers: []string{"10.0.0.1", "10.0.0.2"}},
                  expected: []string{"10.0.0.1", "10.0.0.2", "1.1.1.1", "8.8.8.8", "1.0.0.1", "8.8.4.4", "9.9.9.9", "208.67.222.222"},
              },
              {
                  name: "default mode deduplicates",
                  mode: resolver.ServerMode{Mode: "default"},
                  info: platform.NetworkInfo{DNSServers: []string{"8.8.8.8", "10.0.0.1"}},
                  expected: []string{"8.8.8.8", "10.0.0.1", "1.1.1.1", "1.0.0.1", "8.8.4.4", "9.9.9.9", "208.67.222.222"},
              },
              {
                  name: "default mode no local resolvers",
                  mode: resolver.ServerMode{Mode: "default"},
                  info: platform.NetworkInfo{},
                  expected: resolver.BootstrapResolvers,
              },
              {
                  name: "local mode",
                  mode: resolver.ServerMode{Mode: "local"},
                  info: platform.NetworkInfo{DNSServers: []string{"10.0.0.1"}},
                  expected: []string{"10.0.0.1"},
              },
              {
                  name: "gateway mode",
                  mode: resolver.ServerMode{Mode: "gateway"},
                  info: platform.NetworkInfo{Gateway: "192.168.1.1"},
                  expected: []string{"192.168.1.1"},
              },
              {
                  name: "explicit mode",
                  mode: resolver.ServerMode{Mode: "explicit", ExplicitAddr: "10.0.0.53:5353"},
                  info: platform.NetworkInfo{},
                  expected: []string{"10.0.0.53:5353"},
              },
          }
          // ... run table tests
      }
      
    • Key decisions and gotchas: Deduplication must preserve order (local resolvers first). If a local resolver matches a bootstrap resolver, the bootstrap duplicate is removed (the local one stays at its earlier position).
    • Acceptance signal: Tests compile but FAIL because resolver/pool.go does not exist yet.
  • T008 [P] Implement BuildResolverPool() in resolver/pool.go

    Context:

    • Depends on: ServerMode from resolver/parse.go (T002), NetworkInfo from platform/network.go (T003), BootstrapResolvers from resolver/parse.go (T002).
    • Target file state: resolver/pool.go does not exist.
    • Expected implementation pattern:
      package resolver
      
      import "dns-helper/platform"
      
      // BuildResolverPool constructs the resolver list for the given mode.
      func BuildResolverPool(mode ServerMode, info platform.NetworkInfo) []string {
          switch mode.Mode {
          case "default":
              // Concatenate local + bootstrap, deduplicate preserving order
              seen := make(map[string]bool)
              var pool []string
              for _, r := range info.DNSServers {
                  if !seen[r] { seen[r] = true; pool = append(pool, r) }
              }
              for _, r := range BootstrapResolvers {
                  if !seen[r] { seen[r] = true; pool = append(pool, r) }
              }
              return pool
          case "local":
              return info.DNSServers
          case "gateway":
              return []string{info.Gateway}
          case "explicit":
              return []string{mode.ExplicitAddr}
          default:
              return nil
          }
      }
      
    • Key decisions and gotchas: Returns nil for unknown modes (caller handles). Deduplication uses a map[string]bool with order-preserving iteration. No error return — the pool may contain empty strings for gateway mode if gateway wasn't discovered; the caller validates.
    • Acceptance signal: go1.20 test ./resolver/... -run TestBuildResolverPool passes. go1.20 vet ./resolver/... clean.

Checkpoint: Foundation ready — shared transport (udpQuery), pool construction (BuildResolverPool), parsing (ParseServerFlag, ExtractLabelLevels), platform types (NetworkInfo, NetworkDiscoverer), and constants (BootstrapResolvers, PrivateTLDs) all in place. User story implementation can now begin.


Phase 3: User Story 1 — Smart Default Resolution (Priority: P1) 🎯 MVP

Goal: When no -server flag is provided, the tool performs parallel NS fan-out across local + public resolvers for authoritative answers, with parallel A fallback for internal hosts, CNAME re-resolution, and split-horizon conflict detection.

Independent Test: Run dns-helper add -host www.example.com (no -server flag) and verify the tool discovers the authoritative NS for example.com, queries it directly, and adds the resulting IP to the hosts file.

Phase Context:

  • Files modified in this phase: resolver/authority.go (new — NS fan-out, NS selection, authoritative query), resolver/authority_test.go (new — tests), resolver/fallback.go (new — parallel A fallback), resolver/fallback_test.go (new — tests), resolver/splithorizon.go (new — split-horizon cross-check), resolver/splithorizon_test.go (new — tests), resolver/modes.go (new — mode dispatcher), resolver/modes_test.go (new — tests), main.go (updated — new flags, mode dispatch)
  • Key types/interfaces used:
    • ServerMode from resolver/parse.go: type ServerMode struct { Mode string; ExplicitAddr string }
    • QueryConfig from resolver/parse.go: type QueryConfig struct { Timeout time.Duration; Verbose bool }
    • NSResult from resolver/authority.go (defined in this phase): type NSResult struct { LabelLevel string; Resolver string; NSRecords []string; CNAMETarget string; Err error }
    • AuthoritativeNS from resolver/authority.go (defined in this phase): type AuthoritativeNS struct { Zone string; Nameservers []string }
    • SplitHorizonResult from resolver/splithorizon.go (defined in this phase)
    • NetworkDiscoverer from platform/network.go: type NetworkDiscoverer interface { Discover() (NetworkInfo, error) }
    • BuildResolverPool from resolver/pool.go
    • udpQuery from resolver/transport.go
    • Existing startFakeDNS/startFakeDNSMulti test helpers from resolver/resolver_test.go
  • Codebase conventions to follow: Error wrapping with fmt.Errorf("context: %w", err). Use goroutines + buffered channels for parallelism (R-000). Use context.WithTimeout for per-query timeouts. DNS wire protocol via dnsmessage package. Tests use fake UDP DNS servers listening on 127.0.0.1:0.

Stage 1: Parallel NS Fan-out

  • T009 [P] [US1] Create tests for ParallelNSFanOut() and SelectAuthoritativeNS() in resolver/authority_test.go

    Context:

    • Target file state: resolver/authority_test.go does not exist. Test helpers (startFakeDNS, startFakeDNSMulti, queryID, mustNewName, buildAResponse, buildNXDOMAINResponse) are in resolver/resolver_test.go under package resolver_test and are accessible from this file (same test package).
    • Expected implementation pattern: Need a new helper buildNSResponse for NS queries:
      func buildNSResponse(id uint16, name dnsmessage.Name, nsNames []dnsmessage.Name) []byte {
          answers := make([]dnsmessage.Resource, len(nsNames))
          for i, ns := range nsNames {
              answers[i] = dnsmessage.Resource{
                  Header: dnsmessage.ResourceHeader{
                      Name: name, Type: dnsmessage.TypeNS, Class: dnsmessage.ClassINET, TTL: 3600,
                  },
                  Body: &dnsmessage.NSResource{NS: ns},
              }
          }
          msg := dnsmessage.Message{
              Header:    dnsmessage.Header{ID: id, Response: true, RCode: dnsmessage.RCodeSuccess},
              Questions: []dnsmessage.Question{{Name: name, Type: dnsmessage.TypeNS, Class: dnsmessage.ClassINET}},
              Answers:   answers,
          }
          packed, _ := msg.Pack()
          return packed
      }
      
      Test cases:
      • Fan-out with 2 resolvers × 2 levels: One resolver returns NS for example.com, other returns NXDOMAIN. Verify 4 results total.
      • Fan-out timeout: One resolver never responds, verify results still come back from the other.
      • SelectAuthoritativeNS — most specific wins: Results include NS at example.com and sub.example.com, verify sub.example.com selected.
      • SelectAuthoritativeNS — no NS found: All results have nil NSRecords, verify nil returned.
      • SelectAuthoritativeNS — deduplication: Two resolvers return overlapping NS records for same zone, verify merged and deduplicated.
    • Key decisions and gotchas: The fake DNS servers need to handle NS queries (check for dnsmessage.TypeNS in the question). Use startFakeDNSMulti since the fan-out sends multiple queries. NS query response parsing uses parser.NSResource() which returns NSResource{NS: dnsmessage.Name}.
    • Acceptance signal: Tests compile but FAIL because resolver/authority.go does not exist yet.
  • T010 [US1] Implement NSResult, AuthoritativeNS, ParallelNSFanOut(), and SelectAuthoritativeNS() in resolver/authority.go

    Context:

    • Depends on: udpQuery from resolver/transport.go (T006), ExtractLabelLevels from resolver/parse.go (T002).
    • Target file state: resolver/authority.go does not exist.
    • Expected implementation pattern:
      package resolver
      
      import (
          "context"
          "fmt"
          "sort"
          "strings"
          "time"
          "golang.org/x/net/dns/dnsmessage"
      )
      
      type NSResult struct {
          LabelLevel  string
          Resolver    string
          NSRecords   []string
          CNAMETarget string
          Err         error
      }
      
      type AuthoritativeNS struct {
          Zone        string
          Nameservers []string
      }
      
      // ParallelNSFanOut queries NS records for all label levels across all resolvers.
      func ParallelNSFanOut(ctx context.Context, resolvers []string, labelLevels []string, timeout time.Duration) []NSResult {
          total := len(resolvers) * len(labelLevels)
          ch := make(chan NSResult, total)
          for _, r := range resolvers {
              for _, level := range labelLevels {
                  go func(resolver, label string) {
                      result := queryNS(ctx, resolver, label, timeout)
                      ch <- result
                  }(r, level)
              }
          }
          results := make([]NSResult, 0, total)
          for i := 0; i < total; i++ {
              results = append(results, <-ch)
          }
          return results
      }
      
      // queryNS sends a single NS query and returns an NSResult.
      func queryNS(ctx context.Context, resolver, label string, timeout time.Duration) NSResult {
          // Build NS query using dnsmessage, send via udpQuery, parse NS records
          // Return NSResult with LabelLevel, Resolver, NSRecords, Err
      }
      
      // SelectAuthoritativeNS picks the most-specific NS delegation from fan-out results.
      func SelectAuthoritativeNS(results []NSResult) *AuthoritativeNS {
          // Group by LabelLevel, merge NS records, select longest (most labels)
          // Return nil if no NS records found at any level
      }
      
    • Key decisions and gotchas:
      • Use buffered channel sized to N×M per R-000 research. Collector reads exactly N×M results — no goroutine leak.
      • Each goroutine gets a child context.WithTimeout(ctx, timeout) for its individual query deadline.
      • NS query construction: identical to A query in resolver.go but with dnsmessage.TypeNS instead of dnsmessage.TypeA. Parse with parser.NSResource() which returns NSResource{NS: dnsmessage.Name}.
      • NXDOMAIN (RCodeNameError): produce NSResult{Err: nil, NSRecords: nil} — not an error.
      • SelectAuthoritativeNS selection: count labels via strings.Count(level, ".") + 1, pick highest. Merge NS records across all resolvers for the winning level. Deduplicate NS hostnames.
      • Sort NS hostnames alphabetically for deterministic output before returning.
    • Acceptance signal: go1.20 test ./resolver/... -run TestParallelNSFanOut and go1.20 test ./resolver/... -run TestSelectAuthoritativeNS pass.

Stage 2: Authoritative Query

  • T011 [P] [US1] Create tests for QueryAuthoritative() in resolver/authority_test.go

    Context:

    • Target file state: resolver/authority_test.go already exists from T009 with NS fan-out tests.
    • Expected implementation pattern: Add test functions to the existing file:
      • A record success: Fake "NS server" returns A record with RD=0. Verify IPs returned, cnameTarget empty, nsHostnameUsed populated.
      • CNAME response: Fake NS returns CNAME. Verify empty IPs, cnameTarget populated, nsHostnameUsed populated.
      • First NS unreachable, second works: Two NS hostnames, first goes to a closed port, second responds. Verify IPs returned from second and nsHostnameUsed is the second NS hostname.
      • All NS unreachable: All NS go to closed ports. Verify error returned.
      • NS hostname resolution: Fake resolvers that respond to A queries for NS hostnames. Verify the function resolves the NS hostname to an IP before querying it.
    • Key decisions and gotchas: QueryAuthoritative needs to resolve the NS hostname to an IP first (using the resolver pool). Then it sends an A query with RecursionDesired: false to that IP. The fake DNS server for the authoritative NS should check that RecursionDesired is false in the incoming query. The function now returns a 4th value nsHostnameUsed string which is the NS hostname that successfully answered — needed for the split-horizon conflict error message (FR-035).
    • Acceptance signal: Tests compile but initially FAIL, then pass after T012 implementation.
  • T012 [US1] Implement QueryAuthoritative() in resolver/authority.go

    Context:

    • Depends on: udpQuery from resolver/transport.go (T006), ParallelNSFanOut for NS hostname resolution.
    • Target file state: resolver/authority.go already exists from T010 with ParallelNSFanOut, SelectAuthoritativeNS, NSResult, AuthoritativeNS types.
    • Expected implementation pattern:
      // QueryAuthoritative sends an A query to an authoritative NS with recursion disabled.
      // Tries each NS in order if one is unreachable.
      // resolvers is the pool used to resolve NS hostnames to IPs.
      // Returns the IPs, any CNAME target, the NS hostname that answered, and any error.
      func QueryAuthoritative(ctx context.Context, ns *AuthoritativeNS, hostname string, resolvers []string, timeout time.Duration) (ips []string, cnameTarget string, nsHostnameUsed string, err error) {
          for _, nsHostname := range ns.Nameservers {
              // Step 1: Resolve NS hostname to IP using a simple A query via the pool
              nsIP, err := resolveNSHostname(ctx, nsHostname, resolvers, timeout)
              if err != nil {
                  continue // try next NS
              }
              // Step 2: Build A query with RecursionDesired=false
              // Step 3: Send via udpQuery to nsIP
              // Step 4: Parse response — collect A records and CNAME targets
              // If A records found: return ips, "", nsHostname, nil
              // If CNAME found: return nil, cnameTarget, nsHostname, nil  (caller restarts Stage 1)
              // If unreachable: continue to next NS
          }
          return nil, "", "", fmt.Errorf("all authoritative nameservers for %s unreachable", ns.Zone)
      }
      
    • Key decisions and gotchas:
      • Set RecursionDesired: false in the dnsmessage.Header when querying the authoritative NS.
      • The response parser follows the same pattern as resolver.go lines 98-165: skip questions, iterate answers for TypeA and TypeCNAME.
      • If the authoritative NS returns RCodeRefused, try next NS (per R-000).
      • If empty answer with RCodeSuccess, treat as possible referral — try next NS.
      • resolveNSHostname helper: send A query to each resolver in the pool, return first IP. This is a simple sequential try, not a full parallel fan-out.
    • Acceptance signal: go1.20 test ./resolver/... -run TestQueryAuthoritative passes.

Stage 3: Parallel A Fallback

  • T013 [P] [US1] Create tests for ParallelAFallback() in resolver/fallback_test.go

    Context:

    • Target file state: resolver/fallback_test.go does not exist.
    • Expected implementation pattern: Uses the same fake DNS helpers from resolver_test.go:
      func TestParallelAFallback_FirstSuccessWins(t *testing.T) {
          // Start two fake servers: one returns A record, one returns NXDOMAIN
          // Call ParallelAFallback with both
          // Assert: IPs from the successful one returned
      }
      
      func TestParallelAFallback_AllNXDOMAIN(t *testing.T) {
          // All fake servers return NXDOMAIN
          // Assert: error returned
      }
      
      func TestParallelAFallback_OneTimeout(t *testing.T) {
          // One server times out, one returns A record
          // Assert: IPs from the responsive one returned
      }
      
      func TestParallelAFallback_AllFail(t *testing.T) {
          // All servers unreachable
          // Assert: error returned
      }
      
    • Key decisions and gotchas: The fallback returns the first successful result. NXDOMAIN and failures are ignored. "First" means whichever goroutine writes to the channel first — non-deterministic but correct.
    • Acceptance signal: Tests compile but FAIL because resolver/fallback.go does not exist.
  • T014 [US1] Implement ParallelAFallback() in resolver/fallback.go

    Context:

    • Depends on: udpQuery from resolver/transport.go (T006).
    • Target file state: resolver/fallback.go does not exist.
    • Expected implementation pattern:
      package resolver
      
      import (
          "context"
          "fmt"
          "time"
          "golang.org/x/net/dns/dnsmessage"
      )
      
      // ParallelAFallback sends A queries for hostname to all resolvers simultaneously.
      // Returns the first successful A record result.
      func ParallelAFallback(ctx context.Context, resolvers []string, hostname string, timeout time.Duration) ([]string, error) {
          type aResult struct {
              IPs []string
              Err error
          }
          ch := make(chan aResult, len(resolvers))
          for _, r := range resolvers {
              go func(resolver string) {
                  ips, err := queryA(ctx, resolver, hostname, timeout)
                  ch <- aResult{IPs: ips, Err: err}
              }(r)
          }
          var lastErr error
          for i := 0; i < len(resolvers); i++ {
              result := <-ch
              if result.Err == nil && len(result.IPs) > 0 {
                  return result.IPs, nil
              }
              if result.Err != nil {
                  lastErr = result.Err
              }
          }
          if lastErr != nil {
              return nil, fmt.Errorf("all resolvers failed for %s: %w", hostname, lastErr)
          }
          return nil, fmt.Errorf("no resolver returned A records for %s", hostname)
      }
      
      // queryA sends a single A query and returns IPs.
      func queryA(ctx context.Context, resolver, hostname string, timeout time.Duration) ([]string, error) {
          // Build A query with dnsmessage, send via udpQuery, parse A records
          // Similar to queryNS but for TypeA
      }
      
    • Key decisions and gotchas: Uses buffered channel sized to len(resolvers). Reads all results (doesn't short-circuit) to avoid goroutine leaks — channels are buffered so goroutines can write even after we return. Returns the first successful result encountered during iteration. NXDOMAIN: return nil, fmt.Errorf(...) (an error, not a success).
    • Acceptance signal: go1.20 test ./resolver/... -run TestParallelAFallback passes.

Stage 2.5: Split-Horizon Conflict Detection

  • T015 [P] [US1] Create tests for CheckSplitHorizon() in resolver/splithorizon_test.go

    Context:

    • Target file state: resolver/splithorizon_test.go does not exist.
    • Expected implementation pattern:
      func TestCheckSplitHorizon_NoConflict_SameIPs(t *testing.T) {
          // Local resolver returns same IP as authoritative
          // Assert: HasConflict is false
      }
      
      func TestCheckSplitHorizon_Conflict_DifferentIPs(t *testing.T) {
          // Local resolver returns 10.0.5.100, authoritative was 203.0.113.50
          // Assert: HasConflict is true, both IP sets populated
      }
      
      func TestCheckSplitHorizon_NoConflict_LocalNXDOMAIN(t *testing.T) {
          // Local resolver returns NXDOMAIN
          // Assert: HasConflict is false, LocalIPs is nil
      }
      
      func TestCheckSplitHorizon_NoConflict_LocalTimeout(t *testing.T) {
          // Local resolver unreachable
          // Assert: HasConflict is false, LocalIPs is nil
      }
      
      func TestCheckSplitHorizon_NoConflict_OrderIndependent(t *testing.T) {
          // Local returns ["5.6.7.8", "1.2.3.4"], authoritative was ["1.2.3.4", "5.6.7.8"]
          // Assert: HasConflict is false (order doesn't matter, sets are equal)
      }
      
    • Key decisions and gotchas: IP set comparison must be order-independent — sort both sets and compare. nil local IPs (NXDOMAIN or failure) means no conflict. The SplitHorizonResult type is defined in resolver/splithorizon.go.
    • Acceptance signal: Tests compile but FAIL because resolver/splithorizon.go does not exist.
  • T016 [US1] Implement CheckSplitHorizon() and SplitHorizonResult in resolver/splithorizon.go

    Context:

    • Depends on: queryA from resolver/fallback.go (T014) or udpQuery from resolver/transport.go (T006).
    • Target file state: resolver/splithorizon.go does not exist.
    • Expected implementation pattern:
      package resolver
      
      import (
          "context"
          "sort"
          "time"
      )
      
      type SplitHorizonResult struct {
          AuthoritativeIPs    []string
          AuthoritativeSource string
          LocalIPs            []string
          LocalSource         string
          HasConflict         bool
      }
      
      // CheckSplitHorizon queries local resolvers and compares against authoritative IPs.
      func CheckSplitHorizon(ctx context.Context, localResolvers []string, hostname string, authoritativeIPs []string, authoritativeSource string, timeout time.Duration) SplitHorizonResult {
          result := SplitHorizonResult{
              AuthoritativeIPs:    authoritativeIPs,
              AuthoritativeSource: authoritativeSource,
          }
          // Query each local resolver in order, take first success
          for _, lr := range localResolvers {
              ips, err := queryA(ctx, lr, hostname, timeout)
              if err != nil {
                  continue // timeout, NXDOMAIN, etc.
              }
              result.LocalIPs = ips
              result.LocalSource = lr
              // Compare IP sets
              result.HasConflict = !ipSetsEqual(authoritativeIPs, ips)
              return result
          }
          // All local resolvers failed or returned NXDOMAIN — no conflict
          return result
      }
      
      // ipSetsEqual compares two IP slices as sorted, deduplicated sets.
      func ipSetsEqual(a, b []string) bool {
          sa := sortedDedup(a)
          sb := sortedDedup(b)
          if len(sa) != len(sb) { return false }
          for i := range sa {
              if sa[i] != sb[i] { return false }
          }
          return true
      }
      
      func sortedDedup(ips []string) []string {
          m := make(map[string]bool)
          for _, ip := range ips { m[ip] = true }
          result := make([]string, 0, len(m))
          for ip := range m { result = append(result, ip) }
          sort.Strings(result)
          return result
      }
      
    • Key decisions and gotchas:
      • The queryA function from fallback.go returns an error for NXDOMAIN. So NXDOMAIN from a local resolver means err != nil, which correctly falls through to "no conflict".
      • IP set comparison is order-independent: sort and deduplicate both sets.
      • This function queries local resolvers sequentially (not in parallel) since we only need one successful answer. This keeps it simple and avoids the complexity of racing multiple local resolvers for the cross-check.
      • FR-037: This function is only called in default mode — the caller (Resolve() in modes.go) is responsible for that gating.
      • FR-038: This function is only called when local resolvers exist — the caller checks.
      • Note: The ipSetsEqual and sortedDedup helper functions are used both here and in resolveAuthoritative (modes.go). They should be placed in this file (splithorizon.go) and called from both places.
      • Note on concurrency: In the refactored design (T018), the local resolver query is fired concurrently with Stage 2 via queryLocalResolvers() in modes.go. CheckSplitHorizon remains available for standalone testing, but the primary production path uses the concurrent pattern in resolveAuthoritative.
    • Acceptance signal: go1.20 test ./resolver/... -run TestCheckSplitHorizon passes.

Mode Dispatcher

  • T017 [P] [US1] Create tests for Resolve() mode dispatcher in resolver/modes_test.go

    Context:

    • Target file state: resolver/modes_test.go does not exist.
    • Expected implementation pattern: Tests for the default mode path through Resolve():
      func TestResolve_DefaultMode_AuthoritativeSuccess(t *testing.T) {
          // Setup: fake DNS servers that respond to NS and A queries
          // NS server: returns NS records for "example.com"
          // Authoritative server: returns A record for "www.example.com"
          // FakeNetworkDiscoverer with local resolvers
          // Call Resolve("www.example.com", ServerMode{Mode:"default"}, config, discoverer)
          // Assert: returns IPs from the authoritative server
      }
      
      func TestResolve_DefaultMode_FallbackToParallelA(t *testing.T) {
          // NS fan-out returns no NS records at any level
          // One resolver returns A record for the hostname
          // Assert: returns IPs from fallback
      }
      
      func TestResolve_DefaultMode_CNAMERestart(t *testing.T) {
          // Stage 2 returns a CNAME target
          // Second pass: NS found, authoritative returns A record
          // Assert: returns IPs from the second pass
      }
      
      func TestResolve_DefaultMode_CNAMEDepthExceeded(t *testing.T) {
          // Setup: 11 fake NS/authoritative servers, each returns a CNAME
          // pointing to the next one. Depth exceeds maxCNAMEDepth (10).
          // Key: verify the depth counter propagates through
          // resolveDefault → resolveAuthoritative → resolveDefault(depth+1)
          // Assert: error containing "CNAME chain depth exceeded"
      }
      
      func TestResolve_DefaultMode_SplitHorizonConflict(t *testing.T) {
          // Authoritative returns 203.0.113.50
          // Local resolver returns 10.0.5.100
          // Assert: error containing "conflicting DNS answers"
      }
      
      func TestResolve_DefaultMode_PrivateTLDError(t *testing.T) {
          // Hostname is "myservice.client.local"
          // All queries fail
          // Assert: error contains "private TLD" or "internal" hint
      }
      
      func TestResolve_DefaultMode_NoLocalResolvers(t *testing.T) {
          // FakeNetworkDiscoverer returns empty DNSServers
          // Bootstrap resolvers handle resolution
          // Assert: succeeds, no split-horizon check (no local resolvers)
      }
      
    • Key decisions and gotchas: These are integration-level tests for the full default mode pipeline. They require setting up multiple fake DNS servers with different roles (NS responder, authoritative server, local resolver). Keep the test setup functions focused and well-named. The FakeNetworkDiscoverer from platform/network.go is used to inject deterministic local resolver/gateway info.
    • Acceptance signal: Tests compile but FAIL because resolver/modes.go does not exist.
  • T018 [US1] Implement Resolve() mode dispatcher in resolver/modes.go

    Context:

    • Depends on: All prior resolver functions: ParseServerFlag, BuildResolverPool, ExtractLabelLevels, ParallelNSFanOut, SelectAuthoritativeNS, QueryAuthoritative, ParallelAFallback, CheckSplitHorizon.
    • Target file state: resolver/modes.go does not exist.
    • Expected implementation pattern:
      package resolver
      
      import (
          "context"
          "fmt"
          "io"
          "strings"
          "time"
          "dns-helper/platform"
      )
      
      // Resolve performs DNS resolution using the specified mode and configuration.
      func Resolve(hostname string, mode ServerMode, config QueryConfig, discoverer platform.NetworkDiscoverer) ([]string, error) {
          switch mode.Mode {
          case "default":
              return resolveDefaultEntry(hostname, config, discoverer)
          case "local":
              return resolveLocal(hostname, config, discoverer)
          case "gateway":
              return resolveGateway(hostname, config, discoverer)
          case "explicit":
              return resolveExplicit(hostname, mode, config)
          default:
              return nil, fmt.Errorf("unknown server mode %q", mode.Mode)
          }
      }
      
      // resolveDefaultEntry is the public entry point for default mode.
      // It discovers network info once and delegates to resolveDefault with depth=0.
      func resolveDefaultEntry(hostname string, config QueryConfig, discoverer platform.NetworkDiscoverer) ([]string, error) {
          info, _ := discoverer.Discover() // ignore error per FR-009
          pool := BuildResolverPool(ServerMode{Mode: "default"}, info)
          return resolveDefault(hostname, pool, info.DNSServers, config, 0)
      }
      
      // resolveDefault performs smart default resolution.
      // pool and localResolvers are passed explicitly so CNAME restarts reuse them
      // without needing a NetworkDiscoverer (avoids using FakeNetworkDiscoverer in production).
      // depth tracks CNAME hops to enforce maxCNAMEDepth (FR-019).
      func resolveDefault(hostname string, pool []string, localResolvers []string, config QueryConfig, depth int) ([]string, error) {
          if depth > maxCNAMEDepth {
              return nil, fmt.Errorf("CNAME chain depth exceeded for %s (max %d hops): probable CNAME loop or misconfigured zone", hostname, maxCNAMEDepth)
          }
      
          // 1. Extract label levels
          levels := ExtractLabelLevels(hostname)
      
          // 2. Stage 1: Parallel NS fan-out
          ctx := context.Background()
          nsResults := ParallelNSFanOut(ctx, pool, levels, config.Timeout)
          authority := SelectAuthoritativeNS(nsResults)
      
          if authority != nil {
              // 3. Stage 2: Authoritative query
              return resolveAuthoritative(ctx, authority, hostname, pool, localResolvers, config, depth)
          }
      
          // 4. Stage 3: Parallel A fallback
          ips, err := ParallelAFallback(ctx, pool, hostname, config.Timeout)
          if err != nil {
              return nil, addPrivateTLDHint(hostname, err)
          }
          return ips, nil
      }
      
      // resolveAuthoritative handles Stage 2 + Stage 2.5 + CNAME restarts.
      func resolveAuthoritative(ctx context.Context, authority *AuthoritativeNS, hostname string, pool []string, localResolvers []string, config QueryConfig, depth int) ([]string, error) {
          // Fire Stage 2.5 cross-check concurrently with Stage 2 (FR-033, SC-011).
          // The cross-check goroutine starts now and runs in parallel with the
          // authoritative query so it adds no wall-clock latency to the happy path.
          type shOut struct{ result SplitHorizonResult }
          var shCh chan shOut
          if len(localResolvers) > 0 {
              shCh = make(chan shOut, 1)
              go func() {
                  // Stage 2.5 fires against local resolvers for hostname
                  // We don't know the authoritative IPs yet; we'll compare after Stage 2.
                  localIPs, localSource := queryLocalResolvers(ctx, localResolvers, hostname, config.Timeout)
                  shCh <- shOut{result: SplitHorizonResult{LocalIPs: localIPs, LocalSource: localSource}}
              }()
          }
      
          // Stage 2: Query authoritative NS
          ips, cnameTarget, nsHostnameUsed, err := QueryAuthoritative(ctx, authority, hostname, pool, config.Timeout)
          if err != nil {
              return nil, addPrivateTLDHint(hostname, err)
          }
      
          if cnameTarget != "" {
              // CNAME: restart full resolution for the target domain.
              // Increment depth to enforce maxCNAMEDepth (FR-019).
              return resolveDefault(cnameTarget, pool, localResolvers, config, depth+1)
          }
      
          // Stage 2.5: Collect cross-check result and compare
          if shCh != nil {
              shOut := <-shCh
              shResult := shOut.result
              shResult.AuthoritativeIPs = ips
              shResult.AuthoritativeSource = nsHostnameUsed
              shResult.HasConflict = shResult.LocalIPs != nil && !ipSetsEqual(ips, shResult.LocalIPs)
              if shResult.HasConflict {
                  return nil, fmt.Errorf("conflicting DNS answers for %s\n  Authoritative (%s): %s\n  Local resolver (%s): %s\n  The hostname resolves to different IPs depending on the DNS source.\n  Use -server local to trust your internal DNS, or -server <ip> to choose explicitly.",
                      hostname, shResult.AuthoritativeSource, strings.Join(shResult.AuthoritativeIPs, ", "),
                      shResult.LocalSource, strings.Join(shResult.LocalIPs, ", "))
              }
          }
      
          return ips, nil
      }
      
      // queryLocalResolvers queries each local resolver sequentially for an A record.
      // Returns the first successful IPs and the resolver that provided them.
      // Returns nil, "" if all fail or return NXDOMAIN.
      func queryLocalResolvers(ctx context.Context, localResolvers []string, hostname string, timeout time.Duration) ([]string, string) {
          for _, lr := range localResolvers {
              ips, err := queryA(ctx, lr, hostname, timeout)
              if err == nil && len(ips) > 0 {
                  return ips, lr
              }
          }
          return nil, ""
      }
      
      func resolveLocal(hostname string, config QueryConfig, discoverer platform.NetworkDiscoverer) ([]string, error) {
          info, err := discoverer.Discover()
          if err != nil || len(info.DNSServers) == 0 {
              return nil, fmt.Errorf("failed to resolve %s using local resolvers: no local DNS servers found", hostname)
          }
          // Query each in priority order
          ctx := context.Background()
          var lastErr error
          for _, server := range info.DNSServers {
              ips, err := queryA(ctx, server, hostname, config.Timeout)
              if err == nil && len(ips) > 0 {
                  return ips, nil
              }
              lastErr = err
          }
          return nil, fmt.Errorf("failed to resolve %s using local resolvers: all local DNS servers are unreachable\n  Local resolvers tried: %s", hostname, strings.Join(info.DNSServers, ", "))
      }
      
      func resolveGateway(hostname string, config QueryConfig, discoverer platform.NetworkDiscoverer) ([]string, error) {
          info, err := discoverer.Discover()
          if err != nil || info.Gateway == "" {
              return nil, fmt.Errorf("failed to resolve %s using gateway: no default gateway found", hostname)
          }
          ctx := context.Background()
          ips, err := queryA(ctx, info.Gateway, hostname, config.Timeout)
          if err != nil {
              return nil, fmt.Errorf("failed to resolve %s using gateway: gateway %s did not respond to DNS query", hostname, info.Gateway)
          }
          return ips, nil
      }
      
      func resolveExplicit(hostname string, mode ServerMode, config QueryConfig) ([]string, error) {
          // Use per-query timeout from config (FR-029) via the shared transport.
          // We use queryA directly instead of DNSResolver.LookupIP so the user's
          // -timeout flag is respected in explicit mode.
          ctx := context.Background()
          return queryA(ctx, mode.ExplicitAddr, hostname, config.Timeout)
      }
      
      // addPrivateTLDHint wraps a resolution error with a hint if the hostname uses a private TLD.
      func addPrivateTLDHint(hostname string, origErr error) error {
          parts := strings.Split(strings.TrimSuffix(hostname, "."), ".")
          if len(parts) > 0 {
              tld := strings.ToLower(parts[len(parts)-1])
              if PrivateTLDs[tld] {
                  return fmt.Errorf("%w\n  Hint: the hostname uses a private TLD (.%s). Try specifying an internal DNS server:\n        dns-helper add -host %s -server <internal-dns-ip>", origErr, tld, hostname)
              }
          }
          return origErr
      }
      
    • Key decisions and gotchas:
      • CNAME depth tracking (FR-019): resolveDefault accepts a depth int parameter. On CNAME restart, it calls resolveDefault(cnameTarget, pool, localResolvers, config, depth+1). The depth check at the top of resolveDefault rejects anything over maxCNAMEDepth (10). This ensures the limit works correctly even across multiple restarts.
      • No FakeNetworkDiscoverer in production: resolveDefaultEntry discovers network info once and passes pool and localResolvers as plain slices to resolveDefault. CNAME restarts reuse these slices directly — no need to wrap them in a fake discoverer.
      • Split-horizon concurrent cross-check (FR-033, SC-011): resolveAuthoritative fires the local cross-check as a goroutine at the same time Stage 2 starts. The goroutine queries local resolvers and sends results to a buffered channel. After Stage 2 completes, the main goroutine reads the channel and compares IPs. This adds zero wall-clock latency to the happy path. Only runs when len(localResolvers) > 0 (FR-038) and only in default mode (FR-037).
      • QueryAuthoritative return value change: QueryAuthoritative now returns 4 values: (ips []string, cnameTarget string, nsHostnameUsed string, err error). The nsHostnameUsed is the actual NS hostname that answered (e.g., "ns1.example.com"), used as AuthoritativeSource in the split-horizon conflict error message (FR-035).
      • Explicit mode timeout (FR-029): resolveExplicit uses queryA with config.Timeout instead of New().LookupIP() to respect the user's -timeout flag.
      • Private TLD hint: only added when resolution fails entirely (FR-025).
      • resolveLocal and resolveGateway do NOT fall back to public resolvers (FR-026, FR-027).
      • Verbose output (config.Verbose): emit to os.Stderr using fmt.Fprintf. Add verbose logging at key stages if Verbose is true. This can be deferred to the T027 polish task.
    • Acceptance signal: go1.20 test ./resolver/... -run TestResolve passes for all default mode test cases.

CLI Integration

  • T019 [US1] Update main.go to support optional -server, new -timeout/-verbose flags, and mode dispatch

    Context:

    • Existing code references: Current runAdd function in main.go (lines 42-70):
      func runAdd(args []string) int {
          fs := flag.NewFlagSet("add", flag.ContinueOnError)
          hostFlag := fs.String("host", "", "Comma separated list of hostnames to add")
          serverFlag := fs.String("server", "", "DNS resolver to use")
          if err := fs.Parse(args); err != nil {
              fmt.Fprintf(os.Stderr, "Error: %v\n", err)
              return 1
          }
          if *hostFlag == "" {
              fmt.Fprintf(os.Stderr, "Error: -host flag is required for the add command\n")
              return 1
          }
          if *serverFlag == "" {
              fmt.Fprintf(os.Stderr, "Error: -server flag is required for the add command\n")
              return 1
          }
          hostnames := strings.Split(*hostFlag, ",")
          server := *serverFlag
          res := resolver.New()
          // ... resolves each hostname via res.LookupIP(h, server)
      
    • Target file state: main.go currently has runAdd that requires -server and uses resolver.New().LookupIP() directly. It needs to:
      1. Remove the -server required check (make it optional).
      2. Add -timeout (int, default 3) and -verbose (bool, default false) flags.
      3. Parse -server with resolver.ParseServerFlag().
      4. Validate -timeout (must be positive).
      5. Dispatch to resolver.Resolve() instead of resolver.New().LookupIP().
      6. Create a platform NetworkDiscoverer — need a concrete discoverer. For now, import the platform-specific one (T020-T022 provide it).
      7. Update printUsage() with new examples.
    • Expected changes to runAdd:
      func runAdd(args []string) int {
          fs := flag.NewFlagSet("add", flag.ContinueOnError)
          hostFlag := fs.String("host", "", "Comma separated list of hostnames to add")
          serverFlag := fs.String("server", "", "Resolution mode: omit for smart default, local, gateway, or IP/IP:port")
          timeoutFlag := fs.Int("timeout", 3, "Per-query DNS timeout in seconds")
          verboseFlag := fs.Bool("verbose", false, "Emit per-stage resolution trace to stderr")
          // ... parse
          if *hostFlag == "" { /* error */ }
          // Remove the server required check!
          if *timeoutFlag <= 0 {
              fmt.Fprintf(os.Stderr, "Error: -timeout must be a positive integer\n")
              return 1
          }
          mode, err := resolver.ParseServerFlag(*serverFlag)
          if err != nil {
              fmt.Fprintf(os.Stderr, "Error: %v\n", err)
              return 1
          }
          config := resolver.QueryConfig{
              Timeout: time.Duration(*timeoutFlag) * time.Second,
              Verbose: *verboseFlag,
          }
          discoverer := platform.NewNetworkDiscoverer() // platform-specific constructor
          // For each hostname:
          ips, err := resolver.Resolve(h, mode, config, discoverer)
      
    • Key decisions and gotchas:
      • Must add "time" to imports.
      • Need platform.NewNetworkDiscoverer() — a constructor that returns the platform-specific implementation. This will be created in T023. To avoid a compilation gap, T023 has been moved to after the platform types (Phase 1 checkpoint) and provides a minimal stub NewNetworkDiscoverer() that returns &stubNetworkDiscoverer{} (returns empty NetworkInfo, no error). The real implementations (T020-T022) replace the stub bodies in Phase 4. This way main.go compiles cleanly in Phase 3.
      • Update printUsage() to match the CLI contract from contracts/cli.md.
      • The -server flag is now optional — remove the empty check that returns error.
      • Existing import of "dns-helper/resolver" and "dns-helper/platform" already present.
    • Acceptance signal: go1.20 build . succeeds. Running dns-helper add -host www.example.com (no -server) invokes the new smart default resolution path. Running with -server 8.8.8.8 still uses the existing explicit IP path.

Checkpoint: User Story 1 (smart default resolution) is functionally complete at the resolver level. The tool can resolve hostnames without -server using the full Stage 1 → 2 → 2.5 → 3 pipeline. Platform-specific discovery (needed for real local resolvers) is handled in US2 but fallback to bootstrap-only pool works for public hostnames.


Phase 4: User Story 2 — Local Resolver Mode (Priority: P2)

Goal: When -server local is specified, the tool discovers locally configured DNS resolvers and queries only those. No public resolver fallback. Also provides the platform-specific NetworkDiscoverer implementations that US1 depends on for local resolver discovery in default mode.

Independent Test: Run dns-helper add -host internal.client.local -server local and verify it queries only the locally configured resolvers in priority order.

Phase Context:

  • Files modified in this phase: platform/network_windows.go (extended — WindowsNetworkDiscoverer), platform/network_linux.go (new — LinuxNetworkDiscoverer), platform/network_darwin.go (new — DarwinNetworkDiscoverer), platform/discoverer.go (new — NewNetworkDiscoverer() constructor, one per platform with build tags), resolver/modes_test.go (extended — local mode tests)
  • Key types/interfaces used:
    • NetworkDiscoverer interface from platform/network.go: type NetworkDiscoverer interface { Discover() (NetworkInfo, error) }
    • NetworkInfo from platform/network.go: type NetworkInfo struct { DNSServers []string; Gateway string; Interface string }
  • Codebase conventions to follow: Platform files use //go:build <os> build tags (see platform_windows.go, platform_linux.go, platform_darwin.go). Use os/exec.Command() for external commands. Validate IPs with net.ParseIP(). Wrap errors with context using fmt.Errorf.

Windows Network Discovery

  • T020 [P] [US2] Implement WindowsNetworkDiscoverer in platform/network_windows.go

    Context:

    • Target file state: platform/platform_windows.go exists with //go:build windows and only GetHostsFilePath(). The new discoverer struct and Discover() method go in a new file platform/network_windows.go with //go:build windows.
    • Expected implementation pattern:
      //go:build windows
      
      package platform
      
      import (
          "bufio"
          "fmt"
          "net"
          "os/exec"
          "regexp"
          "strconv"
          "strings"
      )
      
      // WindowsNetworkDiscoverer discovers network info using netsh commands.
      type WindowsNetworkDiscoverer struct{}
      
      func (d *WindowsNetworkDiscoverer) Discover() (NetworkInfo, error) {
          info := NetworkInfo{}
          // Step 1: netsh interface ipv4 show route → find 0.0.0.0/0 row
          //   Extract: interface index, gateway IP
          //   If multiple 0.0.0.0/0 rows, take lowest metric
          // Step 2: netsh interface ipv4 show interfaces → map index to name
          // Step 3: netsh interface ipv4 show dnsservers name="<name>" → extract DNS IPs
          return info, nil
      }
      
    • Key decisions and gotchas (from R-001 in research.md):
      • Step 1 parsing: Scan each line for literal 0.0.0.0/0. Split on whitespace. Field 4 (0-indexed) is the interface index. Use IPv4 regex (\d{1,3}\.){3}\d{1,3} on field 5+ for gateway. If multiple default routes, take lowest metric (field 2).
      • Step 2 parsing: Skip first two header lines. Field 0 is Idx. Field 4+ is interface name (may contain spaces — join fields from index 4 onward).
      • Step 3 parsing: Do NOT parse label text (it's localized). Instead, scan each line with IPv4 regex ^\s*(?:\S.*:\s+)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*$. Validate with net.ParseIP().
      • Always quote the interface name in the netsh command: name="vEthernet (Bridge)".
      • If any exec.Command fails, return partial NetworkInfo with a wrapped error.
    • Acceptance signal: go1.20 build ./platform/... succeeds on Windows. go1.20 vet ./platform/... clean. Manual test: run on a Windows machine and verify DNS servers and gateway match ipconfig /all output.

Linux Network Discovery

  • T021 [P] [US2] Implement LinuxNetworkDiscoverer in platform/network_linux.go

    Context:

    • Target file state: platform/platform_linux.go exists with //go:build linux and only GetHostsFilePath(). New file platform/network_linux.go with //go:build linux.
    • Expected implementation pattern:
      //go:build linux
      
      package platform
      
      import (
          "bufio"
          "fmt"
          "net"
          "os"
          "os/exec"
          "strings"
      )
      
      type LinuxNetworkDiscoverer struct{}
      
      func (d *LinuxNetworkDiscoverer) Discover() (NetworkInfo, error) {
          info := NetworkInfo{}
          // Step 1: ip route show default → extract gateway ("via <ip>") and interface ("dev <iface>")
          //   If multiple default routes, take lowest metric ("metric <N>")
          // Step 2: Parse /etc/resolv.conf for "nameserver" lines
          // Step 3 (conditional): If all nameservers are stub addresses (127.0.0.53, 127.0.0.1, 127.0.1.1),
          //   try resolvectl status <interface> to find upstream DNS servers
          return info, nil
      }
      
    • Key decisions and gotchas (from R-002 in research.md):
      • ip route show default output: default via 192.168.1.1 dev eth0 proto dhcp metric 100. Parse via <IP> and dev <iface> by keyword matching. via and dev are not localized — they are fixed in iproute2.
      • /etc/resolv.conf: read lines, skip # and ; comments, extract IP after nameserver keyword. Validate with net.ParseIP().
      • Stub resolver detection: if all nameservers are in {127.0.0.53, 127.0.0.1, 127.0.1.1}, try resolvectl status <interface>. Parse DNS Servers: line and indented continuation lines for IPs.
      • If resolvectl is not found (exec.LookPath fails), skip — use the stub addresses as-is (they're still functional).
      • If /etc/resolv.conf doesn't exist or can't be opened, return empty DNS servers with no error.
    • Acceptance signal: GOOS=linux go1.20 build ./platform/... succeeds. go1.20 vet ./platform/... clean.

macOS Network Discovery

  • T022 [P] [US2] Implement DarwinNetworkDiscoverer in platform/network_darwin.go

    Context:

    • Target file state: platform/platform_darwin.go exists with //go:build darwin and only GetHostsFilePath(). New file platform/network_darwin.go with //go:build darwin.
    • Expected implementation pattern:
      //go:build darwin
      
      package platform
      
      import (
          "bufio"
          "fmt"
          "net"
          "os/exec"
          "regexp"
          "strings"
      )
      
      type DarwinNetworkDiscoverer struct{}
      
      func (d *DarwinNetworkDiscoverer) Discover() (NetworkInfo, error) {
          info := NetworkInfo{}
          // Step 1: route -n get default → extract "gateway:" and "interface:" lines
          // Step 2: scutil --dns → parse resolver blocks for nameserver IPs
          //   Match resolver block whose if_index contains the interface from Step 1
          //   Filter out mDNS blocks (domain: local + options: mdns)
          //   Fallback: use resolver #1 from top section if no interface match
          return info, nil
      }
      
    • Key decisions and gotchas (from R-003 in research.md):
      • route -n get default output: scan for gateway: <IP> and interface: <name>. Keywords are fixed (not localized). Validate gateway with net.ParseIP().
      • scutil --dns parsing: split into resolver blocks by resolver #N lines. For each block, extract nameserver[N] : <IP> lines and if_index : N (<iface>) line. Match the block whose if_index contains the interface from Step 1.
      • Filter out blocks with domain : local + options : mdns (mDNS/Bonjour, not general DNS).
      • Ignore the "DNS configuration (for scoped queries)" section (after the second DNS configuration header).
      • Fallback: if no interface match, use resolver #1 from the top section.
    • Acceptance signal: GOOS=darwin go1.20 build ./platform/... succeeds. go1.20 vet ./platform/... clean.

Platform Constructor

  • T023 [US2] Update NewNetworkDiscoverer() stubs (from T004b) to return real platform discoverers in platform/discoverer_<os>.go

    Context:

    • Target file state: platform/discoverer_windows.go, platform/discoverer_linux.go, and platform/discoverer_darwin.go already exist from T004b with stub discoverers. Now replace the stub with the real implementation.
    • Expected implementation pattern: In each file, remove the stubNetworkDiscoverer type and update NewNetworkDiscoverer() to return the real discoverer:
      //go:build windows  // (or linux, darwin)
      
      package platform
      
      // NewNetworkDiscoverer returns the platform-specific NetworkDiscoverer.
      func NewNetworkDiscoverer() NetworkDiscoverer {
          return &WindowsNetworkDiscoverer{}  // (or LinuxNetworkDiscoverer, DarwinNetworkDiscoverer)
      }
      
    • Key decisions and gotchas: The function returns the interface type NetworkDiscoverer, not the concrete struct. This allows main.go to call platform.NewNetworkDiscoverer() without build-tag conditionals. Each platform file returns its own concrete type. The stub types from T004b should be deleted.
    • Acceptance signal: go1.20 build . succeeds on the current platform. main.go can call platform.NewNetworkDiscoverer() and get real platform discovery results.

Local Mode Tests

  • T024 [US2] Add local mode tests to resolver/modes_test.go

    Context:

    • Target file state: resolver/modes_test.go exists from T017 with default mode tests.
    • Expected implementation pattern: Add tests for local mode:
      func TestResolve_LocalMode_Success(t *testing.T) {
          // FakeNetworkDiscoverer with DNSServers: ["<fakeServer>"]
          // Fake server returns A record
          // Call Resolve with Mode: "local"
          // Assert: IPs returned
      }
      
      func TestResolve_LocalMode_NoLocalResolvers(t *testing.T) {
          // FakeNetworkDiscoverer with empty DNSServers
          // Assert: error "no local DNS servers found"
      }
      
      func TestResolve_LocalMode_AllFail(t *testing.T) {
          // FakeNetworkDiscoverer with unreachable servers
          // Assert: error "all local DNS servers are unreachable"
      }
      
      func TestResolve_LocalMode_PriorityOrder(t *testing.T) {
          // Two local resolvers: first times out, second responds
          // Assert: IPs from second resolver
      }
      
      func TestResolve_LocalMode_NoPublicFallback(t *testing.T) {
          // Local resolvers all fail, verify no attempt to query bootstrap resolvers
      }
      
    • Acceptance signal: go1.20 test ./resolver/... -run TestResolve_LocalMode passes.

Checkpoint: User Story 2 (local resolver mode) is complete. -server local discovers and queries locally configured DNS resolvers on Windows, Linux, and macOS. User Story 1's default mode also benefits from platform discovery now providing real local resolvers.


Phase 5: User Story 3 — Gateway Resolver Mode (Priority: P3)

Goal: When -server gateway is specified, the tool discovers the default gateway IP and sends DNS queries directly to it.

Independent Test: Run dns-helper add -host www.example.com -server gateway and verify the query is sent to the default gateway IP.

Phase Context:

  • Files modified in this phase: resolver/modes_test.go (extended — gateway mode tests)
  • Key types/interfaces used: Resolve() from resolver/modes.go, NetworkDiscoverer from platform/network.go, FakeNetworkDiscoverer for testing
  • Codebase conventions to follow: Same error message patterns as local mode. Gateway discovery reuses the Discover() implementations from Phase 4.

  • T025 [US3] Add gateway mode tests to resolver/modes_test.go

    Context:

    • Target file state: resolver/modes_test.go exists from T017/T024 with default and local mode tests.
    • Expected implementation pattern:
      func TestResolve_GatewayMode_Success(t *testing.T) {
          // FakeNetworkDiscoverer with Gateway: "<fakeServer>"
          // Fake server returns A record
          // Call Resolve with Mode: "gateway"
          // Assert: IPs returned
      }
      
      func TestResolve_GatewayMode_NoGateway(t *testing.T) {
          // FakeNetworkDiscoverer with empty Gateway
          // Assert: error "no default gateway found"
      }
      
      func TestResolve_GatewayMode_GatewayNotResponding(t *testing.T) {
          // FakeNetworkDiscoverer with Gateway pointing to closed port
          // Assert: error "did not respond to DNS query"
      }
      
      func TestResolve_GatewayMode_NoFallback(t *testing.T) {
          // Gateway fails, verify no attempt to query other resolvers
      }
      
    • Key decisions and gotchas: Gateway mode is the simplest mode — single resolver, no fallback, no split-horizon check. The resolveGateway implementation in modes.go (T018) already handles this. These tests verify the integration.
    • Acceptance signal: go1.20 test ./resolver/... -run TestResolve_GatewayMode passes.

Checkpoint: User Story 3 (gateway mode) is complete. The implementation was already created in T018 as part of the Resolve() dispatcher; this phase adds focused tests.


Phase 6: User Story 4 — Explicit Server IP (Priority: P4)

Goal: When -server <ip> or -server <ip>:<port> is specified, the tool queries that server directly. This is existing behavior that must remain backward-compatible.

Independent Test: Already tested by existing resolver/resolver_test.go. Additional integration tests verify the new parsing + dispatch path.

Phase Context:

  • Files modified in this phase: resolver/modes_test.go (extended — explicit mode tests)
  • Key types/interfaces used: Resolve() from resolver/modes.go, ServerMode from resolver/parse.go
  • Codebase conventions to follow: Backward compatibility is critical — existing LookupIP behavior must be preserved exactly.

  • T026 [US4] Add explicit mode tests to resolver/modes_test.go

    Context:

    • Target file state: resolver/modes_test.go exists with default, local, and gateway mode tests.
    • Expected implementation pattern:
      func TestResolve_ExplicitMode_Success(t *testing.T) {
          // Start fake DNS server
          // Call Resolve with Mode: "explicit", ExplicitAddr: "<fakeServer>"
          // Assert: IPs returned (same as existing LookupIP behavior)
      }
      
      func TestResolve_ExplicitMode_WithPort(t *testing.T) {
          // Start fake DNS on non-standard port
          // Call Resolve with ExplicitAddr: "<ip>:<port>"
          // Assert: IPs returned
      }
      
      func TestResolve_ExplicitMode_ServerUnreachable(t *testing.T) {
          // ExplicitAddr points to closed port
          // Assert: error returned
      }
      
    • Key decisions and gotchas: The explicit mode now uses queryA with config.Timeout instead of resolver.New().LookupIP() to ensure the user's -timeout flag is respected (FR-029). The existing resolver_test.go tests verify that the underlying DNS query behavior is unchanged. No split-horizon check in explicit mode (FR-037).
    • Acceptance signal: go1.20 test ./resolver/... -run TestResolve_ExplicitMode passes. Existing tests in resolver_test.go continue to pass.

Checkpoint: User Story 4 (explicit IP mode) is verified as backward-compatible through the new mode dispatch path.


Phase 7: Polish & Cross-Cutting Concerns

Purpose: Verbose output, error message refinement, documentation updates, and full validation.

Phase Context:

  • Files modified in this phase: resolver/modes.go (verbose logging), resolver/authority.go (verbose logging), resolver/fallback.go (verbose logging), resolver/splithorizon.go (verbose logging), main.go (usage text), README.md (documentation)
  • Key types/interfaces used: QueryConfig.Verbose flag from resolver/parse.go
  • Codebase conventions to follow: Verbose output goes to os.Stderr using fmt.Fprintf. Prefix all verbose lines with [dns]. See contracts/cli.md for exact verbose output format. README updates per copilot-instructions.md (must be done after every implementation change).

  • T027 [P] Add verbose diagnostic output to resolver pipeline in resolver/modes.go, resolver/authority.go, resolver/fallback.go, resolver/splithorizon.go

    Context:

    • Target file state: All four files exist from Phases 2-3. Verbose output needs to be threaded through the resolution pipeline via an io.Writer parameter.
    • Expected implementation pattern: Add verbose logging at key decision points per contracts/cli.md verbose output spec:
      [dns] Resolver pool: [10.26.1.1, 1.1.1.1, 8.8.8.8, ...]
      [dns] Stage 1: NS fan-out for www.example.com (2 levels × 7 resolvers = 14 queries)
      [dns]   example.com NS: ns1.example.com., ns2.example.com. (via 8.8.8.8, 1.1.1.1)
      [dns] Selected authority: example.com → ns1.example.com.
      [dns] Stage 2: Querying ns1.example.com. (93.184.216.34) for www.example.com A (RD=0)
      [dns] Stage 2.5: Split-horizon cross-check against local resolvers [10.26.1.1]
      [dns] Result: 93.184.216.34
      
    • Function signature changes: Thread an io.Writer through the pipeline. Pass io.Discard when verbose is off, os.Stderr when on. The following signatures gain a w io.Writer parameter:
      // resolver/modes.go
      func resolveDefault(hostname string, pool []string, localResolvers []string, config QueryConfig, depth int) ([]string, error)
      // → Add w io.Writer parameter; pass through to all Stage functions.
      
      func resolveAuthoritative(ctx context.Context, authority *AuthoritativeNS, hostname string, pool []string, localResolvers []string, config QueryConfig, depth int) ([]string, error)
      // → Add w io.Writer parameter.
      
      // resolver/authority.go
      func ParallelNSFanOut(ctx context.Context, resolvers []string, labelLevels []string, timeout time.Duration) []NSResult
      // → Add w io.Writer parameter. Log query count and per-level NS results.
      
      func QueryAuthoritative(ctx context.Context, ns *AuthoritativeNS, hostname string, resolvers []string, timeout time.Duration) (ips []string, cnameTarget string, nsHostnameUsed string, err error)
      // → Add w io.Writer parameter. Log NS hostname resolution and authoritative query.
      
      // resolver/fallback.go
      func ParallelAFallback(ctx context.Context, resolvers []string, hostname string, timeout time.Duration) ([]string, error)
      // → Add w io.Writer parameter. Log fallback trigger and per-resolver results.
      
      The Resolve() entry point constructs the writer:
      var w io.Writer = io.Discard
      if config.Verbose {
          w = os.Stderr
      }
      
    • Key decisions and gotchas:
      • Use fmt.Fprintf(w, ...) — no slog (Go 1.21+). FR-030 says silent by default; FR-031 says verbose to stderr.
      • io.Writer is more testable than a bool — tests can pass a bytes.Buffer and assert on verbose output content.
      • Prefix all lines with [dns] for easy grep-ability.
      • Writing to io.Discard is effectively a no-op with zero allocation for format strings without args. For format strings with args, the formatting still happens but the write is discarded. This is acceptable for a diagnostic-only path.
    • Acceptance signal: Run dns-helper add -host www.example.com -verbose and verify stderr shows resolution trace. Run without -verbose and verify stderr is silent.
  • T028 [P] Update printUsage() in main.go with new flags and examples

    Context:

    • Existing code references: Current printUsage() in main.go (lines 282-290):
      func printUsage() {
          fmt.Println("This utility will update the local hosts file with DNS entries obtained by the specified DNS server.")
          fmt.Println("Usage: dns-helper [add|delete] -host hostname [-server dns.example.com]")
          // ...
      }
      
    • Expected new content (from contracts/cli.md):
      Usage: dns-helper [add|delete] -host hostname [-server dns.example.com] [-timeout seconds] [-verbose]
      Example:
             dns-helper add -host xyz.acme.com -server dns.example.com
               This will use dns.example.com to find and add all IP addresses for xyz.acme.com to the local hosts file.
             dns-helper add -host xyz.acme.com
               This will use smart resolution to find the authoritative IP for xyz.acme.com.
             dns-helper add -host internal.corp -server local
               This will use only locally configured DNS servers to resolve internal.corp.
             dns-helper add -host www.example.com -server gateway
               This will use the default gateway as the DNS server.
             dns-helper delete -host hostname.example.com
      
    • Acceptance signal: Run dns-helper with no args and verify updated usage text.
  • T029 [P] Update README.md with new CLI flags, resolution modes, program flow, and examples

    Context:

    • Target file state: README.md exists at repo root. Per copilot-instructions.md, README must be updated after implementation changes with: new CLI flags, new resolution modes, updated examples, any new prerequisites.
    • Expected updates:
      1. Document -server (now optional with keyword modes), -timeout, -verbose flags.
      2. Add a "Resolution Modes" section explaining the four modes (smart default, local, gateway, explicit IP) with examples for each.
      3. Add a "How Smart Default Resolution Works" section describing the multi-stage pipeline:
        • Stage 1 — NS Fan-out: Parallel NS queries across all label levels × all resolvers (local + public) to discover the authoritative nameserver.
        • Stage 2 — Authoritative Query: Direct A-record query to the discovered NS with recursion disabled for the freshest answer.
        • Stage 2.5 — Split-Horizon Check: Cross-check the authoritative answer against local resolvers to detect enterprise DNS overrides (runs concurrently with Stage 2).
        • Stage 3 — Parallel A Fallback: If no NS records found (internal hosts), fall back to parallel A queries across all resolvers, taking the first success.
        • CNAME Following: Automatic CNAME chain resolution with 10-hop limit.
      4. Add a "Error Messages" section explaining context-aware errors: private TLD hints, split-horizon conflict reports, CNAME loop detection.
      5. Note the per-platform network discovery behavior (Windows: netsh, Linux: ip route + resolv.conf, macOS: route + scutil).
    • Acceptance signal: README.md accurately reflects all new CLI capabilities and includes a clear description of the smart default resolution program flow.
  • T030 Run full test suite and build validation

    Context:

    • Purpose: Final validation per copilot-instructions.md — run go1.20 test ./..., go1.20 vet ./..., and .\build.ps1 to confirm everything compiles and all tests pass.
    • Expected commands:
      go1.20 vet ./...
      go1.20 test ./... -v
      .\build.ps1
      
    • Acceptance signal: All tests pass. Vet is clean. Build produces binaries. Zero regressions in existing test suite.
  • T031 Run quickstart.md manual validation scenarios

    Context:

    • Purpose: Execute the manual verification scenarios from quickstart.md to confirm end-to-end behavior:
      .\build\dns-helper.exe add -host www.example.com -verbose
      .\build\dns-helper.exe add -host www.example.com -server local -verbose
      .\build\dns-helper.exe add -host www.example.com -server gateway -verbose
      .\build\dns-helper.exe add -host www.example.com -server 8.8.8.8
      .\build\dns-helper.exe add -host www.example.com -timeout 1 -verbose
      
    • Acceptance signal: All scenarios produce expected output. Existing delete commands unaffected.

Dependencies & Execution Order

Phase Dependencies

  • Phase 1 (Setup): No dependencies — can start immediately. Tasks T001-T004 and T004b can all run in parallel.
  • Phase 2 (Foundational): Depends on Phase 1 (needs ServerMode, NetworkInfo, BootstrapResolvers). Tasks T005-T008 can run in parallel.
  • Phase 3 (US1 — Smart Default): Depends on Phase 2 (needs udpQuery, BuildResolverPool) and T004b (needs NewNetworkDiscoverer() stub for T019). Internal ordering: T009/T010 (NS fan-out) → T011/T012 (authoritative) → T013/T014 (fallback) and T015/T016 (split-horizon) in parallel → T017/T018 (dispatcher) → T019 (CLI).
  • Phase 4 (US2 — Local): Depends on Phase 2 (needs NetworkDiscoverer interface). T020-T022 can run in parallel. T023 depends on T020-T022 (replaces T004b stubs with real implementations). T024 depends on T018.
  • Phase 5 (US3 — Gateway): Depends on Phase 3 (needs Resolve()). T025 only.
  • Phase 6 (US4 — Explicit): Depends on Phase 3 (needs Resolve()). T026 only.
  • Phase 7 (Polish): Depends on all phases. T027-T031.

User Story Dependencies

  • US1 (Smart Default): Can start after Phase 2. Core story — all others build on this.
  • US2 (Local Mode): Can start after Phase 2 in parallel with US1 for platform discovery (T020-T022). But T024 (local mode tests) depends on Resolve() from US1 (T018).
  • US3 (Gateway): Depends on Resolve() from US1 (T018). Gateway discovery provided by US2 platform tasks.
  • US4 (Explicit): Depends on Resolve() from US1 (T018). Backward-compatible path.

Within Each User Story

  • Tests MUST be written and FAIL before implementation (TDD)
  • Types/models before functions
  • Core functions before dispatcher
  • Dispatcher before CLI integration

Parallel Opportunities

Phase 1 (all in parallel):

  • T001 (parse tests) | T002 (parse impl) | T003 (network types) | T004 (network tests) | T004b (stub discoverer constructors)

Phase 2 (all in parallel):

  • T005 (transport tests) | T006 (transport impl) | T007 (pool tests) | T008 (pool impl)

Phase 3 (within story):

  • T009/T010 (NS fan-out) then T011/T012 (authoritative)
  • T013/T014 (A fallback) | T015/T016 (split-horizon) — both in parallel after T010
  • T017/T018 (dispatcher) after all above
  • T019 (CLI) after T018

Phase 4 (platform tasks in parallel):

  • T020 (Windows) | T021 (Linux) | T022 (macOS) — all in parallel
  • T023 (constructors) after T020-T022
  • T024 (local tests) after T018 + T023

Phase 7 (polish, partially parallel):

  • T027 (verbose) | T028 (usage) | T029 (README) — all in parallel
  • T030 (validation) after all above
  • T031 (quickstart) after T030

Implementation Strategy

MVP First (User Story 1 Only)

  1. Complete Phase 1: Setup (T001-T004)
  2. Complete Phase 2: Foundational (T005-T008)
  3. Complete Phase 3: User Story 1 — Smart Default Resolution (T009-T019)
  4. STOP and VALIDATE: Run go1.20 test ./... and .\build.ps1. Test US1 by running dns-helper add -host www.example.com (no -server).
  5. The tool works for public hostnames via bootstrap resolvers even without platform discovery.

Incremental Delivery

  1. Setup + Foundational → Foundation ready
  2. Add US1 (Smart Default) → Test independently → MVP! Tool works without -server for public hostnames
  3. Add US2 (Local Mode) → Platform discovery unlocks local resolvers for both US1 and US2
  4. Add US3 (Gateway Mode) → Adds gateway resolution keyword
  5. Add US4 (Explicit Mode) → Verifies backward compatibility through new dispatch path
  6. Polish → Verbose output, documentation, final validation

Task Count Summary

Phase Tasks Description
Phase 1: Setup 5 T001-T004, T004b
Phase 2: Foundational 4 T005-T008
Phase 3: US1 (Smart Default) 11 T009-T019
Phase 4: US2 (Local Mode) 5 T020-T024
Phase 5: US3 (Gateway Mode) 1 T025
Phase 6: US4 (Explicit Mode) 1 T026
Phase 7: Polish 5 T027-T031
Total 32