← Ledger


title: Phase 4 — Contracts (rocky-hq/contracts scaffold + initial RALPH schemas) date: 2026-05-03 status: Accepted phase: 4 spec: docs/specs/2026-05-02-rocky-system-redesign.md §Repository layout, §Phasing-4 predecessor: docs/specs/2026-05-04-rocky-phase-3e.md

Phase 4 — Contracts

A new submodule rocky-hq/contracts (Apache-2.0) is created and populated with the RALPH schema surface that Phase 3 already crystallised in TypeScript. After this phase, every cross-submodule type used by SS-07 has a single source of truth in zod, and console re-exports from there instead of declaring locally.

1. Goal

Stand up rocky-hq/contracts per redesign-spec §Repository layout (contracts/ → submodule → @rocky/contracts (zod + JSON schema + proto for cross-submodule interfaces)) and §Phasing-4 ("Stand up rocky-hq/contracts with the cross-submodule schemas needed by SS-07").

Three deliverables, in order:

  1. Repo + submodule. Create https://github.com/rocky-hq/contracts (Apache-2.0). Add as contracts/ submodule of the parent superproject. Scaffold an npm-publishable @rocky/contracts package (TS + ESM, zod-first source, generated JSON Schema bundle as a build artifact).
  2. RALPH schema port. Move the cross-submodule subset of console/src/lib/ralph/types.ts (the wire-shape types — KahnEvent, RunConfig, RunStatus, RunSummary, Outcome, plus the optional KILN slots) into @rocky/contracts/ralph. Console re-exports from there; runtime validators replace ad-hoc as casts at HTTP boundaries.
  3. Console adoption + verify. A console PR swaps the local types.ts for re-exports, wires parseSubmitRunOpts / parseKahnEvent validators at the existing API-route entry points, and proves the round-trip is byte-identical against the Phase 3d/3e fixture set.

After this phase, adding a Phase-5 (HEARTH, Go) consumer means generating Go bindings from the same zod-emitted JSON Schema — no schema rewrite required.

2. Locked decisions

# Decision Rationale
D1 Source format: zod. Schemas authored in TypeScript (z.object, z.discriminatedUnion); JSON Schema emitted via zod-to-json-schema as a build-time artifact (dist/schemas.json). Future Go bindings come from the JSON Schema artifact via quicktype or gojsonschema, not from a separate .proto source. Console is the dominant consumer (3 of the 4 current submodules — parent, ralph, console); zod gives the best DX where the most code lives. JSON-Schema-as-byproduct keeps the cross-language door open without forcing buf/protoc into the build of every submodule today. The redesign spec leaves this explicitly TBD ("zod or proto, TBD in the contracts spec"); this resolves it.
D2 License: Apache-2.0. Inherited from decision 0001 §3 ("contracts/ license → Apache-2.0") and reinforced here for clarity. Every other Rocky submodule remains MIT. Schemas/IDLs get redistributed downstream by consumers; the explicit patent grant in Apache-2.0 is the standard-of-care for this artifact class.
D3 Package shape: single @rocky/contracts package, namespaced subpaths. Exports: @rocky/contracts/ralph (this phase), @rocky/contracts/hatch, @rocky/contracts/hearth, @rocky/contracts/polar (later phases). One repo, one package, multiple entry points via package.json#exports. Mirrors the algo-package pattern resolved in decision 0001 §4 ("one repo with namespaced packages"). Avoids npm-org churn; lets each consumer pin a single dep.
D4 KAHN scope boundary: KAHN stays vendored upstream. transitions.schema.json and kahn_emit.py continue to live under kahn-hq/contracts/ and are vendored into ralph (Python) per the Phase-3b pattern. @rocky/contracts/ralph declares its KahnEvent shape as a TS re-projection of the same JSON Schema (verified by a CI test that diffs the emitted JSON Schema against the upstream one) but does NOT re-vendor the source. Operator's kahn-hq is the canonical owner per redesign-spec §KAHN integration; Rocky is a downstream consumer. Re-vendoring would create two sources of truth and a sync-or-die maintenance load. The verification test catches drift without forking.
D5 Consumer migration: re-export, then validate. console/src/lib/ralph/types.ts becomes a re-export shim (export type * from "@rocky/contracts/ralph"); existing call sites unchanged. New validators (parseSubmitRunOpts, parseKahnEvent) plug into the route handlers at src/app/api/ralph/runs/route.ts etc. — runtime validation replaces the cast-and-pray pattern at the HTTP boundary only. Internal call sites stay un-validated (TS already proves them). Smallest blast radius for a Phase 4 PR. The validators live at the trust boundary (HTTP/SSE/CRDT-snapshot/HATCH-relay POST) where validation actually buys safety. Internal call-site re-validation is overhead with no payoff.
D6 No codegen step in the consumer. Console imports zod schemas directly; ralph (Python) keeps its existing pydantic models as the source-of-truth for the Python side, with a CI parity test that verifies the JSON Schema emitted from zod matches the JSON Schema dumpable from pydantic. Adds no build-time tooling to the consumers. The "single source of truth" is a contract, not a code-generation pipeline — zod is the source for TypeScript; pydantic is the source for Python; CI proves they agree. Codegen-from-schema lands in Phase 5 when Go (HEARTH) needs it.
D7 Versioning: SemVer; pre-1.0 in this phase. Initial publish at 0.1.0 (npm tag next until 1.0). Breaking schema changes bump minor; additive changes bump patch. Pre-1.0 conventions match the rest of the Rocky surface during the foundation phases. The 1.0 line gets drawn after Phase 6 when external consumers are realistic.

3. Architecture

   ┌─────────────────────────────────────────────────────────────────┐
   │ rocky-hq (parent superproject)                                  │
   │                                                                  │
   │  console/  (TS, MIT)            contracts/  (TS, Apache-2.0) ★   │
   │  ralph/    (Python, MIT)              ▲                          │
   │  hearth/   (Go, MIT — Phase 5+)       │                          │
   │  algo/     (TS, MIT — later)          │                          │
   │                                       │                          │
   │  ★ NEW in Phase 4 — submodule, npm-publishable                   │
   │                                       │                          │
   └───────────────────────────────────────│──────────────────────────┘
                                           │
                                  consumes via npm
                                           │
                          ┌────────────────┴────────────────┐
                          ▼                                 ▼
                console (TS): import {                ralph (Python): runs
                RunConfig, RunStatus, KahnEvent,      its OWN pydantic models
                Outcome } from                        as source — CI parity
                "@rocky/contracts/ralph"              test diffs the emitted
                                                      JSON Schemas

4. @rocky/contracts package layout

contracts/                              # NEW submodule
  package.json                          # name: "@rocky/contracts", version: "0.1.0"
                                        # exports: { "./ralph": ..., "./hatch": ... }
  tsconfig.json                         # ESM, strict, declarations
  README.md                             # what this is, how to add a schema
  LICENSE                               # Apache-2.0
  CLAUDE.md                             # per push-down: build/test instructions

  src/
    ralph/
      index.ts                          # public surface (re-exports)
      kahn-event.ts                     # NodeAttempt, NodeTransition, RunStart, RunEnd, KahnEvent
      run.ts                            # RunConfig, RunStatus, RunSummary, Outcome, SubmitRunOpts
      kiln.ts                           # convergence_score, early_stop_reason — KILN-extensible
      hatch.ts                          # RalphHatchEvent (the 7 event types from 3d+3e)

  test/
    ralph/
      kahn-parity.test.ts               # diffs emitted JSON Schema vs upstream kahn-hq/contracts
      pydantic-parity.test.ts           # ensures Python pydantic models emit equivalent JSON Schema
      fixtures/                         # one JSON file per locked event shape

  scripts/
    build-json-schemas.mjs              # zod-to-json-schema → dist/schemas.json

  dist/                                 # gitignored; generated by build
    index.js                            # ESM entry
    schemas.json                        # JSON Schema bundle for Python/Go consumers

Not in this phase (deferred to a later contracts sub-phase or Phase 5):

5. Schemas in scope (initial v0.1)

Lifted from console/src/lib/ralph/types.ts:1-115. Every type below becomes a zod schema; the TS type is z.infer<typeof Schema>.

Schema Source Wire boundary
NodeAttemptSchema types.ts:4-22 KAHN journal (ralph→console SSE)
NodeTransitionSchema types.ts:23-32 KAHN journal
RunStartSchema types.ts:33-42 KAHN journal
RunEndSchema types.ts:43-51 KAHN journal
KahnEventSchema types.ts:53 (discriminated union) the wire-format envelope
RunConfigSchema types.ts:55-60 POST /api/ralph/runs body
RunStatusSchema types.ts:62-71 GET /api/ralph/runs/[id] response
OutcomeSchema types.ts:79-85 KILN-extensible enum
RunSummarySchema types.ts:86-99 GET /api/ralph/runs response (array)
SubmitRunOptsSchema types.ts:101-107 RalphClient.submitRun input
RalphHatchEventSchema console/src/lib/ralph/hatch.ts:3-8 (7-type union) POST /api/relay/ralph body

KILN-extensibility (per redesign-spec D3 of 3d): convergence_score? and early_stop_reason? are .optional() in zod and surface as | undefined in the inferred TS type. Consumers that don't carry KILN render no-op slots; consumers that do are forward-compatible without a schema bump.

6. Console migration plan (lands as a separate console PR after the contracts PR merges)

  1. console/src/lib/ralph/types.ts becomes a 4-line file: export * from "@rocky/contracts/ralph". All previously local interfaces live in contracts.
  2. New file console/src/lib/ralph/parsers.ts exports parseSubmitRunOpts(unknown): SubmitRunOpts (and friends), thin wrappers around Schema.parse() with route-friendly error mapping (zod → 400 JSON).
  3. Three route handlers gain a single parseX(...) line at the top:
    • src/app/api/ralph/runs/route.ts POST → parseSubmitRunOpts(body)
    • src/app/api/ralph/runs/[id]/cancel/route.ts POST → no body to parse; skip
    • src/app/api/relay/ralph/route.ts POST → parseRalphHatchEvent(body) (replaces the ad-hoc KNOWN_TYPES Set)
  4. SSE deserialiser at src/lib/ralph/transport/remote.ts:streamEvents validates each frame against KahnEventSchema before yielding. Invalid frames emit a console.error and are skipped (does not abort the stream — KAHN is best-effort downstream).
  5. Console picks up @rocky/contracts as a regular npm dep ("@rocky/contracts": "workspace:*" if a workspace setup is added later, or a tarball-style file: dep until then; this phase uses a pinned version published from CI).

Verify the round-trip on the Phase 3d/3e fixture set:

7. Ralph (Python) parity

ralph keeps its own pydantic models as the Python source-of-truth. The contracts repo ships a CI test (test/ralph/pydantic-parity.test.ts) that:

  1. Imports the upstream pydantic model from ralph (parent submodule, sibling of contracts/).
  2. Calls Model.model_json_schema() to dump its JSON Schema.
  3. Compares it (after a normalisation pass that strips ordering + description differences) against zod-to-json-schema of the equivalent zod schema.

A diff fails CI. This is the "single source of truth as a contract" pattern from D6 — both languages own their own models, contracts proves they agree.

Implementation note: the parity test runs in the contracts repo's CI (which has access to ralph as a peer submodule via the parent superproject's checkout pattern). The test does NOT run inside npm test of the published package — it's a CI-gated integrity check, not a runtime contract.

8. Repo & submodule mechanics

  1. Create https://github.com/rocky-hq/contracts (Apache-2.0, public).
  2. Initial commit on main: the package scaffold from §4 (no schemas yet — empty src/ralph/index.ts returning the empty re-export).
  3. Add as parent submodule at path contracts/:
    git submodule add https://github.com/rocky-hq/contracts.git contracts
    git add .gitmodules contracts
    git commit -m "feat(superproject): add contracts submodule (Phase 4)"
    
  4. First feature PR on rocky-hq/contracts: feat(ralph): port KahnEvent + RunConfig + RunStatus + RunSummary from console types.ts. Lands at contracts HEAD (e.g. 0.1.0).
  5. Parent advance PR: bump the contracts submodule pointer + flip MILESTONES Phase 4 row to closed. (Same cadence as Phase 3e parent close-out, PR #12.)
  6. Console PR (after parent merges): §6 migration. This is the "Phase 4 closes" PR for the console submodule.

The MILESTONES table gets a Phase 4 row (currently shows not-started) flipped to closed once steps 1–6 land.

9. CI shape (contracts repo)

Three jobs in .github/workflows/ci.yml:

  1. buildnpm run build (tsc --emitDeclarationOnly + node scripts/build-json-schemas.mjs); uploads dist/ as an artifact.
  2. testnpm test (vitest): zod schemas round-trip their fixtures; KAHN parity test diffs against upstream kahn-hq/contracts (cloned in the workflow).
  3. pydantic-parity — only runs on PRs that touch src/ralph/**; checks out rocky-hq/ralph as a peer; runs the parity test from §7.

Publishing to npm happens on tag (v0.1.0npm publish --tag next). Manual until Phase 6.

10. Out of scope (deferred)

11. Acceptance criteria

A Phase 4 close is verified when ALL of the following hold:

  1. https://github.com/rocky-hq/contracts exists, is Apache-2.0, has a green CI on main.
  2. Parent .gitmodules has a [submodule "contracts"] entry; contracts/ resolves to a real commit.
  3. @rocky/contracts@0.1.0 is published (npm next tag) with the §5 schemas exported under ./ralph.
  4. The KAHN parity test in contracts is green against the current kahn-hq/contracts HEAD.
  5. The pydantic parity test in contracts is green against the current rocky-hq/ralph HEAD.
  6. Console PR consuming @rocky/contracts/ralph is merged: npm run typecheck clean, npm run test:run ≥342/342, parsers attached at the three trust boundaries (§6).
  7. Parent advance PR bumps the contracts and console submodule pointers, flips MILESTONES Phase 4 row to closed, and updates the CLAUDE.md Subsystems table heading to "as of Phase 4".

12. What this spec does NOT do