title: Phase 5c close — Hearth LocalDocker driver + JSON-over-HTTP RPC date: 2026-06-29 status: Accepted phase: 5c predecessor: docs/decisions/2026-05-25-phase-5b-close.md spec: docs/specs/2026-05-04-rocky-phase-5.md §6 + §7 plan: docs/plans/2026-06-20-hearth-localdocker-rpc-phase-5c.md
Phase 5c close
Shipped
rocky-hq/hearthPR #4: first concreteDriverimplementation (internal/driver/localdocker/) wired against a narrowdockerAPIinterface (so unit tests mock the SDK without a daemon), plusinternal/server/carrying the JSON-over-HTTP handlers + Unix-socket transport with0600perms + signal-driven graceful shutdown, pluscmd/hearth/main.go(env-driven driver selection:local-docker|fake). Integration suite attest/integration/gated byROCKY_HEARTH_INTEGRATION=1covers all four verbs against the real daemon (nginx:alpine stand-ins) plus Provision idempotence and Teardown-leaves-nothing. CIintegrationjob added to.github/workflows/ci.yml. Merged atf2dc306.rocky-hq/rockyPR-A (plan):docs/plans/2026-06-20-hearth-localdocker-rpc-phase-5c.mdmerged at7213139(parent #63).- Parent:
hearthsubmodule pointer bumped tof2dc306;CLAUDE.mdSS-08 row refreshed;MILESTONES.md5c row closed.
Carry-forwards
1. Docker SDK pinned at v28.5.2
The plan specified github.com/docker/docker@v27.3.1. cmd/hearth/main.go (Task 6) was the first real consumer of client.NewClientWithOpts — unit tests previously used a mocked dockerAPI. v27.3.1's client package imports a sockets.DialPipe symbol that no longer exists in current go-connections, breaking the cmd-binary build. v28.5.2 resolves cleanly. v28 also keeps types.ContainerJSON and types.Container as deprecated aliases for container.InspectResponse and container.Summary; the dockerAPI interface uses the non-aliased forms to satisfy staticcheck SA1019. Future hearth SDK bumps should land in their own PR with a CI run, since v27/v28 transitive deps differ materially.
2. DeploymentRef.Created reads from container inspect, not d.now()
The first cut of Task 2 sourced Created from a driver clock (d.now()) on every Provision call, including the idempotent re-Provision path. Re-invocations returned a fresh timestamp, breaking the spec's idempotence guarantee that (slug, tier, driver) returns "the existing ref." Fix at c7e2f35 extracts cairnetCreated(ctx, summaries) which reads the cairnet container's inspect.Created; both fresh and idempotent paths use it, so ref == ref across repeated Provision(slug, sameTier) calls. Falls back to d.now() if the cairnet container is absent (mid-Provision race) or if inspect/parse fails. The integration test was tightened to assert full struct equality. The takeaway: DeploymentRef.Created is "deployment birth," not "ref construction time"; future drivers (Kustomize in Phase 6a, DevarnoCloud in Phase 6b) should keep this invariant.
3. LabelTier is a fourth canonical label
The plan §Global Constraints listed three canonical labels (managed-by, workspace, role). To enforce tier-mismatch rejection on Provision against an existing workspace, LabelTier = rocky-hq.io/tier was added in Task 2 and lives in internal/driver/localdocker/labels.go alongside the other three. It is applied to network + every container + every volume (writing it on the network is benign — tier-mismatch check reads containers — but it keeps label coverage symmetric for future label-filter sweeps). Future drivers should preserve this label-set so cross-driver Status views remain meaningful.
4. Provision and Teardown are split into helpers for gocyclo
The plan's literal Provision body crossed golangci-lint's default gocyclo ceiling (19 against 15). Task 8.5 (lint cleanup) extracted createNetwork / createWorkspaceContainers / imageFor / pullImage / rollbackWorkspace. Same shape applied to Teardown → teardownContainers / teardownNetworks / teardownVolumes. Contract semantics unchanged (rollback semantics preserved, ctx honoring preserved, idempotence path preserved). Future driver implementations should aim for similar cohesion — keep the public method orchestrating, push SDK calls into helpers.
5. Wire-format admin-throw vs SSE-skip parser pattern remains intact
Server boundary parsers reuse parseProvisioningProfile / parseDeploymentRef from @rocky-hq/contracts/go/hearth (Phase 5b §5 carry-forward). The HEARTH throw-on-invalid pattern is the right shape for admin RPC: bad input surfaces as a 4xx (invalid_request code in the error envelope). KILN-extensibility is preserved — the server's JSON decoder does NOT call DisallowUnknownFields, so additive contract bumps don't break older callers. Verified by the unknown-field passthrough test in internal/server/server_test.go.
6. No-test-only-mutation-of-internal/ exception: WorkspaceFilterForTests
Integration cleanup needs label-filtered sweeps over containers/networks/volumes for a workspace slug. The internal workspaceFilter(slug) helper is unexported. Task 7 exported WorkspaceFilterForTests(slug) as a thin wrapper (it is the only test-only export in the localdocker package). Future packages may follow the same convention if cleanup helpers require label-aware lookups; the _ForTests suffix is the convention to keep it grep-able and out of production code.
Phase 5c acceptance verified at merge
go build ./...clean in hearth.go test ./... -race -count=1green across all four packages (fake,localdocker,server,integrationauto-skips when the env gate is unset).ROCKY_HEARTH_INTEGRATION=1 go test ./test/integration/... -race -count=1green against host Docker (TestLocalDockerE2E + TestRPCEndToEnd).gofumpt -l .empty.go vet ./...clean.golangci-lint runzero findings (bodyclose/noctx/SA1019/gocyclo/errorlint/misspell/shadow/errcheckall addressed without//nolint:directives or.golangci.ymlrelaxations).go mod tidy && git diff --exit-code go.mod go.sumclean.- Operator smoke:
ROCKY_HEARTH_DRIVER=fake ROCKY_HEARTH_SOCKET=/tmp/h.sock ./hearth &→curl --unix-socket /tmp/h.sock http://x/v1/healthz→{"ok":true}. - CI
lint+test+integrationjobs all green on the hearth merge commit (f2dc306).
Out of scope (deferred per plan §Out-of-scope)
- VAULT integration —
DeploymentRef.SecretsVaultPathstays empty (Phase 5d adds a post-Provision VAULT write). - Console SS-08 wrapper calling the RPC (Phase 5d).
- TCP transport + bearer-token auth (Phase 6 cloud).
- K8s / Fly / Render drivers (Phase 6a/6b).
- HATCH audit events at the driver layer (Phase 7 cross-cut; see
docs/decisions/2026-06-29-agentic-layer-projection.mdfor the projection model that 7b will wire driveragent.invokedevents into). - Polar entitlement check at provision time (Phase 7).
- Real CAIRNET / LORE images (still nginx:alpine stand-ins; published from separate streams).
Sources
docs/specs/2026-05-04-rocky-phase-5.md§6 (HEARTH wire contracts) + §7 (driver tests).docs/decisions/2026-05-25-phase-5b-close.md(parser-pattern carry-forward + Go-module tagging).docs/decisions/2026-06-29-agentic-layer-projection.md(Phase 7 cross-cut that consumes driver events).docs/plans/2026-06-20-hearth-localdocker-rpc-phase-5c.md(implementation plan; merged in parent PR #63).rocky-hq/hearthPR #4 (merge commitf2dc306).