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 driver — Kustomize 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):
- 5a — Bootstrap. Create
https://github.com/rocky-hq/hearth(MIT). Go scaffold: module pathgithub.com/rocky-hq/hearth,internal/driver/driver.gowith theDriverinterface from §6 below,internal/driver/fake/withFakeDriverfor protocol contract tests, GitHub Actions CI (lint + unit). Add as parent submodule (cadence mirrors Phase 4 PR #14). - 5b —
@rocky-hq/contracts/hearth. Add./hearthsubpath to the existing contracts package; bump to0.2.0. Zod schemas forTier,ProvisioningProfile,DeploymentRef,DriverName,Status,HearthHatchEvent. JSON Schema artifact regenerates atdist/schemas.json. Addgo/codegen output for hearth's consumption (per §7 below). - 5c —
LocalDockerdriver. Implementinternal/driver/localdocker/against the Docker daemon usinggithub.com/docker/docker/client. Integration tests viatestcontainers-go, gatedROCKY_HEARTH_INTEGRATION=1. - 5d — Console SS-08. New
console/src/lib/hearth/(client + server module) and admin-only/hearthpage. SS-06 VAULT stores per-workspace driver credentials. Tenancy invariant wired: Airlock check + HATCH event before mutation. - 5e — Superproject e2e + close-out.
tests/e2e/test_provision_then_ralph.pyprovisions asoloworkspace 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.mdas 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):
Provisionis idempotent on the(slug, profile.tier, profile.driver)triple. Calling it twice returns the sameDeploymentRefwithout side effects.Statusis read-only. Never mutates state; callable any number of times.Upgradeis allowed to mutate the live deployment but MUST preserveDeploymentRef.workspace_slug. The returned ref may changeendpoint/secrets_vault_path/last_status.Teardownis irreversible. After it returns,Statusagainst the same ref returnstier_torn_down(a terminal state, not an error).- All four methods MUST honour
ctx.Done()and return promptly withctx.Err()when cancelled.
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:
node scripts/build-json-schemas.mjs→dist/schemas.json(existing).node scripts/build-go-bindings.mjs→go/hearth/types.go(NEW).cd go && go test ./...(round-trips fixtures, asserts no regen drift).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:
- Docker SDK:
github.com/docker/docker/client(the official Go SDK; library, not shell-out — per redesign §Why Go). - Container labels carry
rocky.workspace_slug=<slug>androcky.tier=<tier>soStatusandTeardownfind them byslugalone. - Network: a per-workspace bridge network
rocky-<slug>for CAIRNET↔LORE traffic. Endpoints exposed only on127.0.0.1:<random-high-port>; the console reaches them via the published port mapping. - Storage: named volumes
rocky-<slug>-cairnetandrocky-<slug>-lore.Teardownremoves both. - Credentials: a
LocalAuthtoken for SS-06 VAULT is generated at provision time and written tosecrets_vault_path = vault://hearth/<slug>(the console's VAULT layer translates this on read). - For the e2e, image stand-ins land per D6 —
nginx:alpinefor both, with the test asserting only on the lifecycle, not service behaviour. A subsequent (post-Phase-5) PR can swap stand-ins for real CAIRNET/LORE images once those are published.
10. Tenancy invariant wiring
Every HEARTH route enforces redesign §Tenancy invariant in this order:
- Airlock check.
getServerSession()resolves toadmin/operator/observer/denied.denied→ 403 + HATCHauth.denied, no further side effects. - 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. - (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 singleawait polar.assertEntitlement(slug, tier)line that's a no-op stub in 5d and gets a real implementation later. - Driver call.
- HATCH outcome event.
hearth.provisioned/hearth.failed/hearth.decommissioned/hearth.upgraded. Written before the route response.
Self-host (ROCKY_AUTH=local, ROCKY_BILLING=disabled):
- Step 1 resolves via the existing
LocalAuthadapter (file-backed single-user identity). - Step 3 is a no-op.
- Step 2 + 5 default to a local JSONL HATCH sink (existing pattern from Phase 3a).
11. OSS parity invariant (verbatim from redesign §261-263)
The
solotier +LocalDockerdriver +LocalAuthadapter 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:
lint—golangci-lint run(config:.golangci.yml).test—go test ./...(unit only; no Docker).integration— runs only whenROCKY_HEARTH_INTEGRATION=1is set in the workflow env (set on PRs touchinginternal/driver/localdocker/**ortest/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:
https://github.com/rocky-hq/hearthexists, MIT-licensed, has a green CI onmain(lint + test).- Parent
.gitmoduleshas a[submodule "hearth"]entry;hearth/resolves to a real commit. @rocky-hq/contracts@0.2.0is published to GitHub Packages with the §6 schemas under./hearth. Thego-parityjob is green.- The
LocalDockerdriver passes its testcontainers integration suite (ROCKY_HEARTH_INTEGRATION=1 go test ./test/integration/...). - Console PR consuming
@rocky-hq/contracts/hearthis merged:npm run typecheckclean,npm run test:run≥ baseline, parsers attached at the three SS-08 trust boundaries. tests/e2e/test_provision_then_ralph.pyis green on parent CI in thesolo+LocalDocker+LocalAuthconfiguration with no cloud env vars set.- Parent advance PR bumps
contracts,console, andhearthsubmodule pointers, flips MILESTONES Phase 5 →closed, updates theCLAUDE.mdSubsystems table heading to "as of Phase 5", flips the SS-08 row to its new closed-state description, and updatesscripts/verify-scaffold.sh'sEXPECTED_SUBMODULESto includehearth. docs/shared/conventions.mdGo line is filled in (lands with this design-spec PR; verified intact at close).
16. Out of scope (deferred)
KustomizeandDevarnoClouddrivers — Phase 6 per redesign §Phasing.- Polar.sh entitlement enforcement +
/api/relay/polar— Phase 7 (D9). - PETROVA
provision_rockyverb — separate PETROVA spec (redesign §PETROVA hookup). - Real CAIRNET/LORE container images in the e2e — D6 uses stand-ins; image-swap is a post-Phase-5 PR once those services publish to a registry.
- Tier upgrade/downgrade UX in the admin UI — Phase 5d ships provision + decommission only; upgrades go through the same
Driver.Upgrademethod, exposed in a later Phase 6/7 console iteration alongside the cloud drivers. - Multi-workspace orchestration / batch provisioning — out of scope for the redesign.
- Operator-facing observability of HEARTH itself (separate from the deployments it creates) — KAHN integration owns this; redesign §KAHN integration.