Keeping AI Agents from Writing Wrong Data: Validation, Deterministic Checks and Fallbacks in Go
An agent that answers a question wrongly is embarrassing. An agent that writes wrong data, such as moving the wrong ticket, booking the wrong slot or saving a made-up value, is a production incident. And unlike a bad answer, bad data stays around and spreads to every system that reads it.
The approach that has worked for me across Go agent projects is one rule: the model proposes, the code decides. The LLM can suggest an action. Deterministic Go code checks it, applies it, records it, or refuses it. This post covers the layers that implement that rule. The examples are simplified versions of patterns from my own projects, including a Jira-style product where a Gemini assistant changes project data only through typed backend tools, and a restaurant recommendation API where safety filters run before the LLM sees anything.
Layer 1: no free-form writes
The first decision is architectural. The agent should never produce SQL, raw API payloads or "update these fields" maps. It calls narrow, typed operations that your domain layer already supports.
// Bad: the model controls what gets written. // update_record(table string, id string, fields map[string]any) // Good: one intent, typed and checkable. type AssignIssue struct { IssueKey string `json:"issue_key"` Assignee string `json:"assignee_email"` } type SetDueDate struct { IssueKey string `json:"issue_key"` Due string `json:"due_date"` // YYYY-MM-DD } Each struct maps to one function in your service layer, the same one your REST API calls, with the same permission checks. This is how the assistant in my sprint-management product works: it acts on project data through typed backend tools, not by writing to the database directly.
Layer 2: strict decoding
Model output is untrusted input. Decode it like you'd decode a request from the public internet:
func decodeStrict[T any](raw []byte) (T, error) { var v T dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&v); err != nil { return v, fmt.Errorf("decode: %w", err) } if dec.More() { return v, errors.New("decode: trailing data after JSON object") } return v, nil } DisallowUnknownFields catches the model inventing a field like priority_level that you'd otherwise drop silently. The More() check catches responses with extra content after the JSON.
Layer 3: domain validation
Valid JSON isn't valid data. Put the business rules on the type itself, so every caller, agent or human, goes through them:
type Validator interface{ Validate() error } var issueKeyRE = regexp.MustCompile(`^[A-Z][A-Z0-9]+-[0-9]+$`) func (c SetDueDate) Validate() error { if !issueKeyRE.MatchString(c.IssueKey) { return fmt.Errorf("issue_key %q must look like ABC-123", c.IssueKey) } d, err := time.Parse(time.DateOnly, c.Due) if err != nil { return fmt.Errorf("due_date %q must be YYYY-MM-DD", c.Due) } today := time.Now().UTC().Truncate(24 * time.Hour) if d.Before(today) { return fmt.Errorf("due_date %s is in the past", c.Due) } if d.After(today.AddDate(2, 0, 0)) { return fmt.Errorf("due_date %s is more than 2 years away; confirm with the user", c.Due) } return nil } Write the messages for the model. When validation fails inside an agent loop, the error goes back as a tool result, and a specific message ("must be YYYY-MM-DD") lets the model fix the call instead of guessing again.
Layer 4: reference checks against real data
Hallucinated identifiers are the most common way agents write wrong data. The model "remembers" an issue key, a user email or a product ID that doesn't exist, or belongs to someone else. Format checks won't catch it. Only your database can.
func (s *Service) CheckRefs(ctx context.Context, u User, c AssignIssue) error { issue, err := s.issues.Get(ctx, c.IssueKey) if errors.Is(err, ErrNotFound) { return fmt.Errorf("issue %s does not exist; call search_issues to find the right key", c.IssueKey) } if err != nil { return err } if !s.authz.Can(u, "issue:update", issue.ProjectID) { return fmt.Errorf("you cannot modify issues in project %s", issue.ProjectKey) } if ok, err := s.projects.IsMember(ctx, issue.ProjectID, c.Assignee); err != nil { return err } else if !ok { return fmt.Errorf("%s is not a member of project %s", c.Assignee, issue.ProjectKey) } return nil } The same idea works for read paths. In a RAG system, the IDs in the model's answer can be checked against the set of candidates that were actually retrieved and sent to it. Anything outside that set is a hallucination by definition.
Layer 5: plan, then apply
For anything with more than one write, split the work into two phases. The agent builds a plan. Code validates the whole plan, shows it to a human or policy check if needed, and only then applies it in a single transaction.
type Plan struct { ID string Actions []Action // each Action wraps a validated, typed command } func (s *Service) Apply(ctx context.Context, u User, p Plan) error { for i, a := range p.Actions { if err := a.Validate(); err != nil { return fmt.Errorf("action %d: %w", i, err) } if err := a.CheckRefs(ctx, s, u); err != nil { return fmt.Errorf("action %d: %w", i, err) } } return s.db.InTx(ctx, func(tx Tx) error { for _, a := range p.Actions { if err := a.Apply(ctx, tx); err != nil { return err // rolls back everything } } return s.audit.Record(ctx, tx, u, p) }) } Validate everything before writing anything. A plan where step 3 is invalid should leave no trace of steps 1 and 2.
Layer 6: idempotency
Agents retry. Networks retry. Sometimes the model simply repeats a tool call. Without idempotency, "create a task" twice means two tasks.
func idempotencyKey(runID string, a Action) string { b, _ := json.Marshal(a) sum := sha256.Sum256(append([]byte(runID+":"), b...)) return hex.EncodeToString(sum[:]) } Store the key with a unique constraint and treat a duplicate as "already done", returning the original result. Scoping the key to the agent run means a genuine second request in a new conversation still works.
Layer 7: audit everything the agent writes
Record who asked, which run, which tool, the arguments, the before and after values, and the model used. When something looks wrong a week later, you need to answer "did the agent do this, and why?" in minutes. It also gives you the data to undo it.
Layer 8: fallbacks that are safe by construction
When the model is down, slow, or keeps failing validation, the agent needs a defined way out. The important property: the fallback must not skip the checks.
func (s *Service) Handle(ctx context.Context, u User, req Request) (Result, error) { plan, err := s.agent.Propose(ctx, req) if err != nil { // Model unavailable: do the safe, deterministic subset, or hand off. return s.deterministicPath(ctx, u, req) } if err := s.Apply(ctx, u, plan); err != nil { return Result{Status: "needs_review", Reason: err.Error()}, nil } return Result{Status: "applied", PlanID: plan.ID}, nil } In the Restaurant Suggestion System, the fallback is vector-only search when the LLM is unavailable. Run that path through the same allergen and health filters, so users still get safe, if less conversational, recommendations. In a write workflow, the fallback is often "queue it for a human" rather than "try again with a weaker check".
Testing the guardrails
The guardrails are deterministic Go, so test them like any other code. Table tests cover the cases that matter: hallucinated IDs, past dates, unknown fields, duplicate calls, a plan with one bad action, permission failures. Then add a few end-to-end runs with a scripted fake model that deliberately sends bad tool calls, and assert that nothing reaches the database.
Takeaways
- Never let the model write free-form; give it narrow, typed operations.
- Decode strictly, validate in the domain, and check references against real data.
- Plan, then apply in one transaction; make writes idempotent; audit them.
- Make the fallback at least as safe as the main path.
None of this is AI-specific. It's the same input validation and transactional discipline good backends always needed, applied to a new and very confident source of input.
Pradip Parmar is a Go backend and AI agent engineer from Surat, India, who has been writing Go since 2018. He designs LLM agents, MCP servers and RAG systems for businesses, with a focus on guardrails, typed tool calls and graceful fallbacks. Need a Go AI agent developer? Reach Pradip Parmar on LinkedIn, Upwork or Contra. Code: GitHub.