I am watching an AI agent rewrite my database schema in real time.
It types a description into the tool loop. "Add a phone field to Contact, indexed, optional." A second later it has emitted a block of DSL. The validator rejects it. Strict-mode Cedar finds an authorization policy that referenced the old shape and now points at a field that does not exist. The agent reads the error, edits its output, submits again. This time validation passes. The diff engine computes a migration plan, classifies the step as safe, applies it to the running database, and the new policy bundle swaps in behind it. The binary did not restart. I did not write a migration. The previous schema is still in memory, ready to take over if anything had gone wrong.
This is SchemaForge. I have been building it for a few months, and I am only now putting words to what it is, because the words are old ones.
What I built is an Adaptive Object Model. Foote and Yoder described the pattern in 1998. I read it around 2000 and used it to build an XML-driven enterprise system on VB and COM+: schemas in XML, generated three-tier architecture, business rules in metadata. Then I moved on, and the pattern faded out of my working memory the way most patterns do, replaced by whatever was in front of me at the time. Halfway through the SchemaForge implementation, it came floating back to the front of my mind. Re-finding a 25-year-old name on the thing you are sweating over a quarter-century after you first used it is a particular kind of feeling, and it is what I want to write about.
What the pattern actually says
The argument is simple to state. Encode the structure of your domain (entities, attributes, relationships, rules, constraints) as data the running system reads, instead of code the compiler compiles. Then provide enough metadata-aware machinery (generic editors, generic persistence, generic UI) to make that data useful without writing new code for each new entity. Schemas in tables. Rules in tables. Layouts in tables. The system reads its own shape every time it answers a request.
The promise was that business analysts could shape the system without programmers in the loop. Add a new field, define a new entity type, change a validation rule, all by editing metadata instead of filing a ticket. The canonical citations land in finance, insurance, and healthcare. Domains that shift slowly but unpredictably, where the gap between "we need to track this new thing" and "the system tracks it" was the whole business problem.
The pattern receded for a few reasons. The analysts who were supposed to wield the metadata mostly did not want to. When they did, the systems they touched became opaque. Behavior lived in database rows. Debugging meant joining six tables to figure out what the runtime was going to do. Onboarding required holding both the metadata model and the code that interpreted it in your head at once. Implementations often degenerated into stringly-typed JSON soup with no validation, which delivered the worst of both worlds. And the economic premise (rebuild-and-deploy is too slow for our pace of change) turned out not to apply to most teams.
So the pattern went somewhere quieter. It kept working where it had always worked, in the corners of enterprise software where domain volatility was real. It mostly stopped being a thing fashionable software people built. I forgot about it. Most of the industry did too.
The thing agents broke
I have written a lot of code that treats the schema as a fact about the world. You define a model, you generate types, migrations, an ORM layer, API handlers, UI. The chain runs at build time. The artifact is a binary that knows its tables the way a person knows their own birthday.
That arrangement works as long as humans are the ones changing the schema. Humans change schemas rarely. A schema change is a meeting, a PR, a review, a migration plan, a deploy window. Build-time codegen is fine when the build runs once a week.
Agents change schemas constantly.
I am not speaking abstractly. The agent I run against SchemaForge takes a natural language description, generates DSL, watches it validate or fail, self-corrects, applies it to the running database. In a productive session it will iterate twenty times in an hour. Each iteration would, in a traditional Rust service, mean: regenerate code, rerun cargo build, run migrations, restart the binary, replay state. Even at the most optimistic twenty seconds per cycle, that is a closed feedback loop measured in minutes. Most of the time spent waiting on rustc, not on the agent.
The economics flip. When the loop is fast, the agent is useful. When the loop is slow, the agent is theater.
So the chain has to go. The schema cannot be code that gets compiled. It has to be data that gets loaded.
This is the move that makes Airtable, Notion, and every low-code platform look the way they do. It is also the move that historically produced some of the worst software ever shipped: opaque rule engines, BPM systems where the business logic lives in a database row no one can read, "configurable" platforms that became unmaintainable in three quarters.
The question is not "should the schema be data." For agent-iterated systems, that question is answered. The question is how to do it without inheriting the curse.
A schema that is data
Here is what a schema looks like in SchemaForge, verbatim from the repo:
schema Contact {
name: text(max: 255) required indexed
email: text(max: 512) required indexed
phone: text
priority: enum("low", "medium", "high") default("medium")
company: -> Company
tags: text[]
notes: richtext
is_active: boolean default(true)
}
That is the entire definition. From that text, SchemaForge generates a Postgres table, a REST CRUD surface, an OpenAPI document, a React admin view, a set of Cedar authorization policies, and the migration to move existing data forward. All of it at runtime, in the running process, without cargo build.
The parser lives in crates/schema-forge-dsl/src/parser.rs. It is logos-based, about 2,800 lines, and it produces a typed AST. text(max: 255) is not a string. It is a FieldType::Text { max: Some(255) }. -> Company is not magic. It is a FieldType::Reference("Company") that the type checker resolves against the rest of the schema set.
That distinction matters more than it looks. Most low-code platforms expose configuration as untyped JSON. A field is {"type": "text", "max": "255"} where "255" is a string and the consumer is supposed to coerce it. Validation happens, when it happens at all, at the point where the configuration is used, often deep in a request handler. By that time the bad config has been sitting in the database for months. You discover it the day a customer's webhook fires at 3 AM.
A typed DSL with a real parser is what separates this from stringly-typed chaos. The schema is data, yes. But the data has a grammar, and the grammar is checked before anything downstream sees it.
This is the same instinct as the constraint design I wrote about earlier: make the wrong shape unrepresentable instead of detectable.
The DSL has one reader
The standard objection to a custom DSL is that humans have to learn it. Every new grammar is a tax on every new developer. Project teams have spent decades shrinking config languages to YAML and JSON for exactly this reason. The cost of expressiveness is the cost of teaching.
That trade-off changes when the author is an agent.
The SchemaForge DSL is not designed to be ergonomic for humans. It is designed to compose cleanly into the generated layers below it. A human can read it (the syntax is plain enough), but no human has to write it. The natural-language interface is for the human. The agent reads the description, holds the whole grammar in its context, emits the DSL, submits it to the validator. If something is wrong, the validator says so and the agent edits.
That changes what a grammar can afford to be. When humans hold the grammar, you keep it small to lower the learning cost. When agents hold the grammar, size is approximately free. The whole thing arrives in the context every time. No version skew between the docs and what the agent remembers, because the agent does not remember.
So the DSL can be exactly as constrained as it needs to be without paying a human-ergonomics tax for the constraint.
Two generative passes
The DSL is deliberately small. It expresses what an entity is, what fields it has, what is required, what is indexed, what references what. It does not express arbitrary logic. There is no place in the schema to say "validate that the phone number matches the customer's country code" or "after this entity is created, push a copy into the search index."
Hooks are the escape hatch.
Hooks are not new. PayloadCMS, Strapi, Hasura, and most modern headless CMSs treat lifecycle hooks as a first-class injection point: a place where arbitrary code runs against entity events the framework knows about. The shape is familiar. What SchemaForge changes is who writes them and what the code-generation contract gives them to work from.
A schema annotated with @hook(before_change) triggers a second round of code generation. SchemaForge emits a typed gRPC service whose proto request fields match the schema fields exactly, a Rust stub for that service, and a .prompt.md file alongside the stub. The prompt file describes the hook's intent (taken from the annotation), its full signature, every field the request carries, and a "Done when" checklist. The same agent that wrote the schema reads the prompt and implements the stub.
That is the second generative pass. The first pass produces the schema. The second pass produces the code that runs against it. Both are AI-authored, and both are typed against the same source of truth.
The shape of the second pass matters. The agent is not writing a service from scratch. Almost everything in a hook is generated boilerplate: the proto, the service trait, the dispatch wiring, the request and response types. What the agent has to produce is the body of each method — the small piece of logic that actually does the work. That keeps the context the agent needs to hold small. The surface where the agent can be wrong is small. And the compiler is checking the rest, so the parts the agent did not author are consistent across every hook in the project.
At runtime, the dispatcher loads a FileDescriptorSet and resolves service and method descriptors through prost_reflect. Schemas without hooks pay nothing. The read-side dispatcher early-exits on a per-event check. The hook is not a parallel system that has to be kept in sync with the schema. It is the schema, projected into a typed RPC surface, with stub code the agent can fill in directly.
This is what closes the gap the bounded DSL leaves open. The schema layer stays small because arbitrary logic does not have to live in it. Hooks handle the rest, and they are generated from the same definition.
The foundation that does not move
Before the validation gate makes sense, I have to mention the part of the system that I did not have to build.
SchemaForge sits on acton-service, a Rust backend framework I maintain. It ships with the boring pieces compiled in: type-enforced API versioning, dual HTTP and gRPC on the same port, OpenTelemetry tracing and metrics, structured logging, circuit-breaker and retry resilience, Kubernetes health and readiness probes, Cedar-based authorization. None of that mutates at runtime. None of that needs to.
SchemaForge is the layer above. The schema mutates. The generated tables, handlers, OpenAPI document, admin UI, and authorization policies all mutate with it. The foundation underneath stays put: request routing, observability, versioning, the authorization engine itself, all compiled in.
That split is not incidental. It is the only way the runtime-mutability story works. If everything could change, nothing about the system would be reliable. Pinning the service skeleton lets the schema move freely, because the agent is not editing how requests are observed or how policies are evaluated. It is editing what entities exist and which policies apply.
The validation gate
The DSL parser catching shape errors is the easy half. The hard half is making sure that when a new schema lands, three things stay in sync: the database, the generated handlers and admin UI, and the authorization policies. The schema change has to survive being applied to all three before it goes live.
A diff engine handles the database. DiffEngine::diff is a pure function that takes the old and new schema definitions and produces a migration plan: an ordered list of steps, each classified by safety. Adding a nullable column is Safe. Renaming a field with an explicit hint is Safe. Changing a type, removing a field, dropping a unique constraint: those land as RequiresConfirmation or Destructive, and the request is rejected unless the caller has explicitly accepted the risk. The migration runs against the live database. If the backend rejects it, the schema change is rolled back before any policy is recompiled.
Authorization is the hardest of the three to keep in sync, so it is where the gate is sharpest. The authorization engine comes from acton-service. It is Cedar in strict mode, and SchemaForge feeds it a generated policy bundle every time the schema changes.
When a recompile is requested, PolicyStore::recompile_from_schemas regenerates the schema source from the new DSL, regenerates per-schema and per-field and global policies, merges any hand-written extensions, and validates the result against the schema. The validation call is at crates/schema-forge-acton/src/authz/store.rs:184-192:
let validator = Validator::new(schema.clone());
let result = validator.validate(&policy_set, ValidationMode::Strict);
if !result.validation_passed() {
return Err(combined_errors_and_warnings(result));
}
Strict mode is where the real work happens. It rejects policies that reference entity types that do not exist, attributes that do not exist, action shapes that do not match. A schema change that drops a field fails validation if any policy still mentions that field. The bundle never loads. The error comes back to the tool loop, which reads it and tries again.
Two details I keep wanting to point at.
Warnings are errors. At store.rs:172-177, schema warnings are treated as hard failures. There is no permissive mode and no "log it and continue." The bundle either passes cleanly or it does not load.
The old bundle keeps serving. PolicyStore wraps a snapshot in arc_swap::ArcSwap at store.rs:66-115. A failed recompile does not mutate the live snapshot. Requests in flight do not see a half-built state. The test at store.rs:347-385 exists specifically to verify this: point recompile at a custom-policy directory whose source is broken, and the store keeps serving the old bundle. There is no window where the database has the new shape but the authorization layer has not caught up.
This is what the agent iterates against. Not a permissive runtime that accepts whatever it emits. A gate that says no, here is what is wrong, try again.
What makes this different from low-code
If I described SchemaForge to someone who has worked with low-code platforms, the immediate reaction is some version of "you have built Airtable in Rust." That is the first comparison I want to push back on, because it misses where the safety comes from.
Airtable's data model is data. So is Notion's. So is every BPM system, every CMS, every "no-code" platform. They have all made the same move I made: the schema is configuration, not code. The difference is not the move. The difference is what stops a bad configuration from going live.
In most of these platforms, the answer is: nothing, until someone notices. The configuration is JSON. The validation is whatever the form in the admin UI does. Cross-cutting concerns (does this new field break that automation, does this permission still make sense given the schema change) are not checked. They are discovered.
In SchemaForge, the answer is: a typed parser, then strict-mode Cedar across the entire policy bundle, then an atomic swap that either succeeds completely or rejects completely. The schema is data. The data is typed. The data is checked against the whole system before it loads. If it does not pass, the system keeps running on the previous version and the change is rejected with a structured error.
That is the part low-code platforms skipped, and it is why they earned the reputation they have.
Trade-offs
There are real costs.
You inherit acton-service's opinions. Versioned APIs, dual HTTP and gRPC, Cedar for authorization, OpenTelemetry for observability, XDG-compliant config. If those match what you would have picked anyway, you get them for free. If they do not, you are fighting the foundation. An agent-mutable schema is not a good enough reason to adopt a framework whose service skeleton you would not have picked otherwise.
Debugging is harder when generation produces something wrong. A failed recompile gives you a clean error. A live request that hits a generated handler and behaves oddly is a longer trace. The generated layer is well-tested, but it is still a layer between you and the data, and when something goes sideways there the stack trace points at the runtime, not at the schema. I have spent evenings in that gap.
The whole design assumes a capable agent. The usual cognitive-load argument against custom DSLs does not apply here, because the DSL is for the agent. But that load has to land somewhere, and where it lands is in the agent's ability to read errors and iterate. If your agent cannot close the loop, you fall back to writing DSL by hand. That works, but it is slower than writing migrations against a normal Rust service, and you have given up the main reason to adopt the pattern.
I think these costs are worth paying for the agent-iteration case. I do not think they are worth paying for a CRUD app with a stable schema and three developers. The Adaptive Object Model is overengineering until the rate of schema change rises past some threshold. The threshold is moving, and agents are the reason it is moving, but it has not moved for everyone.
What transfers
You may not be building SchemaForge. You may not need a runtime schema at all. The thing I want to leave you with is not the implementation. It is the shape of the problem.
If your system has a piece that agents iterate on faster than humans can review, that piece should be data. Build-time codegen will become the bottleneck. The compile is a tax on every loop the agent runs.
If a piece of your system is data that agents touch, it needs a typed grammar. Not JSON. Not YAML with conventions. A real parser producing a real typed AST that the rest of the system reads. The grammar does not have to be small or ergonomic. The agent is the reader. It does have to compose cleanly into whatever it generates, because that is what the validator is going to check.
If you have a typed grammar, you need a strict-mode validator that sees the whole bundle. Schemas, generated artifacts, hand-written extensions, authorization policies. All of it has to compose before any of it goes live. Partial validation is worse than no validation, because it builds false confidence.
If you have a strict validator, you need an atomic swap. The previous version has to keep serving until the new version has fully passed. ArcSwap is the Rust answer but the principle is older than Rust. No window of partial state. No request that sees half the new world. Pass completely or do not pass.
And underneath all three, you need a foundation that does not move. The service skeleton, the observability layer, the authorization engine, the request lifecycle. Pin them. Mutability has to be a layer, not a property of the whole system. The reason this pattern got its bad reputation is that the platforms that adopted it tried to make everything configurable. That is how you end up debugging a request that has been routed by a database row. Decide what mutates and what does not, and let the agent edit only the part you have made safe to edit.
Drop any one of those four and you have the thing the pattern got its bad reputation for.
Foote and Yoder described this pattern for a world where business analysts wanted to edit rules without filing a ticket. That world mostly chose to file the ticket instead, and the pattern receded into the corners of the industry where it had always lived (and into the corners of my memory, where it lived too). Agents are not analysts. They iterate at a rate that makes the build-time chain a liability. The pattern fits the conditions again.
The code is at github.com/Govcraft/schemaforge. Signed prebuilt binaries for PostgreSQL and SurrealDB backends are on the releases page; each release carries a Sigstore keyless signature you can verify with cosign against the Rekor transparency log, so you can run a SchemaForge instance without compiling anything. The repo also ships a standards-compliant agent skill, validatable against the agent-skills spec, that any compatible runner can pick up. Bring your own: Claude Code, Codex, Hermes, or acton-ai. Point it at the skill, hand it a natural-language description, and let it work the loop. If you want to understand the shape, read the recompile test in store.rs and the policy generation in the backend crate; you will see it in about an hour.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.