← Ledger


title: Phase 5 — HEARTH (LocalDocker) — Go submodule + console SS-08 wrapper + e2e date: 2026-05-04 status: Accepted phase: 5 spec: docs/specs/2026-05-02-rocky-system-redesign.md §SS-08, §Phasing-5, §Testing strategy predecessor: docs/specs/2026-05-03-rocky-phase-4.md

Phase 5 — HEARTH (LocalDocker)

A new submodule rocky-hq/hearth (Go, MIT) implements the per-workspace CAIRNET+LORE provisioner from redesign §SS-08. Phase 5 ships only the LocalDocker driverKustomize and DevarnoCloud are Phase 6, Polar.sh entitlement gates are Phase 7. After Phase 5 closes, a self-hoster can run solo tier on their own box with LocalAuth + ROCKY_BILLING=disabled and provision a workspace end-to-end without any cloud credentials. That is the OSS-parity contract this phase exists to ship.

1. Goal

Stand up rocky-hq/hearth per redesign §Repository layout (hearth/ → submodule → @rocky/hearth (NEW; Go)) and §Phasing-5 ("Build rocky-hq/hearth with LocalDocker driver only. Wire console SS-08. Ship the e2e test.").

Five deliverables, ordered as sub-phases (one PR each in subsequent sessions):

  1. 5a — Bootstrap. Create https://github.com/rocky-hq/hearth (MIT). Go scaffold: module path github.com/rocky-hq/hearth, internal/driver/driver.go with the Driver interface from §6 below, internal/driver/fake/ with FakeDriver for protocol contract tests, GitHub Actions CI (lint + unit). Add as parent submodule (cadence mirrors Phase 4 PR #14).
  2. 5b — @rocky-hq/contracts/hearth. Add ./hearth subpath to the existing contracts package; bump to 0.2.0. Zod schemas for Tier, ProvisioningProfile, DeploymentRef, DriverName, Status, HearthHatchEvent. JSON Schema artifact regenerates at dist/schemas.json. Add go/ codegen output for hearth's consumption (per §7 below).
  3. 5c — LocalDocker driver. Implement internal/driver/localdocker/ against the Docker daemon using github.com/docker/docker/client. Integration tests via testcontainers-go, gated ROCKY_HEARTH_INTEGRATION=1.
  4. 5d — Console SS-08. New console/src/lib/hearth/ (client + server module) and admin-only /hearth page. SS-06 VAULT stores per-workspace driver credentials. Tenancy invariant wired: Airlock check + HATCH event before mutation.
  5. 5e — Superproject e2e + close-out. tests/e2e/test_provision_then_ralph.py provisions a solo workspace via LocalDocker, submits a mock 4-prompt RALPH run, asserts the full event chain reaches a fake HATCH sink. Parent ref-bumps + MILESTONES Phase 5 row → closed + CLAUDE.md as of Phase 5.

After this phase, adding a Phase-6 driver (Kustomize, DevarnoCloud) means adding a new file under internal/driver/<name>/ that satisfies the same Driver interface — no schema rewrite, no console-side changes.

2. Locked decisions

# Decision Rationale
D1 HEARTH language: Go. Inherited from redesign §Why Go for HEARTH and decision 0001 §2. Module path github.com/rocky-hq/hearth. Conventions land in docs/decisions/0004-phase-5-go-conventions.md. Ecosystem fit (client-go, docker/cli, kustomize, controller-runtime); single-static-binary distribution; goroutine concurrency.
D2 License: MIT. Per redesign §Licensing ("console, ralph, hearth, algo → MIT"). Distinct from contracts/ Apache-2.0 (Phase 4 D2). Lightweight infrastructure-tool ethos. No redistributed schemas/IDL inside this submodule — those live in contracts/.
D3 Driver protocol matches redesign §SS-08 verbatim. Four methods: Provision, Status, Upgrade, Teardown. No additions in Phase 5. The interface is the contract for Phases 5/6; locking it now prevents drift when 5b/5c land. Additive evolution is allowed (new methods); breaking changes require a decision doc.
D4 LocalDocker only in Phase 5. Kustomize + DevarnoCloud drivers are Phase 6 per redesign §Phasing. Phase 5's CI does not assume k8s or any cloud credentials. OSS-parity invariant requires LocalDocker to land first and stand alone. Cloud drivers add credential-and-billing complexity that Phase 5 does not need.
D5 Cross-language types via codegen. Zod is the source (Phase 4 D1). dist/schemas.json is the byproduct. Go bindings are emitted via quicktype from the JSON Schema artifact, checked in under go/ in the contracts repo, consumed by hearth as a regular Go module dep (github.com/rocky-hq/contracts/go/hearth). Per redesign §SS-08 ("emitted to both Go (via quicktype or buf if proto) and TypeScript"). Console keeps importing zod directly (Phase 4 D6); hearth imports the generated Go package. Both sides stay green via the contracts repo's existing CI plus a new Go-parity test in 5b.
D6 No CAIRNET/LORE source moves in Phase 5. Those services already exist in ~/code/workspace/devarno-cloud/cairnet and …/lore. The LocalDocker driver pulls their published images by tag (or — if not yet published — uses tiny stand-in images for the e2e: nginx:alpine for both, with the e2e test asserting only on the deployment lifecycle, not on CAIRNET/LORE service behaviour). Redesign §Non-goals: "Building CAIRNET, LORE, SHIELD, HATCH, or AIRLOCK — they already exist". Phase 5 is a deployer; hearth must not couple to internal CAIRNET/LORE source layout. The e2e validates the plumbing, not the services.
D7 VAULT is the only credential store. Driver-issued credentials never land in the console DB; they go to SS-06 VAULT keyed by workspace_slug. The DeploymentRef row stores secrets_vault_path (a pointer), never the secret. Redesign §SS-08 "Driver-issued credentials land in SS-06 VAULT keyed by workspace_slug." Tenancy invariant: SS-06 audits read access; the console never logs a raw credential.
D8 No auto-retry on driver failure. A failed Provision marks the DeploymentRef row failed, retains logs, requires admin re-run. Redesign §Error handling, driver_failure row: "Roll back partial state, mark DeploymentRef failed, retain logs, no auto-retry". Provisioning failures usually mean credential or capacity drift; silent retry hides the real problem.
D9 Polar.sh stays out of Phase 5. Tenancy invariant point (3) (cloud-only Polar entitlement check) compiles to a no-op in self-host. The /api/relay/polar route + cloud entitlement enforcement are Phase 7. OSS-parity invariant. The self-host build with ROCKY_BILLING=disabled must pass the e2e with no Polar code paths active. Phase 7 layers entitlements on top of the same SS-08 surface.

3. Architecture

   ┌────────────────────────────────────────────────────────────────────────┐
   │ rocky-hq (parent superproject)                                         │
   │                                                                         │
   │  console/                       contracts/  ─── @rocky-hq/contracts    │
   │   ├─ src/lib/hearth/  ★          ├─ src/ralph/        (v0.1, Phase 4)   │
   │   │  ├─ client.ts                ├─ src/hearth/   ★   (v0.2, Phase 5b) │
   │   │  ├─ server.ts                │   ├─ index.ts                       │
   │   │  └─ types.ts                 │   ├─ tier.ts                        │
   │   └─ src/app/(admin)/hearth/  ★  │   ├─ profile.ts                     │
   │                                  │   ├─ deployment-ref.ts              │
   │  ralph/  (Python, MIT)           │   ├─ status.ts                      │
   │                                  │   └─ hatch.ts                       │
   │  hearth/  ★  (Go, MIT — NEW)     └─ go/hearth/  ★ (quicktype output)   │
   │   ├─ go.mod                                                            │
   │   ├─ cmd/hearth/                                                       │
   │   │  └─ main.go              (RPC entrypoint, called by console)       │
   │   ├─ internal/driver/                                                  │
   │   │  ├─ driver.go            (interface + types)                       │
   │   │  ├─ fake/  (5a)                                                    │
   │   │  └─ localdocker/  (5c)                                             │
   │   └─ test/integration/  (testcontainers, ROCKY_HEARTH_INTEGRATION=1)   │
   │                                                                         │
   │  tests/e2e/test_provision_then_ralph.py  ★ (5e)                        │
   │                                                                         │
   │  ★ NEW or modified in Phase 5                                          │
   └────────────────────────────────────────────────────────────────────────┘

The console talks to hearth over a small JSON-over-HTTP RPC surface (single binary, listens on a Unix socket in self-host or a private port in cloud). The console NEVER embeds Go; the boundary is the right place for polyglot per redesign §Why Go §Rejected alternatives.

4. hearth/ repo layout

hearth/                                 # NEW submodule, Go, MIT
  go.mod                                # module github.com/rocky-hq/hearth
  go.sum
  README.md
  LICENSE                               # MIT
  CLAUDE.md                             # per push-down: build/test instructions
  .golangci.yml                         # config baseline (mirrors kahn-hq Go style)
  .github/workflows/ci.yml              # lint + unit + (gated) integration

  cmd/hearth/
    main.go                             # JSON-over-HTTP server; loads driver per env

  internal/
    driver/
      driver.go                         # Driver interface (§6), shared types
      fake/
        fake.go                         # FakeDriver (5a) — records calls, deterministic
        fake_test.go                    # protocol contract tests
      localdocker/                      # 5c
        localdocker.go
        localdocker_test.go             # unit (no Docker daemon)
    server/
      server.go                         # HTTP handlers; maps RPC verbs to driver methods
      server_test.go

  test/
    integration/
      localdocker_test.go               # testcontainers-go, ROCKY_HEARTH_INTEGRATION=1

  schemas/                              # checked-in copy of contracts artifact for offline build
    schemas.json                        # gitignored unless `tools/sync-schemas.sh` ran

Generated Go bindings live in the contracts repo (under go/hearth/) and are imported as a regular Go module dep, NOT committed to hearth. This keeps the source-of-truth clear: contracts owns the schemas and their codegen output; hearth consumes.

5. Driver protocol (D3 — locked surface)

// internal/driver/driver.go
package driver

import "context"

type Driver interface {
    Provision(ctx context.Context, slug string, profile ProvisioningProfile) (DeploymentRef, error)
    Status(ctx context.Context, ref DeploymentRef) (Status, error)
    Upgrade(ctx context.Context, ref DeploymentRef, profile ProvisioningProfile) (DeploymentRef, error)
    Teardown(ctx context.Context, ref DeploymentRef) error
}

// ProvisioningProfile, DeploymentRef, Status types are imported from
// github.com/rocky-hq/contracts/go/hearth (generated from zod via quicktype).

Contract guarantees (locked by 5a's protocol tests against FakeDriver):

6. @rocky-hq/contracts/hearth schemas (5b)

Lifted from redesign §SS-08 §Tiers and §State store. Every type below becomes a zod schema; the TS type is z.infer<typeof Schema>. The Go struct comes from quicktype against the emitted JSON Schema (D5).

Schema Shape (TS source) Wire boundary
TierSchema z.enum(["solo", "team", "studio", "bespoke"]) Input to provisionWorkspace; persisted on DeploymentRef
DriverNameSchema z.enum(["local-docker", "kustomize", "devarno-cloud"]) Selected by HEARTH server based on env; stored on DeploymentRef
ProvisioningProfileSchema { tier: Tier, resource_caps: { cairnet_storage_mb: number, lore_retention_days: number, seats: number, vector_index: "faiss-local" | "pgvector" }, driver_flags: Record<string, unknown> } HEARTH server input; resolved from a YAML row (one per tier)
DeploymentRefSchema { workspace_slug: string, tier: Tier, driver: DriverName, endpoint: string, secrets_vault_path: string, created: string /* ISO8601 */, last_status: Status } Console DB row + RPC return value
StatusSchema z.enum(["provisioning", "ready", "upgrading", "tearing_down", "failed", "tier_torn_down"]) RPC return + DeploymentRef.last_status
HearthHatchEventSchema discriminated union over "hearth.provisioned" | "hearth.upgraded" | "hearth.decommissioned" | "hearth.failed" with envelope fields (workspace_slug, tier, driver, ts, actor) POST /api/relay/ralph-style boundary, repurposed for hearth events

KILN-extensibility (Phase 4 §5): every nested object accepts .passthrough() so unknown fields ride through without schema bumps. ProvisioningProfile.driver_flags is the explicit extension point per driver.

7. Codegen path (D5, contracts → hearth)

In the contracts repo, 5b adds:

contracts/
  scripts/
    build-go-bindings.mjs   # runs quicktype against dist/schemas.json
                            # writes to go/hearth/types.go
  go/                       # NEW — published as a separate Go module
    go.mod                  # module github.com/rocky-hq/contracts/go
    hearth/
      types.go              # generated; do not edit by hand
      types_test.go         # round-trips fixtures from src/hearth/test/fixtures/

go/ ships as a Go module inside the same repo. Hearth imports it as github.com/rocky-hq/contracts/go/hearth and pins by Git tag (the existing v0.2.0 contracts tag). Updating contracts schemas → re-tagging contracts → bumping go.mod in hearth.

A new contracts CI job (go-parity) runs:

  1. node scripts/build-json-schemas.mjsdist/schemas.json (existing).
  2. node scripts/build-go-bindings.mjsgo/hearth/types.go (NEW).
  3. cd go && go test ./... (round-trips fixtures, asserts no regen drift).
  4. git diff --exit-code go/ (fails if generated output drifted from checked-in).

This mirrors Phase 4 D6's "single source of truth as a contract" pattern — zod is canonical; the generated Go is verified to agree.

8. Console SS-08 surface (5d)

Per redesign §console/src/lib/hearth/:

// console/src/lib/hearth/client.ts (browser-safe)
export async function provisionWorkspace(slug: string, tier: Tier): Promise<DeploymentRef>;        // admin-only
export async function getWorkspaceDeployment(slug: string): Promise<DeploymentRef | null>;          // any role with workspace access
export async function decommissionWorkspace(slug: string): Promise<void>;                           // admin-only

Three API routes mediate (matches Phase 4 D5 pattern — parsers at trust boundaries):

Route Method Parser RBAC HATCH event before mutation
/api/hearth/workspaces POST parseProvisionRequest admin hearth.provisioning_started
/api/hearth/workspaces/[slug] GET n/a workspace-member none (read)
/api/hearth/workspaces/[slug] DELETE n/a admin hearth.decommission_started

After the driver returns, a second HATCH event fires (hearth.provisioned / hearth.failed / hearth.decommissioned) before the response is sent. The "audit-event-before-action" half of the tenancy invariant is non-negotiable; the second event is the outcome record.

Admin UI lands at src/app/(admin)/hearth/page.tsx — server component listing all DeploymentRef rows with last_status, plus a "provision new" form. Observers see only their own workspace's row at the existing workspace settings page (a single-row read embed; not a separate page).

9. LocalDocker driver (5c)

Spins 1×CAIRNET + 1×LORE containers per Provision. Implementation notes:

10. Tenancy invariant wiring

Every HEARTH route enforces redesign §Tenancy invariant in this order:

  1. Airlock check. getServerSession() resolves to admin / operator / observer / denied. denied → 403 + HATCH auth.denied, no further side effects.
  2. HATCH "started" event. Written via SS-05 RELAY before the driver call. Events: hearth.provisioning_started, hearth.upgrade_started, hearth.decommission_started. If RELAY write fails, route returns 503 and the driver is never called.
  3. (Cloud build only) Polar.sh entitlement check for tier > solo. Compiled to a no-op in self-host (ROCKY_BILLING=disabled). Implementation lands in Phase 7; the seam is a single await polar.assertEntitlement(slug, tier) line that's a no-op stub in 5d and gets a real implementation later.
  4. Driver call.
  5. HATCH outcome event. hearth.provisioned / hearth.failed / hearth.decommissioned / hearth.upgraded. Written before the route response.

Self-host (ROCKY_AUTH=local, ROCKY_BILLING=disabled):

11. OSS parity invariant (verbatim from redesign §261-263)

The solo tier + LocalDocker driver + LocalAuth adapter path must pass the full e2e test with no Polar.sh network calls and no devarno-cloud-tenant credentials. If a self-hoster's CI breaks on the e2e test, we broke OSS.

Phase 5e's CI runs the e2e in this exact configuration on every PR. The job MUST be required for merge (branch protection on main).

12. e2e test pattern (5e)

tests/e2e/test_provision_then_ralph.py — Python because RALPH is Python and the producer-side language for KAHN events:

def test_provision_then_ralph(tmp_path, hatch_sink):
    # 1. Spin up hearth (Go binary) on a unix socket.
    # 2. POST /provision { slug: "test", tier: "solo" } → DeploymentRef{ status: "ready" }.
    # 3. Submit a 4-prompt mock RALPH run via ralph serve, pointed at the
    #    workspace's CAIRNET/LORE endpoints from the DeploymentRef.
    # 4. Assert the full event chain reaches hatch_sink:
    #      hearth.provisioning_started → hearth.provisioned →
    #      ralph.run.started → ralph.node.* → ralph.run.ended.
    # 5. POST /decommission { slug: "test" } → DeploymentRef{ status: "tier_torn_down" }.
    # 6. Assert hearth.decommission_started → hearth.decommissioned.

Gate: ROCKY_E2E=1. Always-run on parent CI; opt-in locally (Docker daemon required).

13. CI shape

Hearth repo (rocky-hq/hearth)

Three GitHub Actions jobs:

  1. lintgolangci-lint run (config: .golangci.yml).
  2. testgo test ./... (unit only; no Docker).
  3. integration — runs only when ROCKY_HEARTH_INTEGRATION=1 is set in the workflow env (set on PRs touching internal/driver/localdocker/** or test/integration/**). Spins testcontainers; gated to keep CI fast on doc-only PRs.

Mirrors contracts/console job names.

Contracts repo additions (5b)

Adds go-parity job (§7). Existing build, test, pydantic-parity jobs stay green.

Console repo additions (5d)

No new CI jobs; new tests run inside the existing test job (vitest).

Parent (rocky-hq) additions (5e)

Adds e2e job that runs tests/e2e/test_provision_then_ralph.py. Required for merge to main.

14. Sub-phasing summary (committed by this spec)

Sub-phase Branch (parent) Branch (submodule) Sole deliverable
5a feat/phase-5a-bootstrap-hearth-submodule phase-5a (hearth, new repo) hearth Go scaffold + Driver interface + FakeDriver + parent submodule add
5b chore/phase-5b-bump-contracts-0.2.0 feat/phase-5b-hearth-schemas (contracts) @rocky-hq/contracts@0.2.0 published with ./hearth subpath + go/hearth codegen
5c none (submodule-internal) feat/phase-5c-localdocker-driver (hearth) LocalDocker driver + testcontainers integration tests
5d chore/phase-5d-bump-console feat/phase-5d-console-hearth (console) console SS-08 wrapper + admin /hearth page
5e feat/phase-5e-e2e-and-close none parent e2e test + ref-bumps + MILESTONES close

Each sub-phase owns its own implementation plan inside the relevant submodule (push-down policy, decision 0001).

15. Acceptance criteria

A Phase 5 close (5e merge) is verified when ALL of the following hold:

  1. https://github.com/rocky-hq/hearth exists, MIT-licensed, has a green CI on main (lint + test).
  2. Parent .gitmodules has a [submodule "hearth"] entry; hearth/ resolves to a real commit.
  3. @rocky-hq/contracts@0.2.0 is published to GitHub Packages with the §6 schemas under ./hearth. The go-parity job is green.
  4. The LocalDocker driver passes its testcontainers integration suite (ROCKY_HEARTH_INTEGRATION=1 go test ./test/integration/...).
  5. Console PR consuming @rocky-hq/contracts/hearth is merged: npm run typecheck clean, npm run test:run ≥ baseline, parsers attached at the three SS-08 trust boundaries.
  6. tests/e2e/test_provision_then_ralph.py is green on parent CI in the solo + LocalDocker + LocalAuth configuration with no cloud env vars set.
  7. Parent advance PR bumps contracts, console, and hearth submodule pointers, flips MILESTONES Phase 5 → closed, updates the CLAUDE.md Subsystems table heading to "as of Phase 5", flips the SS-08 row to its new closed-state description, and updates scripts/verify-scaffold.sh's EXPECTED_SUBMODULES to include hearth.
  8. docs/shared/conventions.md Go line is filled in (lands with this design-spec PR; verified intact at close).

16. Out of scope (deferred)