DEV Community

Cover image for Designing Schema Boundaries for AI Agents
Takafumi Endo
Takafumi Endo

Posted on

Designing Schema Boundaries for AI Agents

An agent can now change your whole persistence stack in one pass.

Ask Claude Code or Codex to "add a plan_tier to accounts and wire it through," and it will read the repo, edit the Drizzle schema, generate a SQL migration, update the dbt source, touch a model, regenerate the TypeScript types, add a test, run the build, read the failure, and fix it. Multi-file, coherent, green. A year ago that was an afternoon; now, in a well-shaped repo, it's a prompt.

For a while I read that as pure upside — schema changes stopped being bottlenecked on how fast a human can hand-write a migration. Now I think the upside came with a quieter inversion: the same speed that makes good changes cheap makes unsafe changes cheap too. And the unsafe ones are exactly the kind that pass review, because they look consistent.

I've written before about handing agents short, verified facts instead of asking them to be careful. This is the same argument aimed at the database — the place where a confident wrong move is most expensive. It's a working theory, not a universal rule.

Where I've landed for now (and expect to keep revising):

  • Agents making schema changes cheap is good. It's not the problem.
  • The problem is that the change is cheap while the verification boundary is still shaped for the human-migration era.
  • The dangerous change isn't the one that fails to build — it's the one that builds fine and quietly breaks a contract downstream.
  • What's missing isn't a better prompt or a stricter reviewer. It's a compiler that lowers every declaration onto one contract graph and judges the change against it.

One definition up front, so the rest reads cleanly: by contract compiler I mean a tool that reads every declaration of persistence shape — ORM schema, SQL DDL, dbt artifacts, object manifests, SDK types — lowers them into one graph, and evaluates a change against it as a set of operational hazards. Not a migration runner, not a codegen step. A judge.

The new reality: the agent writes the schema

It's worth being concrete about how wide the blast radius of a single "small" schema change already is. When an agent touches one column, the coherent version of that change spans:

Drizzle / Prisma / SQL DDL  →  migration plan     →  dbt source
        ↓                            ↓                    ↓
   app types / SDK          backward compat?         dbt models
        ↓                            ↓                    ↓
   API contract             RLS / tenant guard      Parquet export → S3/R2
Enter fullscreen mode Exit fullscreen mode

(I write migration plan rather than migration on purpose: whether you're SQL-first or ORM-first, the migration is a derivative of a desired state, and it's the desired state the graph should be built from.)

An agent will happily update all of those in one commit, and it will make them look mutually consistent. That's the impressive part. It's also the trap: internal consistency across the files it edited says nothing about consistency with the things it didn't — the live database, last week's published artifact, the SDK a client already pinned, the PII policy that lives outside the repo.

So the interesting question stopped being "can the agent implement the change?" It obviously can. The question is: who verifies the contract graph the change lives in?

The hazard is the green build

Here's the shape of the risk, because it's not the one people reach for first. The scary agent change isn't the one that throws a compiler error — you'll catch that. It's the one where everything is green and the damage is semantic:

  • nullable quietly tightened, so old rows now violate a constraint that only bites on the next write.
  • a type narrowed — textvarchar(32) — that truncates in production and nowhere else.
  • an enum or code value removed or remapped, so historical rows decode or compare differently.
  • a rename modeled as drop-plus-add, which reads to every downstream consumer as data loss.
  • a PII column newly exposed to an analytics model and, three hops later, sitting in a Parquet export.
  • the dbt source left stale, so the model builds against a schema that no longer exists.
  • a dataset's Parquet schema changed without a version bump, over-writing current so yesterday's reader silently gets today's shape.

Many of these slip straight through a CI that only asks whether the SQL parses and the build passes. Wire in dbt contracts, source tests, migration lint, Atlas, or integration tests and some of them get caught — but the default "does it build" pipeline doesn't ask whether the change is destructive, backward-incompatible, or contract-breaking. In the human era that gap was survivable — changes were slow, and a reviewer's eye covered a lot of it. Speed removes the slack. When agents can produce these changes repeatedly, "a human will notice" stops being a control.

The failure I most want to avoid is the one that reads as safety. A green build on a breaking change is exactly that.

The lightweight stack is great — and it doesn't close the loop

I want to be fair to the tools this sits on, because I like them a lot.

dbt is genuinely good at the analytics side of this. Model contracts let you declare the shape a model must return — the column names and types — and at least the build fails when the logic drifts from that shape. (How far the constraints are actually enforced depends on the data platform, but as an analytics-output contract it's strong.) dbt-duckdb lets you run the whole DAG against an in-memory DuckDB inside a CI job, and its external materialization exports CSV/JSON/Parquet straight to a location you choose. Pair that with S3/R2 and you get a lightweight publish layer: compile the transformation, test it locally, publish the result, no long-running warehouse required.

The important detail isn't that the file lands in a bucket. It's that the artifact becomes another contract surface: a schema hash, a versioned path, a manifest, and a current pointer that consumers may already be reading. Overwrite that pointer with a new shape and you haven't "updated a file" — you've changed a contract other systems depend on, silently. Hold that thought; it's where a hazard I'll later call UNTRACKED_LATEST_OVERWRITE comes from — a name from this article's own vocabulary, not a standard.

But dbt's contract is an analytics-output contract. dbt can declare sources, test them, and watch their freshness — but that isn't ownership of the ops database's lifecycle. Indexes, constraints, RLS, triggers, extensions, tenant boundaries, PII classification, the app's ORM types — all of that lives upstream of the source block, managed by Drizzle, Prisma, Atlas, or hand-written DDL. dbt sees a projection of it and trusts the projection.

So the loop isn't closed. The analytics contract depends on an ops contract that nothing verifies it against — and the agent is now editing both ends at once.

What's missing is a harness

We already accepted this idea on the agent side. Nobody runs a raw model against a shell; you wrap it in a harness — the loop that orchestrates tools, prompts, permissions, and execution. OpenAI has used the language of an agent loop and harness around Codex CLI.

My claim is small: schema changes need a harness for the same reason agent loops do — an untrusted author producing fast, plausible actions against a system where mistakes are expensive. The author being a model rather than a human doesn't change the requirement; it sharpens it.

The shape I keep arriving at is four roles:

Author     human or agent writes desired state
           SQL DDL · Drizzle · Prisma · Atlas HCL · dbt SQL + YAML
              │
Observer   reads live state
           DB introspection · dbt artifacts · S3/R2 manifests · registry · pointers
              │
Compiler   lowers everything into one IR
           table · column · constraint · source · model · artifact · policy
              │
Evaluator  turns diffs into decisions
           safe · warning · blocked · requires-human
Enter fullscreen mode Exit fullscreen mode

The Author is deliberately untrusted. I don't care whether a person or a model wrote the migration — I care what the Compiler and Evaluator say about it. That single stance is what makes the whole thing agent-ready: you don't have to trust the writer if you can judge the write.

Which is exactly why the Observer matters as much as the Author. The live database and the authored declaration have to be independent inputs. If the compiler only reads the migration the agent produced, it's grading the agent's story about the world — not the world. The whole point is to check the write against a state the writer didn't get to narrate.

Lower to an IR, don't diff strings

The declarations are all different renderings — SQL DDL, TypeScript, a Prisma schema, HCL, dbt's SQL + YAML + manifest.json, a DuckDB catalog, a Parquet schema, an object key. Compare any two as text and it breaks on the first reformat. So the Compiler lowers each into a common intermediate representation and compares that:

// illustrative, not a real schema — the point is which fields exist, not the format
type Column = {
  name: string
  type: string
  nullable: boolean
  semantic?: string      // 'email' | 'money' | 'tenant_id' | …
  sensitivity?: string   // 'public' … 'restricted'
  pii?: boolean
  lineage?: Ref[]        // where it came from, where it flows
}
Enter fullscreen mode Exit fullscreen mode

A table carries analogous facts (keys, constraints, indexes, access policies); an analytics model carries its dependencies, its output columns, and how it materializes. The exact shape isn't the interesting part — what matters is that ops objects, dbt models, and published artifacts all land in one vocabulary the evaluator can reason over.

The point that took me a while: the IR is a unit of judgment, not an output. Types, validators, SDK clients, sources.yml, Parquet schemas, policy bundles — those are all derived from it. dbt's manifest.json is, in effect, the analytics-side IR you already have; the missing half is lowering the ops side into the same shape so the two can be compared at all. Whatever the agent writes, in whatever format, ends up flowing through one evaluator.

Hazards, not diffs

A diff tells you what changed. It can't tell you whether that's safe — and safe/unsafe is the only thing a gate actually needs. So the Evaluator turns a diff into a classified hazard with its blast radius attached, and emits it as structured data, because the readers are CI and an agent as much as a person. Something along these lines — the exact fields matter less than the idea:

{
  "status": "blocked",
  "hazards": [
    {
      "type": "COLUMN_DROP",
      "severity": "critical",
      "object": "ops.users.email",
      "affected": [
        "dbt.source.raw_users",
        "dbt.model.mart_users",
        "sdk.User.email",
        "artifact.customer_segments.v1"
      ],
      "reason": "column is still consumed by downstream models"
    }
  ],
  "nextAction": {
    "kind": "expand_contract",
    "requiresHumanDecision": true
  }
}
Enter fullscreen mode Exit fullscreen mode

CI's question stops being "is there a diff" and becomes "what's the highest severity, and does anything here need a human." The vocabulary of hazards is finite and worth naming, though I won't try to enumerate it here — a column drop that's still consumed downstream, a type narrowing, an enum remapped, PII that reaches an export without a policy, a published artifact whose schema changed under a live pointer. They group loosely by where they originate: the ops schema, the analytics models, or the published artifacts. (The names I give them across this post are my own shorthand, a design vocabulary for the argument, not a standard.) Let the agent implement. Don't let it past the Evaluator.

Make the Boundary Machine-Readable

Everything above works with a human author too. Here's what changes when the author is a model — and it's the part I find most underrated. Give the agent the contract, not vibes.

If the harness can judge a change, it can also tell the agent the rules before the change. Instead of a paragraph of "please be careful, watch for breaking changes," you give it something machine-readable: which classes of change are pre-approved (adding a nullable column, an index, a test), which are off-limits without a deprecation path (dropping a column, exposing PII without a policy, overwriting a live pointer), and what the migration policy is (breaking changes go through expand–contract; a dataset shape change forces a version bump). The specific encoding doesn't matter much; treating the boundary as data the agent can read does.

Now the instruction isn't "fix it nicely." It's: change this so no hazard exceeds warning; if a critical is unavoidable, don't implement — return a plan. The agent iterates against structured feedback (a hazard verdict) instead of against a green checkmark that means nothing. Breaking changes come back as expand–contract plans, not as a DROP COLUMN that happened to compile.

This is the same thing I keep chasing in every one of these posts. The reason I stopped tacking "don't guess, verify, watch for false negatives" onto every prompt isn't that I started trusting the agent more. It's that the facts I hand it — and now the actions I allow it — carry their own guarantees. When "you may not expose PII without a policy" is a blocked action and a blocking hazard, I don't have to ask the agent to respect it.

Which points at the shift underneath all of this. The scarce skill is no longer writing the migration — an agent will write ten before lunch. The scarce skill is defining the contract that decides whether a migration is safe. That's the part that doesn't get cheaper when the model gets better; if anything it gets more valuable.

A few patterns fall out of this once the boundary is a compiler rather than a reviewer's attention — which is the point where "what does this look like in practice" deserves its own section.

What I would put in CI

None of this is real until it's a gate. What I want is for the harness to run like any other CI check — except that the question it asks is a different one. The loop, end to end:

AI agent
  │ proposes change
  ▼
Author files
  Drizzle · Prisma · SQL · dbt · SDK
  │
  ▼
Contract Compiler
  ops IR · analytics IR · artifact IR
  │
  ▼
Hazard Evaluator
  safe · warning · blocked · requires-human
  │
  ├── safe ──────────────▶ merge
  ├── warning ───────────▶ review
  └── blocked ───────────▶ agent gets structured feedback,
                            returns a plan instead of a change
Enter fullscreen mode Exit fullscreen mode

The principle, not a spec: the gate compiles the proposed state and the live state into the same IR — independently — joins the dbt and artifact sides onto that graph, and returns a hazard verdict rather than a build result. It blocks on critical, routes the ambiguous cases to a human, and lets the safe majority through untouched. The mechanics of how you introspect the database or where you read the last published manifest are the boring, swappable part; the load-bearing idea is that CI evaluates a graph, not a syntax tree.

A few principles keep the gate calm to live with rather than something people route around: begin from a plan, not a diff; don't let the analytics source definitions and the ops schema drift into two hand-maintained copies of the same truth; and treat a published artifact as versioned and its live pointer as a deliberate move, not an overwrite. How you implement each is a taste question; that you don't skip them isn't.

The shift in one line: CI stops asking whether the migration builds, and starts asking whether the contract graph is still safe.

Objections I'd raise myself

  • "This is just linting / dbt contracts / Atlas." Those exist and they're good — Atlas flags destructive ops changes, dbt contracts pin analytics output. What none of them does alone is carry a pii label from an ops column to a Parquet export, or block a publish on that basis. The gap is the edge between the tools, which is precisely where an agent editing both ends creates trouble.
  • "Why not just give the agent more context?" Because the missing thing isn't context volume, it's a decision boundary. More repo text doesn't tell the agent whether a column drop is safe — the answer isn't in the files it's reading; it's in the graph across them. A contract graph can decide it; a bigger window just lets the agent read more of the question.
  • "You're just distrusting the model." The opposite. Distrust would be forbidding agents from touching schema. This lets them touch everything — and makes the boundary a compiler instead of my nerves. I'd apply the same harness to myself.
  • "Over-engineering for a three-table app." Yes. Below some size, a human reading the diff is the right control. The harness earns its keep exactly when change volume outruns human attention — which is the situation agents create.
  • "Hazard rules will have false positives." They will, and a conservative gate that occasionally blocks a safe change is a trade I'll take over one that occasionally ships a silent breaking one. But "tune toward missing fewer real hazards" isn't enough on its own — a gate that cries wolf gets muted, and a muted gate is worse than none. So the real requirement is that false positives be explicit, suppressible with evidence, and tracked as policy exceptions rather than quietly tolerated. An override should leave a record, not a shrug.

Where this is going

I came in through "agents make schema changes cheap" and walked out at something more structural: SQL DDL, an ORM schema, a dbt model, a Parquet schema, an S3 manifest, and an SDK type are all renderings of one contract — and in the agent era, the author of every one of those can be a model moving fast. Manage them as separate files reviewed by tiring humans and the speed becomes a liability. Lower them to one IR, judge changes as hazards, and hand the agent the boundary as context — and the speed goes back to being the gift it was supposed to be.

I hold the strong version loosely. The four-layer split and the IR-first stance feel more right the more I sit with them; the exact hazard taxonomy and how far to automate the boundary versus leave it to review are edges I'm still finding. And maybe for an analytics-first shop the honest answer is still a warehouse and shared Postgres with a careful reviewer, and this is a monorepo-shaped preference more than a law.

But the direction feels clear enough to design toward. Let the agents write the schema — the migrations, the models, the types, all of it. Just don't let the safety of it rest on how the diff happens to look on a given afternoon.

Agents should write schema changes. Whether a schema change is safe should be decided by a contract compiler — not by vibes, and not by whoever happens to be reviewing at 6pm.

Top comments (0)