---
phase: 08-external-http
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - internal/validation/types.go
  - internal/validation/loader.go
  - internal/validation/loader_test.go
autonomous: true
requirements:
  - EXT-01
  - EXT-02
  - EXT-03

must_haves:
  truths:
    - "A connectivity spec with `mode: external` parses without error"
    - "A connectivity spec with `mode: external` + `sourcePod` set returns a parse-time error"
    - "A connectivity spec with an unknown mode value returns a parse-time error"
    - "A connectivity spec with no `mode` field (existing challenges) parses unchanged — full backwards compatibility"
    - "A `ConnectivityCheck` with `hostHeader` set parses and is accessible in the typed spec"
    - "A sslip.io URL in a connectivity target parses without modification"
  artifacts:
    - path: "internal/validation/types.go"
      provides: "Mode field on ConnectivitySpec; HostHeader field on ConnectivityCheck"
      contains: "Mode string"
    - path: "internal/validation/loader.go"
      provides: "Parse-time validation of mode field and mode+sourcePod incompatibility"
      contains: "mode: external is incompatible with sourcePod"
    - path: "internal/validation/loader_test.go"
      provides: "Failing then passing tests for loader EXT-01/EXT-02/EXT-03 behaviors"
      contains: "TestParse_Connectivity_ExternalMode"
  key_links:
    - from: "internal/validation/loader.go parseSpec TypeConnectivity case"
      to: "internal/validation/types.go ConnectivitySpec.Mode"
      via: "yaml.Unmarshal → spec.Mode field access"
      pattern: "spec\\.Mode"
---

<objective>
Add `Mode` and `HostHeader` fields to the connectivity types, then validate them at parse time in `loader.go`. This plan is the contract definition layer — all execution logic is in Plan 02.

Purpose: Define the new fields that discriminate external mode, validate incoherent specs early (mode:external + sourcePod), and keep existing challenges fully backwards-compatible.
Output: Updated `types.go`, updated `loader.go`, new loader tests (RED→GREEN).
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/08-external-http/08-CONTEXT.md
@.planning/phases/08-external-http/08-RESEARCH.md

<interfaces>
<!-- Key contracts the executor needs. Extracted from codebase. -->

From internal/validation/types.go (current — to be extended):
```go
type ConnectivitySpec struct {
    SourcePod SourcePod          `yaml:"sourcePod" json:"sourcePod"`
    Targets   []ConnectivityCheck `yaml:"targets" json:"targets"`
}

type ConnectivityCheck struct {
    URL                string `yaml:"url" json:"url"`
    ExpectedStatusCode int    `yaml:"expectedStatusCode" json:"expectedStatusCode"`
    TimeoutSeconds     int    `yaml:"timeoutSeconds,omitempty" json:"timeoutSeconds,omitempty"`
}

type SourcePod struct {
    Name          string            `yaml:"name,omitempty" json:"name,omitempty"`
    LabelSelector map[string]string `yaml:"labelSelector,omitempty" json:"labelSelector,omitempty"`
    Namespace     string            `yaml:"namespace,omitempty" json:"namespace,omitempty"`
}
```

From internal/validation/loader.go (current TypeConnectivity case in parseSpec):
```go
case TypeConnectivity:
    var spec ConnectivitySpec
    if err := yaml.Unmarshal(specYAML, &spec); err != nil {
        return err
    }
    if err := validateSourcePod(spec.SourcePod); err != nil {
        return err
    }
    // Apply default timeout to connectivity targets if not specified
    for i := range spec.Targets {
        if spec.Targets[i].TimeoutSeconds == 0 {
            spec.Targets[i].TimeoutSeconds = DefaultConnectivityTimeoutSeconds
        }
    }
    v.Spec = spec
```

From internal/validation/loader.go (validateSourcePod — currently a no-op):
```go
func validateSourcePod(_ SourcePod) error {
    return nil
}
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add Mode and HostHeader fields to types.go</name>
  <files>internal/validation/types.go</files>
  <behavior>
    - ConnectivitySpec.Mode parses from YAML key "mode" with omitempty
    - ConnectivityCheck.HostHeader parses from YAML key "hostHeader" with omitempty
    - Missing mode field deserializes to empty string "" (not "internal") — existing specs unaffected
    - HostHeader absent deserializes to empty string "" — no change to existing checks
  </behavior>
  <action>
Add two fields to existing structs in `internal/validation/types.go`:

1. Add `Mode string` to `ConnectivitySpec` (insert before or after `SourcePod`, before `Targets`):
```go
// Mode controls how connectivity checks are executed.
// "internal" (default, empty string): existing pod exec via curl (SPDY)
// "external": CLI host sends HTTP request via net/http — no pod exec
// When absent, defaults to internal behavior for full backwards compatibility.
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
```

2. Add `HostHeader string` to `ConnectivityCheck` (after TimeoutSeconds):
```go
// HostHeader overrides the HTTP Host header sent with the request.
// Only used when Mode is "external". Absent: Host is derived from URL automatically.
// Use when URL is a direct IP but Ingress routes by hostname.
HostHeader string `yaml:"hostHeader,omitempty" json:"hostHeader,omitempty"`
```

No other changes. No imports needed.
  </action>
  <verify>
    <automated>go build ./internal/validation/... 2>&1</automated>
  </verify>
  <done>Both fields added; `go build ./internal/validation/...` exits 0; existing loader_test.go and executor_test.go still pass: `go test ./internal/validation/... -count=1`</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Loader parse-time validation + RED→GREEN tests</name>
  <files>internal/validation/loader.go, internal/validation/loader_test.go</files>
  <behavior>
    - TestParse_Connectivity_ExternalMode: spec with mode:external and no sourcePod → parses OK, spec.Mode == "external"
    - TestParse_Connectivity_ExternalModeWithSourcePod: mode:external + sourcePod.Name set → returns error containing "incompatible with sourcePod"
    - TestParse_Connectivity_SslipIO: target URL "http://myapp.127-0-0-1.sslip.io:8080/" parses without modification
    - TestParse_Connectivity_InvalidMode: mode:"banana" → returns error containing "invalid mode"
    - TestParse_Connectivity_NoMode: existing spec with no mode field → parses OK, spec.Mode == "" (empty string)
  </behavior>
  <action>
**Step 1 — Write failing tests first in `internal/validation/loader_test.go`.**

Add a new test group after the existing TypeConnectivity test cases. Use the existing test helper patterns in loader_test.go (look at how TypeConnectivity specs are structured in the file). Add these tests:

```go
func TestParse_Connectivity_ExternalMode(t *testing.T) {
    yaml := `
objectives:
  - key: ext-check
    type: connectivity
    spec:
      mode: external
      targets:
        - url: http://myapp.127-0-0-1.sslip.io:8080/
          expectedStatusCode: 200
`
    cfg, err := Parse([]byte(yaml))
    require.NoError(t, err)
    spec := cfg.Validations[0].Spec.(ConnectivitySpec)
    assert.Equal(t, "external", spec.Mode)
    assert.Equal(t, "http://myapp.127-0-0-1.sslip.io:8080/", spec.Targets[0].URL)
}

func TestParse_Connectivity_ExternalModeWithSourcePod(t *testing.T) {
    yaml := `
objectives:
  - key: ext-check
    type: connectivity
    spec:
      mode: external
      sourcePod:
        name: my-pod
      targets:
        - url: http://example.com/
          expectedStatusCode: 200
`
    _, err := Parse([]byte(yaml))
    require.Error(t, err)
    assert.Contains(t, err.Error(), "incompatible with sourcePod")
}

func TestParse_Connectivity_SslipIO(t *testing.T) {
    yaml := `
objectives:
  - key: sslip-check
    type: connectivity
    spec:
      mode: external
      targets:
        - url: http://myapp.127-0-0-1.sslip.io:8080/health
          expectedStatusCode: 200
          timeoutSeconds: 10
`
    cfg, err := Parse([]byte(yaml))
    require.NoError(t, err)
    spec := cfg.Validations[0].Spec.(ConnectivitySpec)
    assert.Equal(t, "http://myapp.127-0-0-1.sslip.io:8080/health", spec.Targets[0].URL)
}

func TestParse_Connectivity_InvalidMode(t *testing.T) {
    yaml := `
objectives:
  - key: bad-check
    type: connectivity
    spec:
      mode: banana
      targets:
        - url: http://example.com/
          expectedStatusCode: 200
`
    _, err := Parse([]byte(yaml))
    require.Error(t, err)
    assert.Contains(t, err.Error(), "invalid mode")
}

func TestParse_Connectivity_NoMode(t *testing.T) {
    yaml := `
objectives:
  - key: internal-check
    type: connectivity
    spec:
      sourcePod:
        name: my-pod
      targets:
        - url: http://service:80/
          expectedStatusCode: 200
`
    cfg, err := Parse([]byte(yaml))
    require.NoError(t, err)
    spec := cfg.Validations[0].Spec.(ConnectivitySpec)
    assert.Equal(t, "", spec.Mode)
}
```

Run tests — they will FAIL on the mode validation cases because loader.go doesn't validate mode yet.

**Step 2 — Update `internal/validation/loader.go` TypeConnectivity case in parseSpec to make tests GREEN.**

Replace the existing TypeConnectivity case with:

```go
case TypeConnectivity:
    var spec ConnectivitySpec
    if err := yaml.Unmarshal(specYAML, &spec); err != nil {
        return err
    }
    // EXT-01: fail fast if mode: external is combined with sourcePod (incoherent spec)
    if spec.Mode == "external" {
        sp := spec.SourcePod
        if sp.Name != "" || len(sp.LabelSelector) > 0 || sp.Namespace != "" {
            return fmt.Errorf("mode: external is incompatible with sourcePod (remove sourcePod or use mode: internal)")
        }
    } else if spec.Mode != "" && spec.Mode != "internal" {
        return fmt.Errorf("invalid mode %q: must be \"internal\" or \"external\"", spec.Mode)
    }
    if err := validateSourcePod(spec.SourcePod); err != nil {
        return err
    }
    // Apply default timeout to connectivity targets if not specified
    for i := range spec.Targets {
        if spec.Targets[i].TimeoutSeconds == 0 {
            spec.Targets[i].TimeoutSeconds = DefaultConnectivityTimeoutSeconds
        }
    }
    v.Spec = spec
```

Run tests — all 5 new tests must pass.
  </action>
  <verify>
    <automated>go test ./internal/validation/... -run "TestParse_Connectivity_ExternalMode|TestParse_Connectivity_ExternalModeWithSourcePod|TestParse_Connectivity_SslipIO|TestParse_Connectivity_InvalidMode|TestParse_Connectivity_NoMode" -v -count=1</automated>
  </verify>
  <done>All 5 new loader tests pass; full suite still green: `go test ./internal/validation/... -count=1`; `task test:unit` exits 0</done>
</task>

</tasks>

<verification>
Full unit test suite passes after both tasks:

```bash
go test ./internal/validation/... -count=1
task test:unit
```

New loader tests all green. No regressions in existing tests.
</verification>

<success_criteria>
- `internal/validation/types.go` has `Mode string` on `ConnectivitySpec` and `HostHeader string` on `ConnectivityCheck`
- `internal/validation/loader.go` rejects `mode:external + sourcePod` with a clear error; rejects unknown modes; accepts empty mode (backwards compat)
- 5 new loader tests pass; pre-existing test count unchanged (no deletions)
- `task test:unit` exits 0
</success_criteria>

<output>
After completion, create `.planning/phases/08-external-http/08-01-SUMMARY.md`
</output>
