# Phase 1 — GitOps core (backend, no UI, no mutations)

Pure logic + fetch. No HTTP endpoints, no DB writes to workloads yet (migration only).

## Tasks

- [ ] **Migration**: append 4 additive columns to the workloads-table migration list in
  `internal/store/store.go` (idempotent `ALTER TABLE workloads ADD COLUMN`):
  - `gitops_enabled INTEGER NOT NULL DEFAULT 0`
  - `gitops_path TEXT NOT NULL DEFAULT '.tinyforge.yml'`
  - `gitops_last_sync_at TEXT NOT NULL DEFAULT ''`
  - `gitops_commit_sha TEXT NOT NULL DEFAULT ''`
  - Add the fields to the `Workload` struct in `internal/store/models.go` + the
    scan/insert/update column lists in `internal/store/workloads.go` (read path now;
    write path for the setters lands in Phase 2).
- [ ] **`internal/gitops` package — `spec.go`**: `Spec` struct mirroring the v1 schema
  (`Version int`, `Deploy DeploySpec{Port *int, Healthcheck *string, DeployStrategy *string,
  Resources *ResourceSpec{CpuLimit *float64, MemoryLimit *int}, MaxInstances *int}`).
  Pointers so "omitted" is distinguishable from "zero". `ParseSpec([]byte) (Spec, error)`
  using `gopkg.in/yaml.v3` with `KnownFields(true)` to reject unknown keys; reject
  `version != 1`.
- [ ] **`apply.go`**: typed `ApplyPlan{ SourceConfigPatch map[string]any }` (env/faces
  slots reserved in a comment). `BuildPlan(spec) ApplyPlan` maps only the **present**
  (non-nil) declared fields to their `source_config` JSON keys (`port`, `healthcheck`,
  `deploy_strategy`, `cpu_limit`, `memory_limit`, `max_instances`).
- [ ] **`merge.go`**: `MergeAndValidate(liveConfig json.RawMessage, plan ApplyPlan,
  validate func(json.RawMessage) error) (json.RawMessage, error)` — deep-copy live →
  overlay only the patch keys (omitted-field-preserving) → marshal → run `validate` on the
  **merged** result → return merged or error. Never returns a partial/empty config.
- [ ] **`drift.go`**: `Drift(spec Spec, liveConfig json.RawMessage) ([]DriftEntry, error)`
  where `DriftEntry{Field string /*dotted path*/, RepoValue string, LiveValue string}`.
  Compare **only declared leaves**, post-normalization:
  - `deploy_strategy` via the source's effective-default rule (`"" == "recreate"` for
    dockerfile/static) — import or replicate `effectiveStrategy` semantics.
  - numeric coercion (YAML int vs JSON number) compared by value, not raw string.
- [ ] **Provider `DownloadFile`**: add `DownloadFile(ctx, owner, repo, ref, path string,
  maxBytes int64) ([]byte, error)` to the `GitProvider` interface in
  `internal/staticsite/provider.go` and implement for Gitea, GitHub, GitLab using each
  provider's existing raw-file endpoint + the **safe HTTP client**. Cap at 64 KiB.
  Distinguish 404 (file absent) from transport/5xx errors.
- [ ] **`fetch.go`**: `Fetch(ctx, deps, w) (Result, error)` where
  `Result{ Raw []byte, Spec Spec, CommitSHA string, Status string /* ok|no_file|fetch_failed */ }`.
  Decrypt `access_token`, build provider via `NewGitProvider`, `GetLatestCommitSHA`, then
  `DownloadFile(gitops_path)`. Missing file → `no_file` (NOT an error). All errors routed
  through the existing `sanitizeError(msg, token)` so the token never leaks.
- [ ] **Unit tests** (`*_test.go`): ParseSpec (valid/unknown-key/bad-version);
  MergeAndValidate (omitted-field preserved, invalid merged config rejected, no clobber);
  Drift (declared-only, deploy_strategy normalization, numeric coercion, no false positive
  on undeclared keys); a redaction test mirroring `static/helpers_test.go`.

## Verify

- `go build ./...`, `go vet ./internal/...`, `go test ./internal/...` green.

## Handoff notes

_(filled after implementation)_
