ArchSpec: Executable Architecture Specification for Ruby's Agentic Coding Era ArchSpec 1.0, an architecture linter for Ruby and Rails, was released today by developer Enrique Comba Riepenhausen, who also created RubyLLM. The tool allows developers to declare components and boundaries in a single file and checks every code change against those rules, whether written by a person or an AI agent. It uses static analysis via Prism, not AI, and can check the full Discourse app (1,899 files) in 2.5 seconds without booting the app. More and more code is written by a model. Tests still tell you it works. RuboCop still tells you it’s tidy. Nothing tells you it still follows your architecture. I released ArchSpec https://archspecrb.dev 1.0 today. It’s an architecture linter for Ruby https://www.ruby-lang.org/en/ and Rails https://rubyonrails.org . You declare your components and boundaries in one file, and every change gets checked, whether a person or an agent wrote it. This is part of my push towards making Ruby one of the best languages to build with AI. RubyLLM is one piece. Schematist is another. Making the default Rails job queue fiber-based is another. People and AIs take shortcuts An agent or a person that’s in a hurry or doesn’t fully understand your architecture takes shortcuts. The shortcuts work. They pass tests, implement features, and cross your boundaries . Months later, you realise your beautifully crafted architecture is now a patched mess. Or, perhaps, you’re starting a new project and you want to ensure that the agent is using a good architecture, without having to police it at every step. So you write it down in prompts and AGENTS.md . It helps, but it gets buried in its context window and something slips. Again, then again. RuboCop reads your code and enforces a style. Herb https://herb-tools.dev does it for templates. Nothing did it for architecture. Until now. ArchSpec: your architecture in one file Declare your components and their rules in an Archspec.rb at the root of the project: component :models, in: "app/models/ / .rb" component :controllers, in: "app/controllers/ / .rb" component :services, in: "app/services/ / .rb" models.cannot use :controllers services.cannot call :render, :redirect to, receiver: :none controllers.can only use :models, :services Then archspec check verifies every change. Models can’t reach into controllers. Domain code can’t touch adapters. Query objects can’t call save . A pack exposes a public API and keeps everything else private. A directory has to stay empty, and says why. If you’d rather not write rules at all, start from a preset: architecture :vanilla rails That one line is the 37signals playbook: rich models, no service objects, no form objects, no policy objects, and app/services fails the build with a reason if anything shows up in it. There are presets for Rails, layered, hexagonal, clean architecture, modular monoliths, CQRS, and event-driven too. Static Analysis, Not AI I’m the author of RubyLLM https://rubyllm.com so you’d think this uses AI. Nope. ArchSpec doesn’t use AI . Here’s how it works: Prism parses your Ruby, then ArchSpec extracts facts, references, inheritance, mixins, calls, definitions, and evaluates your rules against them. It’s deterministic, it’s offline, and it’s fast enough that you’ll leave it on: the full Discourse app, 1,899 files, was checked in 2.5 seconds, without booting the app. Prism is its only runtime dependency. No Rails, no ActiveSupport, nothing else, so it works on any Ruby codebase. RubyLLM is a plain gem and it’s been the main proving ground since June. It also won’t guess. ArchSpec doesn’t try to infer the “true” design pattern of arbitrary Ruby. You describe the architecture you want, and it tells you whether the code still matches. The AI is on the other side of the loop, writing the code that gets checked. Failures an Agent Can Act On When a rule breaks, you get this: error models must not depend on controllers dependencies.forbid app/models/user.rb:3:5 2 │ def admin path → 3 │ UsersController.admin path for self │ ^~~~~~~~~~~~~~~ 4 │ end note: User references UsersController 1 architecture violation found. The format is a deliberate homage to clang and to Herb. Exact location, the offending span underlined, the evidence as a note, the rule id in brackets so you can suppress it narrowly. A human reads it at a glance. An agent gets everything it needs to fix its own mistake without asking you: the file, the line, the rule, and why. What It Caught in RubyLLM I added ArchSpec checks to RubyLLM https://rubyllm.com 2 months ago https://github.com/crmne/ruby llm/commit/f1cf3b0e92e3e9244a94a2c1b5c7d4f2716d2aae , in CI and as a pre-commit hook, and it has been instrumental in the big Protocol/Provider separation that’s coming in RubyLLM 2.0. A protocol is a wire format, like Chat Completions, Responses, Anthropic’s Messages API, Gemini, or Bedrock Converse. A provider is an account you can talk to, like OpenAI, Azure, DeepSeek, or Ollama. DeepSeek speaks Chat Completions. VertexAI speaks four: Gemini, Anthropic, Mistral, and Chat Completions. Writing each Protocol once is the reason the gem supports as many providers as it does. That distinction is easy to state and easy to erode. The shortcut is to put a piece of wire format inside the provider that needs it, because right now that’s the only provider that needs it. Not on my watch: providers.cannot reference constants 'RubyLLM::Protocol' A provider can subclass a protocol family to change an endpoint or work around a quirk. Subclassing the bare Protocol means it’s inventing a wire format inside an adapter: error providers must not reference RubyLLM::Protocol constants.forbid lib/ruby llm/providers/elevenlabs/audio.rb:9:21 8 │ image endpoints, so those seams are left unimplemented. → 9 │ class Audio < Protocol │ ^~~~~~~~ 10 │ include ElevenLabs::Models note: RubyLLM::Providers::ElevenLabs::Audio inherits from Protocol 1 architecture violation found. This actually happened while I was developing RubyLLM 2.0. The ElevenLabs audio API and the AWS InvokeModel embedding family had become complete wire formats hiding inside provider adapters. Moving them out also deleted extra code that only existed to paper over the misplacement. Win-win. Here are some more examples from RubyLLM: Make contracts only static analysis can actually see chat protocol families.must implement :render payload, :completion url, :parse completion body Every protocol family that speaks chat implements those three seams. The base class declares them abstract with define method , so Ruby only raises at runtime and nothing catches it earlier. ArchSpec sees the definitions. A family that skips a seam fails the build instead of a request. Keep your naming conventions protocols.method names.matching /\A serialize|deserialize|to wire|from wire / .forbidden because: 'serialize with render , deserialize with parse ' In RubyLLM, serialization methods are called render , deserialization parse . Exactly the kind of convention an agent breaks, because serialize payload is a perfectly reasonable name and your rule is 200 lines in AGENTS.md . Stop slowly decaying API parity chat.method names.matching /\Awith ?