Spec-Driven Development: Enforcing Architectural Contracts for Coding Agents

SitePoint TeamPublished inAI·Programming·
September 18, 2026
·Updated:September 18, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
Architectural drift describes the gradual, undetected divergence between a system’s intended design and its actual implementation. Spec-driven development addresses that drift by turning the intended design into machine-readable, enforceable rules. When autonomous coding agents operate with commit-level autonomy, the problem takes on a fundamentally different character—one that demands machine-readable contracts, AST-level enforcement, and automated test boundaries.
Table of Contents
- The Architectural Drift Problem with Autonomous Coding Agents
- What Is Spec-Driven Development?
- Defining Architectural Contracts in TypeScript with Zod
- AST-Level Contract Enforcement with the TypeScript Compiler API
- Automated Test Boundaries as Behavioral Contracts
- The Architectural Contract Linter and Agent Spec Verification Suite
- Preventing Architectural Drift at Scale: Patterns and Pitfalls
- Contracts as the Missing Interface Between Architects and Agents
The Architectural Drift Problem with Autonomous Coding Agents
How Agent-Generated Code Silently Degrades Structure
Architectural drift describes the gradual, undetected divergence between a system’s intended design and its actual implementation. In the context of autonomous coding agents like Copilot Workspace, Devin, Claude Code, and GPT Engineer, this problem takes on a fundamentally different character. These agents, in autonomous or minimally supervised modes, operate with commit-level autonomy, generating entire modules, services, and integration layers without the institutional memory that guides human developers toward established patterns.
The concrete manifestations are predictable and recurring. An agent tasked with adding a repository implementation introduces a direct import from the domain layer into an infrastructure adapter, violating hexagonal architecture boundaries. Another agent flattens a carefully designed module hierarchy into a single directory because its context window lacks visibility into the project’s structural conventions. Circular dependencies emerge when agents generate cross-module utility functions that reference each other. Domain types leak into infrastructure layers when an agent, optimizing for the shortest path to a working implementation, imports an entity directly into a database mapper rather than working through a port interface.
What makes agent-introduced drift qualitatively different from human-introduced drift is volume, speed, and the complete absence of design awareness.
What makes agent-introduced drift qualitatively different from human-introduced drift is volume, speed, and the complete absence of design awareness. On a typical team, a human developer might introduce one boundary violation per sprint. In our testing, an autonomous agent introduced 10 to 50 in a single session, each individually plausible, each compounding into dependency graph corruption that surfaced only weeks later.
Why Code Review and Linting Rules Alone Fail
Standard linting tools like ESLint and Biome operate primarily at the syntactic level. They enforce naming conventions, formatting rules, and per-file code quality heuristics. While ESLint’s import/no-restricted-paths rule provides partial enforcement of module boundaries, it requires manual configuration per boundary and lacks schema-driven composability. These tools do not natively understand that a module named infrastructure/persistence should never import from domain/entities directly. They lack a holistic model of architectural intent.
Human reviewers fare somewhat better but cannot scale. When an agent generates a pull request touching 40 files across 8 modules, a reviewer must mentally reconstruct the dependency graph to detect cross-module violations. At the velocity agents produce code, this becomes untenable. Worse, agents generate code that looks structurally compliant on the surface. Imports are clean, types are correct, tests pass. The violation is not in any single file but in the relationship between files, and that relationship is invisible without tooling that understands the architecture.
What Is Spec-Driven Development?
From Implicit Conventions to Explicit Machine-Readable Contracts
Spec-driven development is the practice of encoding architectural decisions as enforceable, machine-readable schemas that both humans and automated agents consume. It moves architectural intent from implicit team conventions, wiki pages, and onboarding documents into versioned, testable artifacts that live alongside the code they govern.
This stands in contrast to documentation-driven approaches, where architectural rules exist as prose that agents never read, and convention-driven approaches, where rules exist only in the habits of senior developers who happen to be available for code review. In spec-driven development, the contract is a first-class artifact: it lives in the repository, it is versioned with the code, it is validated in CI, and it produces typed, structured errors when violated.
The Three Layers of an Architectural Contract
Architectural contracts operate across three enforcement layers. Structural contracts define module boundaries and dependency direction: which modules may import from which, and which imports are categorically forbidden. Type-shape contracts declare the public API surface of each module, specifying the exact shapes of exported symbols so that agents cannot silently alter interfaces that other modules depend on. Behavioral contracts round out the system by encoding test invariants that validate, at runtime, whether the system’s actual structure matches its declared structure.
Each layer targets a different failure mode. Structural contracts prevent dependency graph corruption. Type-shape contracts stop API surface drift before downstream consumers break. Behavioral contracts act as a catch-all, running inside the test suite to flag anything the first two layers miss.
Defining Architectural Contracts in TypeScript with Zod
Prerequisites
Before working through the examples in this article, ensure the following:
- Node.js ≥ 18 (required for consistent ESM/CJS behavior)
- All source modules must be direct children of the configured
rootDir(e.g.,src/domain/,src/application/,src/infrastructure/). Deeper nesting (e.g.,src/domain/entities/) requires adjusted path resolution in the AST linter. - A
tsconfig.jsonmust exist at the project root with anincludefield coveringsrc/. - Install the following dependencies with the specified versions:
{"dependencies":{"zod":"^3.22.0","minimatch":"^7.4.6"},"devDependencies":{"typescript":"^5.4.0","vitest":"^1.6.0"}}Note on minimatch: Version 7 uses CommonJS and its default export is the minimatch function itself. Use import minimatch from "minimatch" (default import), not a named destructured import. Version 8+ is ESM-only, exports a named minimatch, and requires "type": "module" in package.json or an ESM-compatible bundler.
Note on typescript: The TypeScript Compiler API is used at runtime for AST analysis. If you run contract checks in pre-commit hooks or CI (outside the normal build toolchain), add typescript to dependencies rather than devDependencies.
Modeling Module Boundaries as Zod Schemas
The foundation of contract enforcement is a schema file that declares the architectural rules of the system in a format that is both human-readable and machine-parseable. Using Zod, a TypeScript-first schema validation library, these contracts become type-safe, composable, and directly executable.
The contract registry file, typically named arch.contracts.ts, defines each module’s name, its allowed dependencies, its forbidden imports, and the shapes of its public exports.
import{ z }from"zod";const ExportedSymbolSchema = z.object({name: z.string(),kind: z.enum(["function","class","type","const","interface"]),});const ModuleContractSchema = z.object({moduleName: z.string(),allowedDependencies: z.array(z.string()),forbiddenImports: z.array(z.string()),publicExports: z.array(ExportedSymbolSchema),});exporttypeModuleContract= z.infer<typeof ModuleContractSchema>;exportconst contractRegistry: ModuleContract[]=[{moduleName:"domain",allowedDependencies:[],forbiddenImports:["infrastructure/**","application/**"],publicExports:[{ name:"User", kind:"interface"},{ name:"UserRepository", kind:"interface"},],},{moduleName:"application",allowedDependencies:["domain"],forbiddenImports:["infrastructure/**"],publicExports:[{ name:"CreateUserUseCase", kind:"class"},{ name:"GetUserUseCase", kind:"class"},],},{moduleName:"infrastructure",allowedDependencies:["domain","application"],forbiddenImports:[],publicExports:[{ name:"PostgresUserRepository", kind:"class"},{ name:"createAppRouter", kind:"function"},],},];This registry encodes a classic layered architecture: the domain layer has no dependencies, the application layer depends only on domain, and infrastructure may depend on both. The forbiddenImports field uses glob patterns matched against project-relative resolved paths (e.g., infrastructure/db/connection) to catch any import that crosses a boundary.
Validating Agent Output Against Contract Schemas
With the contract registry in place, validation becomes a matter of parsing each agent-generated file, extracting its import declarations, resolving them to project-relative paths, and checking them against the contract for the module that file belongs to.
import{ readFileSync }from"fs";import*as path from"path";import{ contractRegistry,typeModuleContract}from"./arch.contracts";import minimatch from"minimatch";functionresolveModule(filePath:string,rootDir:string="src"): ModuleContract |undefined{const normalized = filePath.split(path.sep).join("/");const prefix = rootDir.endsWith("/")? rootDir :`${rootDir}/`;const withoutRoot = normalized.startsWith(prefix)? normalized.slice(prefix.length): normalized;const segment = withoutRoot.split("/")[0];if(!segment || segment === normalized)returnundefined;return contractRegistry.find((c)=> c.moduleName === segment);}functionextractImports(source:string):string[]{const importRegex =/(?:imports+(?:types+)?(?:[sS]*?s+froms+)?|exports+(?:types+)?{[^}]*}s+froms+)['"]([^'"]+)['"]/g;const imports:string[]=[];let match: RegExpExecArray |null;while((match = importRegex.exec(source))!==null){imports.push(match[1]);}return imports;}exportfunctionvalidateModuleContract(filePath:string,projectRoot:string= process.cwd(),rootDir:string="src"):{ applicable:boolean; errors:string[]}{const contract =resolveModule(filePath, rootDir);if(!contract)return{ applicable:false, errors:[]};let source:string;try{source =readFileSync(path.resolve(projectRoot, filePath),"utf-8");}catch(e:unknown){const message = e instanceofError? e.message :String(e);return{applicable:true,errors:[`Cannot read file:${filePath}:${message}`],};}const imports =extractImports(source);const errors:string[]=[];for(const imp of imports){const resolvedImp = imp.startsWith(".")? path.relative(projectRoot,path.resolve(path.dirname(path.resolve(projectRoot, filePath)),imp)).split(path.sep).join("/"): imp;for(const pattern of contract.forbiddenImports){if(minimatch(resolvedImp, pattern,{ dot:true})){errors.push(`[${contract.moduleName}] Forbidden import "${imp}" (resolved: "${resolvedImp}") in${filePath}(matches${pattern})`);}}}return{ applicable:true, errors };}This function serves as the core validation primitive. It slots into a pre-commit hook or CI gate. When applicable is true and errors is empty, the file is compliant. When applicable is false, the file has no contract to enforce (which is not an error). Import specifiers are resolved to project-relative paths before glob matching, ensuring that patterns like infrastructure/** correctly match resolved paths such as infrastructure/db/connection.
The extractImports function handles single-line imports, multi-line imports, type-only imports, and re-exports. Dynamic imports (await import('./module')) are not covered by this regex; see the AST-level enforcement section for full coverage.
AST-Level Contract Enforcement with the TypeScript Compiler API
Building a Custom AST Schema Linter
Regex-based import extraction breaks down in real-world codebases. Dynamic imports (await import('./module')) and re-exports (export { Foo } from "./bar") evade regex matching. Type-only imports (import type { Foo } from "./bar") and barrel files that aggregate and re-export from subdirectories add further complexity. Reliable contract enforcement requires AST-level analysis.
Note: The AST implementation below covers ImportDeclaration nodes only. Dynamic imports (import() expressions) are CallExpression nodes in the TypeScript AST and require an additional ts.isCallExpression handler to capture. Re-exportsduction implementation should include both; the code here focuses on the most common case to illustrate the approach
The TypeScript Compiler API provides the necessary machinery. Using ts.createProgram to load the project and ts.forEachChild to traverse eachration and resolve module specifiers to their actual file paths
This implementation assumes all modules are direct children of the rootDir (e.g., src/<module>/). Files at deeper nesting levels like src/domain/entities/User.ts will have their module resolved to domain based on the first directory segment after rootDir. The resolveModuleSegment helper strips the configured rootDir prefix before extracting the module name, ensuring correct results regardless of path depth.
import*as ts from"typescript";import*as path from"path";functionresolveModuleSegment(relPath:string,rootDir:string):string|undefined{const normalized = relPath.split(path.sep).join("/");const prefix = rootDir.endsWith("/")? rootDir :`${rootDir}/`;const withoutRoot = normalized.startsWith(prefix)? normalized.slice(prefix.length):undefined;if(withoutRoot ===undefined)returnundefined;const segment = withoutRoot.split("/")[0];return segment && segment !==""? segment :undefined;}exportfunctionextractDependencyGraph(projectPath:string,rootDir:string="src"): Map<string, Set<string>>{const configPath = ts.findConfigFile(projectPath, ts.sys.fileExists);if(!configPath)thrownewError("tsconfig.json not found");const configFile = ts.readConfigFile(configPath, ts.sys.readFile);if(configFile.error){const message = ts.flattenDiagnosticMessageText(configFile.error.messageText,"");thrownewError(`Failed to read tsconfig.json:${message}`);}const parsed = ts.parseJsonConfigFileContent(configFile.config,ts.sys,path.dirname(configPath));const program = ts.createProgram(parsed.fileNames, parsed.options);const graph =newMap<string, Set<string>>();for(const sourceFile of program.getSourceFiles()){if(sourceFile.isDeclarationFile)continue;const filePath = path.relative(projectPath, sourceFile.fileName).split(path.sep).join("/");if(filePath.startsWith("node_modules"))continue;const sourceModule =resolveModuleSegment(filePath, rootDir);if(!sourceModule)continue;const deps =newSet<string>();functionvisit(node: ts.Node):void{if(ts.isImportDeclaration(node)&&ts.isStringLiteral(node.moduleSpecifier)){const specifier = node.moduleSpecifier.text;if(specifier.startsWith(".")){const resolved = path.resolve(path.dirname(sourceFile.fileName),specifier);const relResolved = path.relative(projectPath, resolved).split(path.sep).join("/");const targetModule =resolveModuleSegment(relResolved, rootDir);if(targetModule) deps.add(targetModule);}else{console.warn(`[ast-linter] Skipping non-relative specifier "${specifier}" in${filePath}.`+`If this is a path alias, configure alias resolution.`);}}ts.forEachChild(node, visit);}visit(sourceFile);const existing = graph.get(sourceModule)??newSet<string>();deps.forEach((d)=> existing.add(d));graph.set(sourceModule, existing);}return graph;}This function produces a Map<string, Set<string>> representing the actual module-to-module dependency graph of the project. It filters out declaration files and node_modules, focusing exclusively on internal project modules. The resolveModuleSegment helper strips the configured rootDir prefix and extracts the first directory segment as the module name, ensuring correct behavior regardless of file nesting depth. Path separators are normalized to forward slashes to ensure consistent behavior across operating systems.
Limitation: Projects using TypeScript paths aliases (e.g., @domain/...) will have specifiers that don’t start with "." and will be logged with a warning and skipped. Handling path aliases requires tsconfig-paths or manual resolution against the tsconfig.jsonpaths configuration.
Detecting Contract Violations Programmatically
With the actual dependency graph extracted, detecting violations becomes a comparison operation against the declared contracts. This implementation uses an allowlist model: any dependency not explicitly listed in allowedDependencies is treated as a violation. This is stricter than the forbiddenImports blocklist in validateModuleContract, which only flags specific patterns. The two approaches are complementary — use allowedDependencies for graph-level enforcement and forbiddenImports for file-level granularity.
import{ contractRegistry,typeModuleContract}from"./arch.contracts";interfaceArchitecturalViolation{module:string;forbiddenDependency:string;message:string;}exportfunctiondetectViolations(graph: Map<string, Set<string>>): ArchitecturalViolation[]{const violations: ArchitecturalViolation[]=[];for(const contract of contractRegistry){const actualDeps = graph.get(contract.moduleName);if(!actualDeps)continue;for(const dep of actualDeps){if(dep === contract.moduleName)continue;if(!contract.allowedDependencies.includes(dep)){violations.push({module: contract.moduleName,forbiddenDependency: dep,message:`Module "${contract.moduleName}" depends on "${dep}", which is not in its allowed dependencies [${contract.allowedDependencies.join(", ")}]`,});}}}return violations;}Edge cases require deliberate handling. Type-only imports may be acceptable in some architectures where the concern is runtime coupling rather than compile-time awareness; the AST walker can check node.importClause?.isTypeOnly to filter these:
if(ts.isImportDeclaration(node)&& node.importClause?.isTypeOnly)return;Test files should be excluded from enforcement unless the team explicitly wants to constrain test dependencies. Generated code directories (such as Prisma client output or protobuf stubs) should be added to an exclusion list in the configuration.
Automated Test Boundaries as Behavioral Contracts
Writing Vitest Architectural Tests
Treating architectural rules as test cases brings them into the same CI pipeline that validates functional correctness. This approach is particularly effective because it produces familiar pass/fail output that both developers and agents can interpret.
Note:extractDependencyGraph requires a tsconfig.json at the resolved project root. If Vitest is invoked from a different working directory (e.g., a monorepo root), use an explicit path such as path.resolve(__dirname, "../..") instead of process.cwd().
import{ describe, it, expect, beforeAll }from"vitest";import{ extractDependencyGraph }from"./ast-linter";import{ contractRegistry }from"./arch.contracts";import{ detectViolations }from"./detect-violations";import*as path from"path";functionformatViolations(vs:{ message:string}[]):string{return vs.map((v)=>`-${v.message}`).join("");}describe("Architectural Contracts",()=>{let graph: Map<string, Set<string>>;beforeAll(()=>{graph =extractDependencyGraph(path.resolve(__dirname,"../.."),"src");});it("enforces dependency direction for all modules",()=>{const violations =detectViolations(graph);expect(violations,formatViolations(violations)).toHaveLength(0);});it("detects no direct mutual dependencies (A↔B)",()=>{for(const[mod, deps]of graph){for(const dep of deps){const reverse = graph.get(dep);expect(reverse?.has(mod)??false,`Direct mutual dependency detected:${mod}<->${dep}`).toBe(false);}}});it("validates public exports declarations are present in contracts",()=>{for(const contract of contractRegistry){expect(contract.publicExports.length,`Contract for "${contract.moduleName}" declares no public exports`).toBeGreaterThan(0);for(const exp of contract.publicExports){expect(exp.name).toBeTruthy();expect(["function","class","type","const","interface"]).toContain(exp.kind);}}});it("ensures agent-generated files land in correct directories",()=>{const moduleNames = contractRegistry.map((c)=> c.moduleName);for(const[fileMod]of graph){if(!fileMod.startsWith(".")){expect(moduleNames.includes(fileMod)|| fileMod ==="shared",`File module "${fileMod}" is not declared in any contract`).toBeTruthy();}}});});This test suite covers critical architectural invariants: dependency direction enforcement, direct mutual dependency detection, and module membership validation. The formatViolations helper ensures that failure messages are actionable, pointing directly to the offending module and its forbidden dependency. The graph is constructed inside beforeAll so that any failure (such as a missing tsconfig.json) produces a clear test-level error rather than a cryptic crash at module scope.
Integrating Contract Tests into Agent Workflows
Contract verification integrates into agent workflows at three points. A pre-commit hook prevents violations from entering version control entirely. A CI gate blocks pull requests that fail architectural tests. At the third level, the pipeline feeds violation reports back to the agent as corrective context, allowing it to self-remediate before a human ever reviews the change.
When an agent receives a structured violation report stating that module “domain” depends on “infrastructure” in a specific file, it has sufficient information to restructure the import.
The feedback loop is critical. When an agent receives a structured violation report stating that module “domain” depends on “infrastructure” in a specific file, it has sufficient information to restructure the import. Teams configure the failure modemerge entirely, “warn” annotates the PR without blocking, and “off” disables enforcement for a given module during migration periods
The Architectural Contract Linter and Agent Spec Verification Suite
What the Suite Includes
The following package structure illustrates the intended organization. These packages are not currently published to npm. The code examples in this article serve as a reference implementation that you can adapt to your own project.
The arch-contracts/ directory contains Zod schema templates for common architectural patterns including layered, hexagonal, and modular monolith structures. The ast-linter/ package provides a configurable AST-based dependency analyzer built on the TypeScript Compiler API. The vitest-arch/ package offers a pre-built Vitest architectural test harness that excludes test files and node_modules by default. The agent-gate/ package provides a pre-commit hook and a CI GitHub Action specifically designed for gating agent-generated pull requests.
Quick-Start: Adding Contract Enforcement to an Existing Repo
Adding contract enforcement to an existing repository requires a configuration file, a first audit run, and CI integration. The following example illustrates the target developer experience for a configuration-driven contract linter. Adapt the code samples from the earlier sections to build this workflow in your own project.
exportdefault{rootDir:"src",modules:{domain:{ allowedDependencies:[], strict:true},application:{ allowedDependencies:["domain"], strict:true},infrastructure:{allowedDependencies:["domain","application"],strict:false,},shared:{ allowedDependencies:[], strict:false},},exclude:["**/*.test.ts","**/*.spec.ts","**/generated/**"],enforcement:"error"as"error"|"warn"|"off",};The audit workflow scans the project, extracts the dependency graph, and produces a summary table. Each violation includes the offending file, the forbidden dependency, and the line number. Teams can start with enforcement set to “warn” during adoption and switch to “error” once existing violations are resolved
Preventing Architectural Drift at Scale: Patterns and Pitfalls
Patterns That Work
Contract-per-module ownership aligns naturally with team boundaries. The team responsible for the domain module owns its contract, declares its public API surface, and controls which modules may depend on it. This distributes the maintenance burden and ensures contracts evolve alongside the code they govern.
Start with dependency direction contracts alone; they catch the highest-impact violations with the least setup cost. Teams can add type-shape contracts later, once they trust the enforcement pipeline. Contract diffing in pull requests then shows what architectural changes an agent’s PR introduces compared to the baseline, giving reviewers a structural summary far more useful than scanning individual file changes.
Common Pitfalls
Over-constraining contracts creates a different problem: agents cannot produce anything that passes validation, leading teams to disable enforcement entirely. Contracts should reflect the architecture’s actual intent, not an idealized version that no implementation can satisfy.
If the architectural test suite runs only on developer machines, it will inevitably fall out of sync with the codebase. Contract tests that never execute in CI are decorative.
The subtlest failure mode is treating contracts as static. Architectures evolve. New modules appear, boundaries shift, and previously forbidden dependencies become necessary. Teams must update contracts as part of any architectural decision rather than writing them once and forgetting them.
Contracts as the Missing Interface Between Architects and Agents
Autonomous coding agents need machine-readable architectural intent, not just natural language prompts. Prompts describe what to build; contracts describe the structural boundaries within which to build it.
Autonomous coding agents need machine-readable architectural intent, not just natural language prompts. Prompts describe what to build; contracts describe the structural boundaries within which to build it. Without that structural layer, every agent interaction risks introducing drift that compounds silently across hundreds of generated files.
Architectural contracts fill this gap. They sit between human design decisions and autonomous code generation, encoding the invariants that keep a codebase maintainable and structurally sound. The tooling described here, Zod schemas for contract definition, TypeScript Compiler API for AST-level enforcement, Vitest for behavioral validation, provides a concrete, reproducible system that teams can adopt incrementally.
The starting point is a single module boundary. Define it, enforce it, and expand from there. As coding agents become standard development infrastructure, teams should treat contracts as a standard input format alongside prompts, giving agents not just instructions for what to build but constraints on how to build it.
Sharing our passion for building incredible internet things.


