Status Distillation — Design Note (closes C4)¶
Status: Draft · Closes: grilling-log C4 ("status-distillation is under-specified — needs its own design note: inputs, RBAC, concrete examples"). Grounds in: Q11 (long-running ops / distillation is the differentiation), design §7.A (the wedge), §10 (token economy + error taxonomy), §6 (tool surface). Precedes: the architecture review's Candidate 1 (name the distillation seam before RayJob lands, while exactly one implementation exists).
1. What distillation is, and why it's the moat¶
A generic Kubernetes MCP hands the agent raw .status YAML. ray-mcp hands it one
agent-actionable line: "Pending: unschedulable, no GPU nodes" instead of a
condition array the agent must re-derive every call (Q11 §3). That translation —
typed CRD status (± live Ray API status) → a distilled, bounded, agent-readable
view — is the soft-moat crown jewel. It is the reason the read tools exist.
Today it lives, correctly and deeply, as unexported helpers in
internal/adapters/kuberay/cluster.go (clusterPhase, clusterHealth,
healthDetail, condReason, clusterAge, dashboardURL). It is right for
RayCluster. The risk C4 names is that when RayJob and RayService land, the
natural move is a fresh job.go/service.go that re-grows the same shape by
hand, spreading the moat policy across three files with no single review surface.
This note specifies the distillation contract (inputs → output, per kind) so that the seam introduced for Candidate 1 is shaped by the real cross-kind variation, not guessed from the single RayCluster case.
2. The distilled output (the shared vocabulary)¶
Every kind distills to the same conceptual shape — already partly present in
internal/domain/types.go (ClusterSummary/JobSummary/ServiceSummary all
carry Phase/*Status + a 1-line Health). This note makes the wedged
signal first-class, because "is it progressing or stuck, and why" is the part
that is the differentiation and the part most likely to drift per kind.
| Field | Meaning | Bound |
|---|---|---|
| Phase | human lifecycle phase derived from the condition ladder (not raw .status.state) |
one short enum-ish string |
| Ready / Desired | progress counters (workers ready, serve replicas healthy) | ints; never re-summed from spec — operator-computed |
| Wedged | bool: is this stuck rather than progressing? | — |
| WedgeReason | when wedged, the one actionable cause (+ the triggering event) | ≤ ~1 line; e.g. "unschedulable: insufficient nvidia.com/gpu" |
| Health | the composed 1-line glance: phase; counts; reason? |
≤ ~1 line (current clusterHealth shape) |
Health is a composition of the others, not a fourth independent source of
truth. The full object stays behind verbose/raw (§10); distillation never
replaces the escape hatch, it is the default that saves the agent's tokens.
Note —
Wedged/WedgeReasonare new. Today RayCluster folds "stuck" into theHealthstring viahealthDetail. Promoting it to a typed field is the one additive domain change this note recommends, and only because all three kinds need it and Q11 calls it out by name. It is not required to ship RayCluster; it is required to keep the moat coherent across kinds.
3. Inputs, per kind (the C4 "inputs" gap)¶
Distillation is not uniform across kinds — this is the core finding, and the reason a single shared function would be wrong. The shared part is the health-line composition and the condition-reason extraction; the sources and the wedge heuristics differ.
3.1 RayCluster — single source: the CRD (built, today)¶
- Source:
RayCluster.statusonly. No Ray dashboard API involved. - Phase: condition-TYPE ladder (
RayClusterSuspended→Suspending→ReplicaFailure→HeadPodReady+Provisioned→Provisioning), falling back to the deprecated.status.state, thenUnknown. (Switches on type, not reason strings — reasons shift between minor releases. SeeclusterPhase.) - Counters:
status.readyWorkerReplicas/status.desiredWorkerReplicas(worker-only; head excluded — operator-computed, not re-summed). - Wedge heuristic:
ReplicaFailure=True, orProvisionedpresent-but-not-True for too long → wedged; reason from the condition message, enriched by the most relevant pod event (e.g.FailedScheduling).
3.2 RayJob — TWO sources: CRD then live Ray API (the two-phase wedge read)¶
This is the case that breaks any "one status struct in" assumption.
- Source 1 — CRD: status.jobDeploymentStatus (lifecycle), status.jobId,
status.rayClusterName, status.dashboardURL (already modeled in
domain.JobDetail).
- Source 2 — Ray dashboard API: GET /api/jobs/{jobId} → domain.RayJobStatus
(PENDING/RUNNING/SUCCEEDED/FAILED/STOPPED + message), but only after
status.jobId is populated.
- Two-phase rule (design §7.A): while status.jobId is empty → return
"job not yet scheduled" from the CRD alone, no dial. Once present → dial
the dashboard and distill the live status.
- Graceful degrade (§10): dashboard unreachable → return the CRD-derived view
annotated "live Ray detail unavailable: RayAPIUnreachableError
reason), never a hard failure.
- Wedge heuristic: jobId empty for too long, or Ray status PENDING with an
unschedulable head/worker pod → "Pending: unschedulable, no GPU nodes".
3.3 RayService — single source: the CRD, rollout-shaped¶
- Source:
RayService.status— serve status, rollout/cutover phase, healthy serve replicas (modeled indomain.ServiceDetail.RolloutPhase+ServiceSummary). - Phase: rollout phase (pending → rolling out → cutover → running) + old/new serve health.
- Wedge heuristic: new serve app
UNHEALTHYpast a threshold, or rollout stalled mid-cutover → wedged with the failing application/deployment named.
Implication for the seam: the health-line composer and condition-reason extractor are genuinely shared (kind-agnostic string work); the source plumbing and wedge predicate are per-kind. The seam must let the per-kind half vary without re-growing the shared half.
4. RBAC (the C4 "RBAC" gap)¶
Distillation reads only — it never widens the write surface. What it consumes,
and therefore what the served-namespace Role (or ClusterRole in cluster mode,
Q12) must grant:
| Read | Verbs | Resources |
|---|---|---|
| Cluster/Job/Service phase + counters | get, list, watch |
rayclusters, rayjobs, rayservices (ray.io group) |
| Status subresource (where served separately) | get |
…/status |
Wedge-reason event enrichment (ray_*_events, the pod-event half of the heuristic) |
get, list |
events (core), pods (to map a cluster→its pods) |
| RayJob live status (the wedge) | — | no K8s RBAC — it is an HTTP call to the dashboard, gated by reachability, not RBAC |
Two boundaries worth stating so they are not conflated:
- The dashboard dial needs no RBAC verb; it needs network reachability
(DirectDial in-cluster vs SPDY PortForward out-of-cluster, Q6). PortForward
itself needs pods/portforward create — but that belongs to the reachability
adapter's RBAC, not distillation's.
- Forbidden during distillation degrades, it does not crash. A missing
events/pods grant must drop the wedge-reason enrichment to "reason
unavailable (RBAC)" and still return phase+counters — the same graceful-degrade
discipline as an unreachable dashboard. It must never turn a get into a raw
Forbidden leak (now centralized in mapDomainError, Candidate 2).
Boot-time scope reconciliation (--allow-all-namespaces vs the SA's actual
cluster-wide list grant) is Q12's SelfSubjectAccessReview, not this note —
distillation assumes the scope is already validated.
5. Concrete examples (the C4 "concrete examples" gap)¶
Each row: raw status in → distilled Health (+ Wedged) out. These double as
golden-test fixtures for the seam.
RayCluster — healthy:
- in: HeadPodReady=True, Provisioned=True, ready/desired = 2/2
- out: Ready; 2/2 workers ready · Wedged=false
RayCluster — wedged on GPU:
- in: Provisioned=False (reason=…), ready/desired = 0/2, pod event
FailedScheduling: 0/3 nodes insufficient nvidia.com/gpu
- out: Provisioning; 0/2 workers ready; unschedulable: insufficient nvidia.com/gpu
· Wedged=true, WedgeReason="unschedulable: insufficient nvidia.com/gpu"
RayJob — not yet scheduled (phase 1, no dial):
- in: status.jobId="", jobDeploymentStatus=Initializing
- out: job not yet scheduled · Wedged=false (no dashboard call made)
RayJob — running (phase 2, dialed):
- in: jobId=raysubmit_abc, dashboard GET /api/jobs/abc → RUNNING
- out: Running · Wedged=false
RayJob — dashboard unreachable (graceful degrade):
- in: jobId=raysubmit_abc, dial → RayAPIUnreachableError("connection refused")
- out: <CRD jobDeploymentStatus>; live Ray detail unavailable: connection refused
· Wedged=false (not knowable; degraded, not failed)
RayService — rollout stuck:
- in: new serve app UNHEALTHY, rollout mid-cutover past threshold
- out: RollingOut; new serve UNHEALTHY: <app> · Wedged=true
6. The seam (the architectural half — Candidate 1)¶
Goal: one navigable home for "how ray-mcp turns Ray status into an agent-actionable line," so a reviewer reads one module, not three; and the RayJob task reuses the shared half instead of re-typing it.
Constraint that fixes the shape: the typed-status reading (KubeRay condition
constants, RayJobStatus) must stay in the adapter — the domain imports no
Kubernetes packages (verified: internal/domain is stdlib-only). So the split is:
- Domain (
internal/domain): owns the vocabulary, not the extraction. - Promote
Wedged bool+WedgeReason stringonto the summary types (§2). - A pure, kind-agnostic health-line composer — the I/O-free
strings.Joinofphase; counts; reason?plus thecondReasonreason-formatting. This is the genuinely reusable core; it imports nothing. - Adapter (
internal/adapters/kuberay/distill.go, new): the shared condition-ladder + reason-extraction helpers currently inline incluster.go, moved to one file, plus one thin per-kind extractor (clusterDistill,jobDistill,serviceDistill) that reads that kind's typed status and calls the domain composer. The per-kind wedge predicate lives with its extractor.
domain: Distilled vocabulary (Phase·Ready/Desired·Wedged·WedgeReason·Health)
+ pure HealthLine composer ──────────────┐ (stdlib only)
│ called by
kuberay/distill.go: shared condition-ladder · condReason · ageGuard
▲ ▲ ▲
clusterDistill jobDistill serviceDistill
(CRD only) (CRD→dashboard, (CRD, rollout)
two-phase+degrade)
Deletion test: delete clusterHealth/healthDetail/condReason today and
the logic reappears in every caller and every future kind → they earn their keep.
The seam's job is to make jobs/services reuse them, not re-grow them.
Discipline guardrails (so this stays honest, not speculative)¶
- The repo's CLAUDE.md forbids abstractions for single-use code. The shared composer is multi-use the moment RayJob lands; this note recommends naming it then, or now-as-part-of-C4 only because the deletion test passes and C4 explicitly asks for it. The risk of naming at n=1 is designing the abstraction against one shape — §3 exists precisely to enumerate all three shapes first, so the composer is shaped by real variation.
- Do not build a kind registry / dispatch table / "distillation DSL." Three named extractors calling one composer is the whole design. One extractor + a hypothetical interface is the anti-pattern to avoid.
- The two-phase RayJob orchestration (CRD → dashboard, graceful degrade) is
fake-testable today against
RayAPIPort/RayReachabilityfakes, before any tunnel code exists (review Candidate 3, testing-strategy §11). Prove the domain wedge logic there.
7. Open questions to resolve when RayJob lands¶
- Wedge thresholds: "too long" for
jobIdempty /Provisionednot True — fixed duration, or surfaced as-is and left to the agent? (Lean: surface the age, no magic timer in v1.) - Where the pod-event enrichment runs: inside
distill, or composed by the service from a separateEventsread? (Lean: service composes — keepsdistilla pure status→line function and the event read independently testable.) Wedgedfor RayService cutover vs. a richer rollout sub-state — does one bool suffice, or does rollout need its own small enum? (Defer to the RayService task; do not pre-model.)