Introduction
The previous article put Genkit flows in Kubernetes pods with senro and let jev, a decision model, route a support ticket. The decisions there were small: which team, how urgent, send or hold.
This one hands jev something bigger to judge: software. A software factory takes a feature request and produces a Go module: a design, a plan, the code, a green build, passing tests and a pull request description. Gemini writes every one of those. jev decides, at every gate, whether the work moves on, goes back, or stops for a human. Each stage runs as a pod, each stage is a Genkit flow, and the whole thing is a few hundred lines of Go.
Everything below ran against the real Gemini API (gemini-3.8-flash) and the real TypeSafe API (jev-latest), on Genkit Go v1.14.0, senro v1.4.5 and a local kind cluster. The code is in genkit-k8s-flows-jev-software-factory.
Why a factory needs a judge, not a second writer
The usual way to review model-written code is to ask another chat model. It works, up to a point. The reviewer writes a paragraph, you parse the paragraph, the paragraph is sometimes wrong in the same way the code is, and a well-written pull request description can talk the reviewer into "looks good to me".
A decision model does not write. It takes a state (the request, the plan, the code, the test log) and answers typed questions with probabilities, in a few hundred milliseconds. There is no prose to parse and nothing to be talked into: BuildGreen is a number, and a red test log drags it down whatever the pull request says.
| Chat model as reviewer | Decision model as reviewer | |
|---|---|---|
| Verdict | A paragraph you parse | Choice[CodeVerdict]: ship, rework or human, with a probability each |
| Evidence | Mentioned, maybe | A Noul per question: covers the request, tests are meaningful, build is green |
| Feedback for the next round | Whatever it felt like writing | Each question that scored "yes" is one line of feedback |
| Latency and cost | Seconds, thousands of output tokens | Milliseconds, tokens for the state only |
| Can be persuaded by the author | Yes | No: it never reads instructions, only state |
The split that falls out of this is the whole design: Gemini builds, jev decides. And senro is what gives each build step a pod, a workspace, a log, a retry policy and a secret, and each decision a place in the graph.
What we are building
A feature request goes in. A reviewed Go module comes out, or a brief for a human saying why not.
The factory is three senro pipelines and a small state machine over them:
| Run | Step | Where | Who | What it leaves behind |
|---|---|---|---|---|
design | clean | coordinator | none | an empty workspace with request.md in it |
design | design | pod | Gemini and jev, in a loop | design.json, DESIGN.md and one record per review round |
design | plan | pod | Gemini | plan.json, PLAN.md |
implement | implement | pod | Gemini | the module's files |
implement | vet, test | pod | go vet, go test | vet.log, test.log and their exit codes |
implement | review | pod | jev | review.json: ship, rework or human |
rework | fix, then vet, test, review | pod | Gemini, Go, jev | the same, after a fix |
finish | pr then publish | pod, coordinator | Gemini | PR.md and the module under out/<request>/src |
finish | brief then notify | pod, coordinator | Gemini | BRIEF.md for a human |
jev decides three times. Inside the design step, it reviews each design Gemini writes and the loop revises until jev says ready, says reject, or three rounds are up. After test, it reviews the code with the logs in hand and picks ship, rework or human. And the state machine in main turns each verdict into the next senro run, with rework bounded at two.
What you need
- Go 1.27 or newer, Docker, kind and
kubectl -
GEMINI_API_KEYandTYPESAFE_API_KEYin your environment - the senro CLI if you want the terminal UI (optional)
go get github.com/firebase/genkit/go@v1.14.0 go get github.com/xavidop/senro@v1.4.5 go get github.com/xavidop/mamori Step 1: a cluster and an image with a Go toolchain
The cluster setup is the one from the previous article: kind, a namespace, a ServiceAccount with the permissions senro's Kubernetes executor needs (including get on pods/exec), and three environment variables, since senro never reads your kubeconfig. The repo's scripts/cluster-up.sh does it and source scripts/cluster-env.sh exports them.
The one difference is the image. A func step needs only sh and tar in the pod, but this factory also runs go vet and go test as plain command steps, so every step uses the official Go image, pinned to its digest as senro requires:
const stepImage = "docker.io/library/golang:1.27-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd" func pods() senro.ExecutorTarget { return k8s.Pod(stepImage, k8s.Namespace("factory"), k8s.Resources( map[string]string{"cpu": "500m", "memory": "512Mi"}, map[string]string{"cpu": "2", "memory": "2Gi"}, )) } Your pipeline binary is still not in that image. senro cross-compiles it and sends it into each pod, exactly as before.
Step 2: the design loop, with jev inside the flow
The first gate is the one that saves the most money: a bad design produces bad code, a green build and a confident pull request. So the design is reviewed before anything is planned, and the review is a loop inside one Genkit flow.
The questions are a Go type. Every yes/no question is one a revision can act on: a "yes" becomes a line of feedback for the next round.
type DesignVerdict string func (DesignVerdict) Criteria() map[DesignVerdict]string { return map[DesignVerdict]string{ "ready": "Complete, unambiguous, scoped to the request, and testable as described", "revise": "The right direction, with gaps a revision can close", "reject": "The request is too vague or too large to design from; a human has to clarify it first", } } type Risk int func (Risk) Levels() []string { return []string{"Low", "Medium", "High"} } type DesignReview struct { Verdict systemonex.Choice[DesignVerdict] `json:"verdict" jsonschema_description:"Is this design ready to implement?"` RequestClear systemonex.Noul `json:"request_clear" jsonschema_description:"Does the request say what to build precisely enough that two engineers would build the same thing?"` ScopeCreep systemonex.Noul `json:"scope_creep" jsonschema_description:"Does the design add things the request did not ask for?"` Underspecified systemonex.Noul `json:"underspecified" jsonschema_description:"Is any component described too loosely to implement without guessing: missing fields, signatures, edge cases or error behaviour?"` MissingTests systemonex.Noul `json:"missing_tests" jsonschema_description:"Does the test plan leave any requirement of the request untested?"` Risk systemonex.Score[Risk] `json:"risk" jsonschema_description:"How risky is implementing this design as written?"` } And the loop. Gemini designs; jev reviews; if the verdict is revise, Gemini gets the previous design plus the feedback and tries again:
f.Design = genkit.DefineFlow(g, "designFeature", func(ctx context.Context, request string) (DesignResult, error) { var res DesignResult feedback := "" for round := 1; round <= maxDesignRounds; round++ { prompt := "Request:\n" + request if round > 1 { prompt += "\n\nYour previous design:\n" + asJSON(res.Design) + "\n\nReviewer feedback:\n" + feedback } d, _, err := genkit.GenerateData[Design](ctx, g, ai.WithModelName("googleai/gemini-3.8-flash"), ai.WithSystem("You are a senior Go engineer writing a short design for exactly what the request asks. "+ "Stay inside the request: no extra features. Components are the Go types and functions you will create. "+ "The test plan names concrete, deterministic test cases. Be brief."), ai.WithPrompt("%s", prompt)) if err != nil { return res, err } res.Design = *d out, resp, err := genkit.GenerateData[DesignReview](ctx, g, ai.WithModelName("typesafe/jev-latest"), ai.WithSystem("The state has a feature request and a design written for it."), ai.WithPromptParts(ai.NewDataPart(map[string]any{"request": request, "design": res.Design}))) if err != nil { return res, err } // jev's verdict, then our rules on top. verdict := string(out.Verdict.Choice) var notes []string if out.RequestClear.Probability < 0.4 { verdict = "reject" // nobody should design from a request two engineers would read differently } if out.ScopeCreep.Probability >= 0.5 { notes = append(notes, "remove everything the request did not ask for") } if out.Underspecified.Probability >= 0.5 { notes = append(notes, "specify every component fully: fields, signatures, edge cases and error behaviour") } if out.MissingTests.Probability >= 0.5 { notes = append(notes, "add a test case for every requirement in the request") } if verdict == "ready" && len(notes) > 0 { verdict = "revise" } res.Rounds = append(res.Rounds, DesignRound{Round: round, Verdict: verdict, Ready: out.Verdict.Probabilities["ready"], Feedback: notes /* ... */}) res.Verdict = verdict if verdict != "revise" { break } feedback = fmt.Sprintf("The reviewer rated the design %q (probability of ready %.2f). Do the following:\n- %s", verdict, out.Verdict.Probabilities["ready"], strings.Join(notes, "\n- ")) } return res, nil }) Two things to notice. The verdict is jev's, but the rules around it are yours: a request that two engineers would read differently is rejected whatever the design looks like, and a design with a flagged problem is not ready even if jev leaned that way. And the feedback is built from the questions, not from prose: a decision model never writes a sentence, so the sentences live in your code, one per question, and the next round gets exactly the ones that applied.
The whole loop is one flow, so it is one senro step, one pod, one log, with every round recorded in design.json.
Step 3: plan, implement, and let Go have its say
The plan and the implementation are ordinary Gemini calls with typed outputs: a Plan with files and steps, then a Code with every file in full. The step writes the files into the workspace, refusing anything that would escape it:
type Code struct { Files []File `json:"files"` Notes string `json:"notes,omitempty"` } func writeCode(ctx senro.Ctx, code Code) error { root := wsPath(ctx, ".") for _, f := range code.Files { rel := filepath.Clean(f.Path) if rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) || strings.HasPrefix(rel, factoryDir) { return fmt.Errorf("refusing to write outside the module: %q", f.Path) } path := filepath.Join(root, rel) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } if err := os.WriteFile(path, []byte(f.Content), 0o644); err != nil { return err } fmt.Fprintf(ctx.Stdout(), "wrote %s (%d bytes)\n", rel, len(f.Content)) } return nil } Then the compiler and the tests run, as command steps in the same pod image, with no model anywhere near them:
func goStep(w *senro.WorkflowBuilder, id, after, cmd string, ws *senro.WorkspaceRef) *senro.StepBuilder { script := "mkdir -p .factory; " + cmd + " > .factory/" + id + ".log 2>&1; echo $? > .factory/" + id + ".exit; " + "cat .factory/" + id + ".log; exit $(cat .factory/" + id + ".exit)" return w.Step(id, exec.Command("sh", "-c", script)). Needs(after). Mount(ws.At("/repo", senro.RW)). WorkDir("/repo"). Env("HOME", "/tmp"). Env("GOCACHE", "/tmp/gocache"). Env("GOFLAGS", "-mod=mod"). Env("GOPROXY", "off"). Env("GOTOOLCHAIN", "local"). Env("CGO_ENABLED", "0"). Timeout(stepTimeout). Retry(3, retry.OnInfra()). ContinueOnError() } goStep(code, "vet", "implement", "go vet ./...", ws) goStep(code, "test", "vet", "go test -count=1 ./...", ws) Each one leaves its output and exit code under .factory/ in the workspace, so the reviewer can read them. GOPROXY=off and GOTOOLCHAIN=local keep the pod honest: the request said standard library only, and the pod cannot download anything to pretend otherwise. And ContinueOnError is the important one: a red test is a failed step, but the review still runs. jev gets to see why the build failed, which is exactly the evidence a rework needs.
Step 4: the code review gate
The second gate reads everything a human reviewer would open: the request, the plan, every source file, and what go vet and go test printed.
type CodeVerdict string func (CodeVerdict) Criteria() map[CodeVerdict]string { return map[CodeVerdict]string{ "ship": "Builds, tests pass, does what the request asked, no obvious defects", "rework": "Fixable problems: failing or missing tests, a missed requirement, sloppy code", "human": "Something a model should not decide: security, data loss, or the request was misunderstood", } } type Quality int func (Quality) Levels() []string { return []string{"Poor", "Acceptable", "Good", "Excellent"} } type CodeReview struct { Verdict systemonex.Choice[CodeVerdict] `json:"verdict" jsonschema_description:"Should this change ship?"` Quality systemonex.Score[Quality] `json:"quality" jsonschema_description:"How good is the code?"` CoversRequest systemonex.Noul `json:"covers_request" jsonschema_description:"Does the implementation do everything the request asked for?"` TestsMeaningful systemonex.Noul `json:"tests_meaningful" jsonschema_description:"Do the tests exercise the behaviour the request cares about, rather than just compile?"` BuildGreen systemonex.Noul `json:"build_green" jsonschema_description:"According to the logs and exit codes, did go vet and go test both pass?"` } The flow sends the whole ReviewInput as a data part, reads the answers, and applies the factory's rules on top. A red build never ships, whatever the overall verdict:
f.Review = genkit.DefineFlow(g, "reviewChange", func(ctx context.Context, in ReviewInput) (Review, error) { out, resp, err := genkit.GenerateData[CodeReview](ctx, g, ai.WithModelName("typesafe/jev-latest"), ai.WithSystem("The state has the feature request, the implementation plan, every source file, "+ "and the output and exit codes of go vet and go test. Exit code 0 means success."), ai.WithPromptParts(ai.NewDataPart(in))) if err != nil { return Review{}, err } r := Review{ Verdict: string(out.Verdict.Choice), Confidence: out.Verdict.Confidence, Quality: out.Quality.Label(), CoversRequest: out.CoversRequest.Probability, TestsMeaningful: out.TestsMeaningful.Probability, BuildGreen: out.BuildGreen.Probability, Model: systemonex.ResponseInfo(resp).Model, } if in.VetExit != 0 { r.Reasons = append(r.Reasons, "go vet failed") } if in.TestExit != 0 { r.Reasons = append(r.Reasons, "go test failed") } if r.CoversRequest < 0.6 { r.Reasons = append(r.Reasons, "the implementation does not cover the request") } if r.TestsMeaningful < 0.5 { r.Reasons = append(r.Reasons, "the tests do not exercise the requested behaviour") } if out.Quality.Level() == 0 { r.Reasons = append(r.Reasons, "code quality is poor") } if r.Verdict == "ship" && len(r.Reasons) > 0 { r.Verdict = "rework" } return r, nil }) The Reasons are what the fix flow gets, together with the logs. Again: the decision model decides, your code says it in words.
Step 5: three pipelines and a state machine
The module under construction lives in a persistent workspace, so every run of one request works on the same tree. senro insists a persistent workspace is bounded, and refuses one without both bounds:
func repo() *senro.WorkspaceRef { return senro.Source:https://dev.to/xavidop/a-software-factory-on-kubernetes-gemini-builds-jev-decides-genkit-senro-in-go-47pRelated posts

FreeRDP 3.33 Adds WebAssembly Support, Experimental UDP Connections
A Tiny Script to Log Evening Network Quality at Home
Expressões Cron sem mistério: Como montar agendamentos sem errar na produção

From Zero to Live: Containerise a Python App, Deploy It to Azure Container Apps and Automate It with GitHub Actions



