4.0. Type Safety
In one glance
- You will: Watch a hostile tool argument die at the boundary that owns it, then trace the same discipline through configuration and structured answers.
- You need:
mise run installdone. No cluster, container, or account; a local model only for the optional evalset run. - Time: about 18 minutes, concept.
How one tool boundary answers three untrusted arguments
Parse, do not validate means checking a value once, while constructing a narrower type. Every function downstream then trusts that type instead of re-checking the string. The discipline exists because the compiler proves that a string is a string and nothing about what it means. A model produces probabilistic text, an environment variable is whatever the operator typed, and a database row can be stale. An unparsed value therefore travels through the call graph until something dangerous uses it, and fails far from the boundary that admitted it.
This page shows where the reference agent narrows each external value, why one id parser is strict and another forgiving, and how the discipline runs before the process serves a turn.
Start with the boundary you can drive by hand. get_incident takes an incident_id typed by a language model, so the string can be a valid id, a padded lower-case id, an id for an incident that does not exist, or something that was never an id at all. Serve the reads over HTTP with mise run mcp:http from agents/go, then send from a second terminal the same tools/call request you used in 3.3. MCP, changing only the argument:
curl -sN -X POST http://127.0.0.1:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_incident","arguments":{"incident_id":"INC-2; DROP TABLE incidents"}}}' \
| sed -n 's/^data: //p' | jq -c '.result.structuredContent'Each block below is one such call’s structuredContent, MCP’s structured result field, indented for reading and otherwise unchanged:
{ "error": "Invalid incident id \"INC-2; DROP TABLE incidents\"; expected an id like INC-002." }{ "error": "No incident found with id \"INC-999\"." }The third argument was " inc-002 " — padded, lower-cased, the sort of thing a small model emits when it is paraphrasing rather than copying. Predict what comes back, then note the order that decides it: normalization runs before the shape check, so the id is trimmed and upper-cased and only then judged.
{
"incident": {
"id": "INC-002",
"opened_at": "2026-07-05T09:02:00Z",
"resolved_at": null,
"runbook": "service-down",
"service": "inventory",
"severity": "SEV1",
"status": "open",
"summary": "Inventory pods are crash-looping; stock lookups return HTTP 503.",
"title": "Inventory service unavailable"
}
}Three arguments, three different outcomes, and none of them is a database error, a stack trace, or a leaked file path.
Parse at the edge, then trust the type
Go’s compiler already proves a great deal before the binary starts. Values, methods, interfaces, and returns have to fit, so an entire family of missing-method and wrong-shape failures never reaches runtime. Prove it over every package whenever you doubt it:
cd agents/go
go build ./...
go test ./config ./domain ./tools -count=1What compilation cannot prove is that the string in a string means anything, and decoding JSON into a struct moves that uncertainty around rather than removing it. The sequence that removes it looks like this:
untrusted bytes -> strict decode -> normalize -> typed domain value -> operationEach stage is a separate job. A strict decode refuses bytes carrying a field the struct does not declare rather than dropping it silently; normalization turns a plausible variant into the one canonical form; the constructor at the end returns a typed value or an error, never a half-checked string.
domain.IncidentID exists precisely so that no function downstream has to re-ask whether an id is shaped like an id. The only ways to obtain one are ParseIncidentID, which is strict because the seed — the dataset committed with the repository — must already store canonical rows, and NormalizeIncidentID, which trims and upper-cases first because " inc-002 " is a plausible thing for a model to emit and worth accepting.
All three captures above came through the forgiving door: runGetIncident calls NormalizeIncidentID and nothing else. That is why the first and third differ: the injection-shaped string had no canonical form to be made into. The strict door is the one the seed’s own rows come through, in the record decoder and the report decoder, and neither capture went near it. The second, INC-999, failed no door at all: it is a perfectly good identifier with no row behind it, which deserves a different message than a malformed one.
Each boundary owns its own parser, and each fails at its own edge with a message naming the field or invariant it protects:
| Boundary | Untrusted input | Trusted result |
|---|---|---|
| Environment | Strings and missing values | config.Config with typed providers and durations |
| Model tool call | JSON arguments | Typed argument struct plus normalized domain ids |
| A2A and REST | Protocol JSON and metadata | Validated messages, tasks, and turns |
| SQLite | Rows and nullable columns | Domain records or an explicit error |
| Evaluation artifact | Captured protocol events | Typed evals.Turn and sanitized result schema |
Reach for a typed enum when the valid set is closed — provider, entrypoint, severity, incident status, A2A transport. Reach for a parsing constructor when a string has to satisfy a structure, such as an INC-123 id or a kebab-case service slug. Reach for a struct when fields belong together and a half-built value would be ambiguous, and keep credentials in a wrapper whose ordinary formatting is already masked.
The A2A and REST row covers the surfaces another process drives rather than a model. The last row is where the discipline pays off twice. When the agent is asked for a structured triage report, the evaluation harness — the offline grader that scores recorded answers — decodes it with unknown fields refused, insists on exactly one JSON value, and then validates incident, severity, service, runbook, evidence, and action fields. It does that against its own evals.TriageReport type, not the agent’s — the harness deliberately does not import agents/go. If both sides shared a type, one coordinated rename would let producer and grader agree on a regression without either of them noticing.
How the agent asks for a typed answer, and what it does when one does not arrive
Typed output is the other direction of the same discipline: not “parse what arrived”, but “declare what must arrive”. ADK takes a JSON schema on the agent config, and the whole request is three lines:
func (c *Compose) reportConfig() (llmagent.Config, error) {
schema, err := TriageReportSchema()
if err != nil {
return llmagent.Config{}, err
}
cfg := c.baseConfig(ReportAgentName, ReportDescription, ReportInstruction)
cfg.Tools = []tool.Tool{c.tools.GetIncident, c.tools.SearchServiceLogs, c.tools.GetRunbook}
cfg.OutputSchema = schema
return cfg, nil
}OutputSchema does not turn the agent into a formatter that has lost its tools. ADK injects a synthetic set_model_response tool carrying the schema, so the model keeps calling get_incident, search_service_logs, and get_runbook as it works, and delivers its final answer by calling that one instead of writing prose. The typed answer and the tool loop are the same turn.
A schema is a request, though, and a 4B model is not obliged to honour it. RequestTriageReport in the same file says what happens then, in a policy worth copying: parse; on failure, retry once, feeding the validation errors back verbatim so the second attempt is told which field was wrong; on a second failure, stop. Exactly one retry, never more — a model that cannot produce the schema twice will not produce it on the third attempt, and an unbounded loop turns a formatting problem into a cost problem. The degraded result is then honest about itself: it carries the prose, sets Degraded, increments agentops.triage_report.schema_failures, and logs a warning naming the incident. That counter is what AgentTriageSchemaFailures in 7.2b. Alerting watches, which is how a prompt edit that silently drops a required field becomes a page rather than a mystery.
Score it the same way you score anything else. The evaluation harness carries a dedicated evalset for this agent, and one flag turns schema validation into a failing score rather than a warning:
cd evals
mise run eval -- --evalset triage-report.evalset.json --app-name triage_report_agent --required-cases "" --require-schemaWhy configuration is parsed before the process starts
A tool argument arrives mid-turn. Configuration arrives before there is a turn at all, which makes it the cheapest place in the system to be strict: a refusal here costs an exit code, and an acceptance costs a whole run. Environment tags declare the names, defaults, and field types:
Entrypoint Entrypoint `env:"AGENT_ENTRYPOINT" envDefault:"agent"`
ModelProvider ModelProvider `env:"AGENT_MODEL_PROVIDER" envDefault:"openai-compatible"`
Model string `env:"AGENT_MODEL" envDefault:"qwen3:4b-instruct"`
// "openai-compatible" describes the ADK client contract, not the deployment
// topology. Point this URL directly at Ollama for the account-free first
// run, or at agentgateway when the governed data plane is introduced.
OpenAIBaseURL string `env:"OPENAI_BASE_URL" envDefault:"http://127.0.0.1:11434/v1"`
OpenAIAPIKey Secret `env:"OPENAI_API_KEY" envDefault:"local-ollama"`
// Optional Gemini paths: an AI Studio API key, or Enterprise/Vertex via ADC
// with an explicit project and location.
GoogleAPIKey Secret `env:"GOOGLE_API_KEY"`
GoogleCloudProject string `env:"GOOGLE_CLOUD_PROJECT"`
GoogleCloudLocation string `env:"GOOGLE_CLOUD_LOCATION"`Those are field-level parses: Entrypoint and ModelProvider are typed enums, and OpenAIAPIKey is a Secret, the masking wrapper described above. But some combinations parse perfectly and still cannot work — a Gemini provider with no credential of any kind, for instance — so a second pass compares fields against each other:
func (c Config) providerProblems() []Problem {
var found problems
// Any value at all — including "false" and the empty string — is a migration
// signal. Someone who wrote this variable is following stale instructions and
// must be told so, never handed a silent default.
if c.DeprecatedGatewayEnabled != nil {
found.addCrossField(
"AGENT_GATEWAY_ENABLED was removed. Keep AGENT_MODEL_PROVIDER=openai-compatible " +
"and select direct Ollama or agentgateway with OPENAI_BASE_URL " +
"(http://127.0.0.1:11434/v1 or http://127.0.0.1:4000/v1).",
)
}
if c.ModelProvider == ProviderOpenAICompatible && c.OpenAIBaseURL == "" {
found.addCrossField(
"AGENT_MODEL_PROVIDER=openai-compatible requires OPENAI_BASE_URL. Use " +
"http://127.0.0.1:11434/v1 for direct Ollama or http://127.0.0.1:4000/v1 " +
"for the host agentgateway model route.",
)
}
if c.ModelProvider == ProviderOpenAICompatible && strings.TrimSpace(c.OpenAIAPIKey.Reveal()) == "" {
found.addCrossField(
"AGENT_MODEL_PROVIDER=openai-compatible requires OPENAI_API_KEY. Ollama and the open " +
"local gateway accept a non-secret marker such as local-ollama.",
)
}
googleAPIKey := strings.TrimSpace(c.GoogleAPIKey.Reveal())
if c.ModelProvider == ProviderGemini {
switch {
case googleAPIKey != "" && c.GoogleGenAIUseEnterprise:
found.addCrossField(
"AGENT_MODEL_PROVIDER=gemini cannot combine GOOGLE_API_KEY with " +
"GOOGLE_GENAI_USE_ENTERPRISE=true in this course. Choose AI Studio API-key auth " +
"or the ADC-backed enterprise path.",
)
case c.GoogleGenAIUseEnterprise:
var missing []string
for _, required := range []struct{ variable, value string }{
{EnvGoogleCloudProject, c.GoogleCloudProject},
{EnvGoogleCloudLocation, c.GoogleCloudLocation},
} {
if strings.TrimSpace(required.value) == "" {
missing = append(missing, required.variable)
}
}
if len(missing) > 0 {
found.addCrossField(
"AGENT_MODEL_PROVIDER=gemini with GOOGLE_GENAI_USE_ENTERPRISE=true requires %s "+
"for the ADC-backed course path.", strings.Join(missing, " and "),
)
}
case googleAPIKey == "":
found.addCrossField(
"AGENT_MODEL_PROVIDER=gemini requires either GOOGLE_API_KEY for AI Studio, or " +
"GOOGLE_GENAI_USE_ENTERPRISE=true with GOOGLE_CLOUD_PROJECT and " +
"GOOGLE_CLOUD_LOCATION for ADC.",
)
}
}
return found
}The failures do not report “invalid configuration”; they name the variable, the combination, and the two ways out of it. Here is that machinery firing with two unrelated mistakes made at once:
$ AGENT_ENTRYPOINT=triage AGENT_MAX_RETRIES=-1 mise run config:check
[config:check] $ go run ./cmd/agent config:check
Agent configuration is invalid:
- AGENT_ENTRYPOINT: must be one of agent, workflow, coordinator; got "triage"
- AGENT_MAX_RETRIES: must be between 0 and 10, got -1
exit status 1
[config:check] ERROR task failed
Both problems in one report, before a model client, a database handle, or a listener exists. A validator that stopped at the first error would have sent you round the loop twice. Run the same command with nothing overridden and it prints every resolved setting with secrets masked, which is the fastest way to answer “what does this process actually think it is configured to do”.
After config.Load succeeds, callers receive one value and thread it explicitly into the model, server, policy, and tool constructors. There is no mutable package-level settings singleton to mutate from a test or a goroutine.
Your turn: make the configuration boundary report two classes of failure
This costs nothing and touches no file.
- Mode:
inspect— every change lives on one command line and disappears with the shell. - Goal: make the configuration boundary report two different classes of failure, and predict each message before you read it.
- Files to touch: none. Do not edit
.env; a variable in front of the command overrides it for that run only. - Preflight: from
agents/go, runmise run config:checkwith nothing overridden and confirm it prints the resolved settings and exits zero. - Steps: run
AGENT_ENTRYPOINT=triage mise run config:check, thenAGENT_MODEL_PROVIDER=gemini mise run config:check. Before each one, write down which variable the message will name and whether it will name a second. - Gate that proves completion: the first run names
AGENT_ENTRYPOINTand lists the three entrypoints it accepts; the second namesAGENT_MODEL_PROVIDER=geminiand both of its credential routes. Each exits non-zero and starts nothing. - Final state: no files changed, and a plain
mise run config:checkis valid again.
The second run is the interesting failure. Nothing about AGENT_MODEL_PROVIDER=gemini is malformed, so a per-field validator would accept it; only a rule that reads two fields at once can reject it.
What you can do now
- You can predict which message an injection-shaped id, a padded
inc-002, and an absentINC-999each return, and why none leaks internals. - You can name the parser that narrows each external value, and say why
ParseIncidentIDis strict whereNormalizeIncidentIDis forgiving. - You can say why only a cross-field rule rejects
AGENT_MODEL_PROVIDER=gemini, and why both failures print before any runtime exists. - You can name one property the compiler cannot prove — that an answer is true, that a tool call was well timed, or that the right human approved it — and say which kind of evidence would settle it instead (0.2. Evidence).
Continue to 4.1. Linting, where a second class of defect — the one that compiles perfectly — gets caught by a machine instead of a reviewer.