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:
- Repo + submodule. Create
https://github.com/rocky-hq/contracts(Apache-2.0). Add ascontracts/submodule of the parent superproject. Scaffold an npm-publishable@rocky/contractspackage (TS + ESM, zod-first source, generated JSON Schema bundle as a build artifact). - 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-hocascasts at HTTP boundaries. - Console adoption + verify. A console PR swaps the local
types.tsfor re-exports, wiresparseSubmitRunOpts/parseKahnEventvalidators 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):
- Generated Go bindings (
go/directory +quicktypepipeline). - HATCH event schema migration into a separate
@rocky/contracts/hatchentry point — current 3d/3e shapes ride along inside@rocky/contracts/ralphfor now since they're RALPH-specific. A pure-HATCH split happens once a non-RALPH HATCH consumer exists. - HEARTH provisioning schemas (
@rocky/contracts/hearth) — owned by 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)
console/src/lib/ralph/types.tsbecomes a 4-line file:export * from "@rocky/contracts/ralph". All previously local interfaces live in contracts.- New file
console/src/lib/ralph/parsers.tsexportsparseSubmitRunOpts(unknown): SubmitRunOpts(and friends), thin wrappers aroundSchema.parse()with route-friendly error mapping (zod → 400 JSON). - Three route handlers gain a single
parseX(...)line at the top:src/app/api/ralph/runs/route.tsPOST →parseSubmitRunOpts(body)src/app/api/ralph/runs/[id]/cancel/route.tsPOST → no body to parse; skipsrc/app/api/relay/ralph/route.tsPOST →parseRalphHatchEvent(body)(replaces the ad-hocKNOWN_TYPESSet)
- SSE deserialiser at
src/lib/ralph/transport/remote.ts:streamEventsvalidates each frame againstKahnEventSchemabefore yielding. Invalid frames emit a console.error and are skipped (does not abort the stream — KAHN is best-effort downstream). - Console picks up
@rocky/contractsas 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:
npm run typecheckin console — clean (no type drift).npm run test:runin console — 342/342 still passing (the parsers run on existing fixtures).- A new test
console/src/lib/ralph/parsers.test.tsadds parsing-error coverage (rejects malformed bodies with the expected 400).
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:
- Imports the upstream pydantic model from
ralph(parent submodule, sibling ofcontracts/). - Calls
Model.model_json_schema()to dump its JSON Schema. - Compares it (after a normalisation pass that strips ordering + description differences) against
zod-to-json-schemaof 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
- Create
https://github.com/rocky-hq/contracts(Apache-2.0, public). - Initial commit on
main: the package scaffold from §4 (no schemas yet — emptysrc/ralph/index.tsreturning the empty re-export). - 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)" - First feature PR on
rocky-hq/contracts:feat(ralph): port KahnEvent + RunConfig + RunStatus + RunSummary from console types.ts. Lands atcontractsHEAD (e.g.0.1.0). - Parent advance PR: bump the
contractssubmodule pointer + flip MILESTONES Phase 4 row toclosed. (Same cadence as Phase 3e parent close-out, PR #12.) - 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:
build—npm run build(tsc --emitDeclarationOnly+node scripts/build-json-schemas.mjs); uploadsdist/as an artifact.test—npm test(vitest): zod schemas round-trip their fixtures; KAHN parity test diffs against upstreamkahn-hq/contracts(cloned in the workflow).pydantic-parity— only runs on PRs that touchsrc/ralph/**; checks outrocky-hq/ralphas a peer; runs the parity test from §7.
Publishing to npm happens on tag (v0.1.0 → npm publish --tag next). Manual until Phase 6.
10. Out of scope (deferred)
- Go bindings codegen — Phase 5 when HEARTH lands.
- HATCH-as-its-own-package (
@rocky/contracts/hatch) — splits when a non-RALPH HATCH consumer exists. - HEARTH provisioning schemas — Phase 5.
- Polar.sh entitlement schemas — Phase 7.
- A shared schema-registry-style runtime catalog (
getSchema("ralph.run.v1")) — premature at the current scale; consumers import named schemas directly. - Rust bindings — no Rocky consumer is in Rust.
11. Acceptance criteria
A Phase 4 close is verified when ALL of the following hold:
https://github.com/rocky-hq/contractsexists, is Apache-2.0, has a green CI onmain.- Parent
.gitmoduleshas a[submodule "contracts"]entry;contracts/resolves to a real commit. @rocky/contracts@0.1.0is published (npmnexttag) with the §5 schemas exported under./ralph.- The KAHN parity test in
contractsis green against the currentkahn-hq/contractsHEAD. - The pydantic parity test in
contractsis green against the currentrocky-hq/ralphHEAD. - Console PR consuming
@rocky/contracts/ralphis merged:npm run typecheckclean,npm run test:run≥342/342, parsers attached at the three trust boundaries (§6). - Parent advance PR bumps the
contractsandconsolesubmodule pointers, flips MILESTONES Phase 4 row toclosed, and updates theCLAUDE.mdSubsystems table heading to "as of Phase 4".
12. What this spec does NOT do
- Does not write the schemas themselves (the impl plan in
contracts/docs/plans/2026-05-NN-contracts-phase-4.mdwill). - Does not migrate any console code (separate console PR).
- Does not change ralph (Python) at all — the parity test is the only new touchpoint and it lives in contracts' CI.