The Missing Layer: Ontology as an Intermediate Representation for AI-Assisted Development

  • ai
  • software

There's a pattern I keep running into when working with AI coding agents. You write careful requirements, maybe even detailed specs, hand them to the agent, and get back code that's plausible but wrong. Not wrong in a way that fails immediately: wrong in a way that subtly misunderstands your domain. The naming is off. The relationships between entities don't quite match what you meant. The edge cases reveal assumptions you never made. The code compiles, the tests pass, and the architecture slowly drifts from what you actually need.

The untethered workflow is fast, but you pay for the speed on the back end, spending twice the tokens getting the agent to track down its own mistakes. In regulated environments (manufacturing, medical devices, finance) "plausible but wrong" is a compliance failure, not an inconvenience. And anyone trying to automate QA hits the same wall even outside regulated industries: you can't test what you haven't defined, and you can't define what you haven't modeled.

So here is the claim, stated carefully. Requirements tell an agent what a system should do, but they leave implicit what kinds of things exist, how those things relate, and which distinctions matter. Human developers reconstruct that model informally as they read. Coding agents fill the gaps with patterns learned from other systems, which is to say from other domains than yours. What AI-assisted development needs, I think, is an explicit intermediate representation of the domain: formal enough to query, validate, and generate tests from, paired with natural language so that intent and context survive. I've been using OWL for that layer. The argument doesn't depend on OWL.

What Is Ontology, Anyway?

The word comes from philosophy, where it names the study of what exists; Aristotle's Categories was already an attempt to enumerate the kinds of things there are and how they relate. Computer science borrowed the term in the early 1990s, and Tom Gruber's definition is still the useful one: an ontology is "a formal, explicit specification of a shared conceptualization." Formal: semantics precise enough to reason over mechanically. Explicit: concepts, relationships, and constraints stated rather than implied. Shared: usable as common ground by multiple agents, human or artificial. A conceptualization: a model of the domain rather than the software. It describes what things are before anything describes what the system does.

If this sounds like Domain-Driven Design, you're not wrong. DDD's ubiquitous language is doing ontological work, and good DDD practice makes the shared model explicit through conversation, diagrams, bounded contexts, and code. What DDD doesn't prescribe is a machine-reasonable representation of that model: a form you can query for gaps, check for certain classes of contradiction, or slice into an agent's context window as structured data. That last step is what this essay is about.

A terminological caution, because "ontology" has a way of absorbing every good idea standing near it. A typed schema, a decision table, an access-control matrix, a state machine, model-based test generation, a traceability graph: each is valuable on its own, none requires OWL, and none automatically deserves the name. The distinctive claim here is narrower. A single semantic model, explicit enough to support inference and generation, can unify those otherwise separate artifacts — schema, permission matrix, generated tests, and agent context derived from one source instead of maintained as five.

About the notation. I've been writing this layer in OWL, in Turtle syntax, and I should be upfront that I don't care about the Semantic Web. The grand public-web vision never became the dominant architecture of the web, and plenty of engineers will see Turtle and file this under academic solutions looking for problems. But the representation languages that era produced are quietly load-bearing in knowledge graphs, the life sciences, and enterprise data integration, and they may have found another tractable use: intermediate representations for agents working in bounded domains. If you can get the same properties from TypeScript types or JSON Schema, the argument survives. The point is the formal model, not the file format.

Here's a small fragment:

pd:Snapshot a owl:Class ;
    rdfs:comment "The fundamental unit — an immutable record at a point in time." .

pd:Stage a owl:Class ;
    rdfs:comment "Lifecycle phase: Part → Engineering → Manufacturing → AsBuilt." .

pd:atStage a owl:ObjectProperty ;
    rdfs:domain pd:Snapshot ;
    rdfs:range pd:Stage .

pd:Snapshot rdfs:subClassOf [
    a owl:Restriction ;
    owl:onProperty pd:atStage ;
    owl:qualifiedCardinality "1"^^xsd:nonNegativeInteger ;
    owl:onClass pd:Stage
] .

Even without knowing OWL you can read most of this: there's a thing called a Snapshot, a thing called a Stage, and — this is what the restriction block encodes — every Snapshot exists at exactly one Stage. The restriction matters. An rdfs:comment that says "exactly one" is documentation; the cardinality axiom is a commitment a reasoner can check. It's easy to write a vocabulary with good intentions in the comments and call it an ontology. The formal claims have to actually be encoded, or the machine is reading a wish.

The Gap Between Requirements and Understanding

Here's the problem with feeding an agent a list of requirements: requirements describe behavior, not structure. Consider:

DOCS-035: The Version operation SHALL create a new Document on the same DocumentRoot,
          incrementing the version number by 1.

This says what should happen. It doesn't say what a Document is, what a DocumentRoot is, how they relate, or what "version" means in this domain as opposed to any other. A skilled human developer fills those gaps through context, experience, and conversation: they read the requirements, form a mental model of the domain, and write code that reflects the model. The mental model is the real source of truth. The requirements are its shadow.

You can't rely on an agent to reconstruct that model from underspecified requirements. Whatever internal representations a language model does or doesn't form, its output is acutely sensitive to how much of the relevant structure is explicit in context; absent that structure, the gaps get filled with patterns from whatever your vocabulary resembles in training data. If your domain matches common patterns (a CRUD app, a blog), the defaults work. If it has its own internal logic, they will be subtly and persistently wrong.

This is why better prompting has diminishing returns. The instructions aren't the bottleneck. The missing structure is.

A Case Study: 549 Requirements and No Domain Model

I've been building a platform called Primer that helps small manufacturers manage product data: parts, bills of materials, document control, engineering changes, approvals. The domain is deceptively complex. Six user roles in a strict hierarchy. Four lifecycle stages that progressively freeze design decisions. Multiple states per stage. Approval workflows that vary by document category. A composition model where assemblies reference sub-assemblies in a directed acyclic graph.

I should be honest about something: I'm not a manufacturing PLM expert. The domain model didn't come from years of shop-floor experience. It came from pointing Claude Code at a legacy Quickbase application — a bunch of tables and some JavaScript — and asking it to reverse-engineer the domain: analyzing existing data structures, identifying entities and relationships, asking clarifying questions. This matters for the argument, and I'll come back to it.

My first approach to formalizing requirements was the obvious one: hand-author them as markdown tables, organized by domain.

| ID       | Requirement                                              | Refs              |
|----------|----------------------------------------------------------|-------------------|
| DOCS-008 | The standard documentNumber formula SHALL be:             | (doc-0015,        |
|          | {prefix}-{rootSeq:4}-{revision}.{version:2}              |  doc-0038)        |
| DOCS-010 | The rootSeq SHALL be a zero-padded 4-digit integer,       | (doc-0015)        |
|          | unique within the Category and Tenant.                    |                   |

Eleven domain files, 549 individual SHALL statements, each with a manually assigned ID and hand-written cross-references. Traceability was manual at every level — a developer read the requirement, wrote a test with the ID embedded in its name, wrote the code, and mentally tracked the mapping:

// Unit test — requirement ID embedded in the test name
test("DOCS-008: standard format is {prefix}-{rootSeq:4}-{revision}.{version:2}", () => {
  expect(formatDocumentNumber("010", 100, "A", 1)).toBe("010-0100-A.01");
});
# E2E test — requirement ID as a Gherkin tag
@DOCS-026
Scenario: Effective button visible only for Working documents
  Given I am viewing a Part in "Working" state
  Then I should see the "Effective" action button

This worked well enough to build a functioning system. As it grew, the cracks became structural.

Gap analysis was a heuristic, not a proof. Finding untested requirements meant diffing IDs against test files. A requirement could appear "covered" by a test that mentioned the ID in its name without actually verifying the behavior.

The negative space was invisible. Requirements described what the system SHALL do. They rarely described what it SHALL NOT do. "Administrator can approve documents" didn't generate "viewer cannot approve documents." With six roles and dozens of actions, the negative permission space is enormous, and it went untested unless someone thought to write each case.

Requirements didn't compose. Each statement was isolated. There was no way to say "this pattern applies to all entities with lifecycle state" and have it propagate. A new entity type meant rewriting every relevant requirement by analogy and hoping nothing was missed.

Contradictions were undetectable. Across eleven files, no tooling checked whether one requirement's grant conflicted with another's precondition.

The domain model was implicit. Concepts like "a Part is a 1:1 extension of a Document" lived as natural language scattered across twenty-plus design documents. When an agent tried to work with the requirements, none of that structure was available to it.

The underlying issue: the requirement space was enumerable but unenumerated. The dimensions existed — roles, stages, states, actions, entity types — but no one had built the full matrix.

The Ontology Layer

The fix was to make the domain model formal. Not as documentation: as a machine-readable artifact that could be queried, validated against, and injected into an agent's context as structured data.

Layer 1: Domain — What the System Is

The most consequential modeling decision in the project: in Primer's model, parts, engineering BOMs, manufacturing BOMs, and as-built records are all represented as the same kind of thing — immutable snapshots in a directed acyclic graph — at different lifecycle stages. I wouldn't defend that as a universal PLM truth; in plenty of manufacturing practice a part, a BOM, and an as-built configuration are importantly different entities. For this system, though, treating them as stage-specific views of one Snapshot abstraction meant the state machine, derivation model, and permission system could each be defined once and applied uniformly.

pd:Derivation a owl:Class ;
    rdfs:comment "How one Snapshot begets another." .

pd:DerivationType a owl:Class ;
    owl:oneOf (pd:Version pd:Revision pd:Clone pd:StageFork) .

pd:source a owl:ObjectProperty ;
    rdfs:domain pd:Derivation ;
    rdfs:range pd:Snapshot .

pd:target a owl:ObjectProperty ;
    rdfs:domain pd:Derivation ;
    rdfs:range pd:Snapshot .

All operations are variations of "fork a frozen snapshot":

Operation What changes What stays
Version version +1 same root, same revision, same stage
Revision next revision letter, version resets same root, same stage
Clone new root, new number same stage
StageFork new root, new number, next stage provenance link back

Rules like "the source of a derivation must be Frozen" don't live in the OWL fragment above, and that's worth saying plainly: domain and range declarations type a relationship's participants; they don't enforce state preconditions. In the working system those rules live in a validation layer generated alongside the code, with the ontology supplying the vocabulary they're written in.

Roles and permissions get the same treatment:

pd:Role a owl:Class ;
    owl:oneOf (pd:viewer pd:participant pd:bom_participant
               pd:bom_super_user pd:administrator pd:system_admin) .

pd:Interaction a owl:Class ;
    rdfs:comment "A possible user action — one cell in the permission matrix." .

pd:hasRole    a owl:ObjectProperty ; rdfs:domain pd:Interaction ; rdfs:range pd:Role .
pd:hasAction  a owl:ObjectProperty ; rdfs:domain pd:Interaction ; rdfs:range pd:ActionType .
pd:atStage    a owl:ObjectProperty ; rdfs:domain pd:Interaction ; rdfs:range pd:Stage .
pd:inState    a owl:ObjectProperty ; rdfs:domain pd:Interaction ; rdfs:range pd:LifecycleState .

Layer 2: Requirements Meta-Model — What a Requirement Is

Instead of writing requirements one at a time, you define patterns that generate requirements when instantiated against the domain model:

pr:RequirementPattern a owl:Class ;
    rdfs:comment "Template for generating SHALL statements." .

pr:generatedBy a owl:ObjectProperty ;
    rdfs:domain pr:Requirement ;
    rdfs:range pr:RequirementPattern .

A pattern like "for each Stage, every Snapshot SHALL have a valid state machine" isn't one requirement. It generates one for every Stage in Layer 1, and adding a Stage produces the new requirements automatically.

The interaction matrix is the most practically useful construct in the project. An Interaction is a cell in:

Role × TargetType × Stage × State × Action

Each valid cell yields a positive requirement, each invalid cell a negative one, and every cell needs a test. One caveat for readers who know this stack: OWL is open-world, so "not declared permitted" is not, to a reasoner, evidence of "prohibited." The closed-world reading (this cell is absent from the permitted set, therefore forbidden) happens in the generation layer — application code in my case, though SHACL or a rules engine would serve. OWL supplies the vocabulary and the dimensions; the enumeration and the negation are done on top of it.

I'll also be the first to admit that the combinatorial explosion looks like garbage at first glance. Six roles times four stages times four states times fifteen actions is a lot of cells, and nobody wants to read that many requirements. But that's the point: the generated requirements are an intermediate artifact, not a deliverable. They exist to be distilled into integration tests. The human reads the ontology and the tests. The matrix is for the machine.

Gap analysis becomes a query:

SELECT ?interaction WHERE {
  ?interaction a pd:Interaction .
  FILTER NOT EXISTS {
    ?req pr:covers ?interaction .
    ?test pr:verifies ?req .
  }
}

That query returns every interaction lacking an asserted requirement-and-test chain. Note the limits: it doesn't distinguish "no requirement" from "requirement with no test," and it can't tell you whether a test that claims to verify a requirement actually exercises the behavior. The graph makes asserted traceability queryable; it doesn't make asserted coverage true. What you gain over 549 markdown rows and grep is that the question becomes precise enough to ask.

Layer 3: Instances

The existing 549 requirements get parsed into individuals, linked to domain concepts through formal bindings, and regenerated whenever the domain model changes. Requirements that were free-floating strings become nodes in a queryable graph.

The New Development Pipeline

With the ontology in place, the workflow becomes:

Rough Requirements → Ontology → Refined Requirements → Specs → Tests → Code

A single requirement now lives at three layers, and it's worth seeing all three, because the formal one doesn't replace the human one:

HUMAN-FACING REQUIREMENT
"A BOM super user may create an Engineering-stage snapshot
 from a Frozen Part-stage snapshot."

FORMAL BINDING
pd:Interaction ;
    pd:hasRole      pd:bom_super_user ;
    pd:hasAction    pd:StageFork ;
    pd:sourceStage  pd:Part ;
    pd:sourceState  pd:Frozen ;
    pd:targetStage  pd:Engineering ;
    pd:permitted    true .

GENERATED TEST DIMENSIONS
Role: bom_super_user | Operation: StageFork
Source: Part/Frozen  | Target: Engineering
Expected: Permit     (+ sibling cells → Deny cases)

The prose stays, for people. The binding disambiguates it for the machine: a StageFork derivation, Part to Engineering, source Frozen, role bom_super_user, and the sibling cells of the matrix say who can't. That removes several of the most consequential structural ambiguities, though not all of them: transaction boundaries, concurrency, and error semantics still live downstream, and an agent can still get them wrong.

Why Structured + Unstructured Input Matters

Give a model only natural language and it fills structural gaps with whatever is statistically likely. Give it only formal structure and it fills intent gaps with generic assumptions. Give it both and the structured input narrows what's valid while the unstructured input says what's wanted. This resembles a recurring pattern across schema-guided generation and retrieval systems — models do better when natural-language context is paired with explicit structure — though I'm arguing from analogy here, not citing a benchmark.

The ontology's other job in context assembly is humbler than "compression," the word I used to reach for. An identifier like pd:Snapshot doesn't carry its semantics into the model by magic; it's an address, a stable handle by which the relevant definitions, constraints, and neighbors can be retrieved and injected on demand, so each task gets the slice of the domain it needs. Each artifact in the pipeline narrows the space the next is generated in, and each is a checkpoint where a wrong turn is cheaper to catch than in code review.

Eliciting the Model Without Delegating It

The obvious objection to this workflow is that it requires a unicorn: someone who knows the domain deeply enough to build an ontology and the engineering deeply enough to use one. In most organizations those are different people.

What the Primer experience suggests is that the elicitation can be assisted. The ontology wasn't hand-crafted from manufacturing experience; an agent derived it from the legacy application's tables and code, asking clarifying questions along the way, and my job was to review and correct rather than author. The step I'm working on now moves the input from code artifacts to conversation: a domain expert describes their work, the model conducts the requirements gathering, and the formal layer comes out the other side without the expert ever seeing Turtle. If that holds up it would take a real bite out of knowledge capture, one of the expensive parts of any compliance pipeline — but whether conversational elicitation can meet compliance-grade rigor has to be demonstrated, and I haven't demonstrated it.

What can't be delegated is authority over the result. Everything downstream inherits the domain model's structure; model two things as one, or collapse a distinction that matters, and the requirements describe the wrong things, the tests validate the wrong behavior, and the code is confidently, systematically incorrect. The human doesn't need to write the model. The human needs to read it and be entitled to say "yes, that is what this domain is" — and entitled is the load-bearing word. I could check Primer's ontology against the legacy system it was derived from. In a domain with no such oracle, accepting the generated model would have been an act of faith.

Governance and Failure Modes

A formal model only helps while it stays authoritative, so the boring operational questions deserve answers. In this project: the ontology is the source of truth, and the requirement instances and interaction matrix are regenerated from it. Generated artifacts are kept separate from hand-authored ones, so regeneration can't silently overwrite an exception a human wrote for a reason. A change to the ontology is treated like a schema migration — versioned with the release, the diff of regenerated requirements reviewed like code. When implementation behavior contradicts the model, one of them is wrong, and a human decides which; the formalism doesn't adjudicate that for you.

Open problems I don't have good answers for: representing genuinely disputed domain concepts, where the model forces a resolution that actual practice hasn't reached; bounded contexts where the same term legitimately means different things; and detecting when a generalization in the model is wrong in a way no generated test happens to exercise. Drift among ontology, requirements, tests, code, and real domain practice doesn't stop being a problem because the artifacts are formal. It just becomes visible earlier, which is the most you can honestly claim. One axis of that drift I have since made mechanical: a build step reads the live database schema and refuses to build when it and the ontology disagree about a surface they share, such as the approval model, an enumerated set, or a field's type. It does nothing for the drift that matters most, between the model and real practice. It does turn the model-versus-database kind from something you catch late into something that cannot be merged.

What This Doesn't Do Yet

This is an engineering hypothesis backed by one real project, not a controlled result. I don't have a side-by-side comparison of agent-generated code with and without the formal layer, and no metrics yet on defect rates, rework cycles, or correction turns.

There's also a confound I'd rather state than bury. The ontology arrived as part of a bundle: the domain got reconceptualized, entities unified, permissions matricized, requirements normalized, traceability made queryable. Any improvement could come from any of those, and it's entirely possible a clean typed schema plus a generated permission matrix delivers most of the benefit, with the OWL layer along for the ride. The way to find out is an ablation — requirements alone; plus an informal domain model; plus a typed schema; plus the full formal layer with generated tests — measured on defects, omissions, unauthorized-action bugs, correction turns, and tokens consumed. I haven't run it.

I haven't run that full ablation, but I have since run a narrower measurement aimed at the model rather than the code: delete one source statement from the ontology, regenerate, and record what actually changes. It forces apart three things I had been quietly conflating — whether a statement derives a requirement, whether it amplifies (one statement controlling many), and whether it's consumed at runtime rather than merely described. The picture is less uniform than "everything derives." About seven families genuinely amplify, with the permission matrix far in front: roughly thirty rules expand into about 2,500 cells, and a single grant flips dozens of them. About nine more are read by the machine but yield one requirement per statement. And about seven, around one requirement in fifty, are still curated text stored in the model or written by hand. The leverage is real but concentrated, which is the confound above restated as a measurement: the permission matrix is doing most of the work, and it is fair to ask how much of the rest earns the formal layer. The numbers come out the same on each run, so it is something I can point at rather than an impression.

The "consumed at runtime" leg has also grown since I first wrote this. Three surfaces are now generated from the ontology and then actually run: the permission matrix compiles to the check the application enforces, the approval workflows drive a real rejection-and-recovery path, and each operation's declared effects (copy these attributes, skip that approval, seed from a template) generate an integration suite that executes the operation and verifies the behavior. Delete an effect from the model and its test disappears with it. None of this settles the top-line hypothesis, but it moves several of these claims from asserted to measured, which is the direction I would rather err.

The pipeline has at least survived transplantation. I'm running the same workflow on manufacturing data management, clinical psychological assessment, and audio signal processing, and the ontologies share nothing (a Snapshot's frozen-fork lifecycle has no analogue in an assessment instrument's norms and scoring model) while the steps are identical. Whether the benefits travel with the steps is part of what needs measuring. If you work in a domain where "plausible but wrong" is unacceptable and you want to try this, I'd like to compare notes.

The Deeper Point

A fair amount of software history consists of ontological commitments that weren't called that: the relational model, objects, bounded contexts are all claims about what kinds of things exist and how they relate. What agents change is the price of leaving the commitment implicit. A human developer with a flawed mental model still writes locally reasonable code, because they keep correcting toward the model in their head. An agent's output takes the shape of whatever model it was actually given, and if you never wrote one down, the shape it takes is borrowed from someone else's domain.

If your agents keep producing code that's plausible but not quite yours, the missing input may be the one thing they can't infer: an explicit account of what your domain is. That's the layer the ontology fills. I won't claim it's the highest-leverage artifact in every process. In mine, it's the one whose absence explained the most.