← All writing

What ADL Can't Express

TL;DR: Every declarative platform that oversold its boundary died on it — the demo was magical, the tenth requirement didn’t fit, the workaround pile grew beside the platform until the pile was the platform. This article is the boundary of ADL, drawn by us before a critic draws it. ADL is a closed language on purpose: a fixed type system, a pure expression language with hard limits, a bounded set of behaviors. Some logic drops one level, into sandboxed Lua hooks: a real hatch with real walls. Some applications shouldn’t be built on ADL at all, and we’ll name the categories. The boundary isn’t an embarrassment we manage; it’s the property that keeps an ADL file reviewable, which is the entire point. A spec you can read in an afternoon is only possible because of everything the language refuses to say.

Updated for ADL v0.3.1: the spatial gap described in the original version of this article has since closed (see the release notes); the section below records both states. The UI paragraph gained a clarifying note on ADL’s v0.3.1 UI-intent additions, which describe an interface without drawing one.


Why we’re writing this ourselves

The declarative pitch has a failure mode so consistent it has a shape. A platform demonstrates beautifully on the demo domain. A team commits. Requirements one through nine fit the model. Requirement ten doesn’t, and now the team discovers where the platform’s honesty was, because there are only two possibilities: either the platform admitted its boundary and provided a governed way across it, or it pretended totality and the team starts building the shadow system next door. The shadow system grows. Two years later the platform is a legacy dependency wrapped in the real application, which is the pile of workarounds.

We would rather you find requirement ten in this article than in month six. So: three rings (what the language refuses by design, what drops into the escape hatch, and what shouldn’t come near the platform at all) followed by the gray zone where you’ll need judgment, and the argument for why the boundary is load-bearing.

Ring one: closed by design

The type system is closed. Thirty-seven types, no mechanism for defining your own. If your domain needs a value type with its own storage, validation, and arithmetic semantics (a rational number, a chemical formula, a unit-carrying quantity that refuses to add meters to seconds), you can approximate it (strings plus patterns plus validation rules), but the language will not let you extend it. This is deliberate: every downstream consumer (migrations, OpenAPI, the schema endpoint, the query layer) depends on the type set being known.

The expression language is pure, and small on purpose. AEL (the language inside validation rules, permissions, guards, and computed formulas) has no assignment, no loops, no mutation, no I/O. Every expression is a pure function of its context. It also has hard ceilings: expression size, nesting depth, and strict budgets on the data queries validation rules may make (bounded rows, bounded time). You cannot write an algorithm in AEL. You can write a judgment (“is this write legal?”, “what does this field derive to?”) and that’s all you can write. If you find yourself fighting AEL to express something iterative, the language is telling you the logic belongs in ring two.

Computed fields compute less than you’d hope. Formulas cover arithmetic, string and date functions, conditionals, and a fixed set of aggregations over declared relationships: count, sum, avg, min, max. They deliberately cannot see the authenticated user, the record’s state machine, or run arbitrary queries. A computed field is a maintained value, not a program.

The query surface is disciplined, not general. List endpoints give you typed filters with a fixed operator set, combined with AND. There is no ad-hoc OR tree a client can compose, no filtering through a relationship (“posts where author.name like…”), no client-issued GROUP BY, no filtering on virtual computed fields or into nested JSON components. Complex read shapes exist, but they’re declared server-side: named scopes (which may carry richer SQL predicates), views, and reports. They are not composed freely by callers. If your product’s core feature is letting users build arbitrary queries, ADL’s read model will feel like a straitjacket, because it is one, worn on purpose: every readable shape is a reviewed shape.

Ring two: the hatch, and its walls

Logic that doesn’t fit the declarative model drops into Lua hooks: before/after functions on the write pipeline, sandboxed, with the platform exposed through a single module. This is where custom ID-generation schemes live, financial math (amortization, IRR), gnarly conditional defaults, checks against other records that outgrow a validation rule, and custom functions that AEL formulas can then call by name.

The hatch is real, and so are its walls. And the walls are the interesting part:

The sandbox blocks the filesystem, the shell, and arbitrary I/O. Database access from Lua is read-only; every write must dispatch back through the action bus, which means every write (even from your most creative hook) still passes validation, permissions, rules, audit, and history. You cannot use Lua to bypass the model; you can only use it to compute within it. Hooks are synchronous and sit inside the request: a slow hook is a slow API, so long-running work doesn’t belong here. And only before hooks can abort; after hooks are fire-and-forget by design.

Read those constraints again as a buyer, not a builder, and notice what they buy you: the escape hatch cannot become the shadow system. A Lua hook can be wrong, but it cannot be sovereign: reads pass a conservative SQL classifier, writes go through the same pipeline as every other request, and opening raw SQL is an explicit configuration choice that defaults off, warns at startup, and shows in health. That’s the difference between a hatch and a hole.

The honest cost: Lua-in-a-sandbox is not your ecosystem. No pip, no npm, no pulling in a library because someone solved your problem in 2019. If your custom logic wants a serious dependency, it wants ring three.

Ring three: don’t build this on ADL

Categories of application where ADL is the wrong tool, stated without softening:

Compute-shaped products. If the product is an algorithm (ML inference, media transcoding, scientific computation, optimization, image processing), the declarative model has nothing to offer the core, and the core is the product. ADL can be the system of record around such a service (jobs, permissions, lifecycle, audit), talking to it through the integrations layer, but the engine itself belongs in a general-purpose language with its ecosystem.

Novel-protocol and realtime-collaboration products. Collaborative editing (OT/CRDT), multiplayer state sync, streaming protocols, anything whose essence is a bespoke wire dance. ADL’s transport is disciplined request/response; there is no client subscription model where callers hold live connections to changing query results. Notifications push outward through channels. That is not the same thing as realtime data sync, and pretending otherwise is how demos lie.

Analytics as the product. ADL’s views, reports, and dashboards cover operational reporting: declared, parameterized, server-computed. They are not a warehouse, not OLAP, and not a self-serve BI tool. The exit is deliberately easy: your data sits in plain SQLite or PostgreSQL tables with predictable names, so point the real analytics stack directly at it. But don’t ask the application layer to be one.

The frontend, entirely. ADL expresses no UI. The schema endpoint is generous to UI generators (localized labels, enum metadata, state machines, permission declarations) but every pixel is someone else’s code. In a stack conversation, ADL is the backend, full stop. (Two v0.3.1 nuances that don’t move the boundary: fields may carry opaque ui: hint blocks the runtime stores and re-exports untouched (Ch. 42), and ADL now defines a declarative UI-intent vocabulary — widgets, navigation, layout — that external rendering engines consume from the schema endpoint (Ch. 43). In both cases Kalos generates no HTML and no JavaScript. ADL can now describe an interface; it still cannot draw one. Every pixel remains someone else’s code, by design.)

The gray zone

Between the rings sits territory where the answer is “partially, and you must judge”:

Complex domain reads. The declared-read model (scopes, views, reports) covers a great deal, until your users need genuinely exploratory queries, at which point you’re either declaring shapes faster than they can ask or conceding the exploratory surface to direct-SQL tooling. Know which product you’re building.

Spatial. This one closed in v0.3.1, and it’s worth recording that it was a gap when this article first ran. Geometry fields are validated GeoJSON on every backend; on PostgreSQL they now get PostGIS-backed geometry columns, GiST indexes, spatial query operators (bbox, intersects, within), and server-side simplification for map-scale payloads. The remaining honest caveat is backend asymmetry: on SQLite the geometry type stores and validates but the spatial predicates require the PostGIS backend: the one place in ADL where your database choice changes feature availability. If spatial queries are core to your product, run PostgreSQL.

Integrations with hostile APIs. The declarative integration layer handles the civilized world: REST with sane auth, webhooks, retries, circuit breakers, mapping. Every veteran knows the other kind: the SOAP endpoint with session cookies and a mood. Some of those fit the declared patterns; some want a small adapter service beside Kalos. Budget for the judgment call.

Identity beyond the built-in model. Kalos ships a complete JWT identity system: registration, rotation, roles, the works. If your requirement is federated enterprise SSO against a corporate IdP, put that question to the evaluation first, not sixth; it’s the kind of requirement that decides architectures.

Why the boundary is the feature

Here’s the argument this article has been walking toward. Everything ADL refuses to express is also everything that would make an ADL file unreviewable.

An open type system means a reviewer can’t know what a field does without reading the type’s implementation. A Turing-complete expression language means a permission check can hide anything. You’re back to auditing code. An ungoverned escape hatch means the file in front of you may not be the system. Client-composable queries mean the set of readable shapes is unknowable in advance. Every single expressiveness concession on the wishlist converts, one for one, into a reviewability loss — and reviewability is the product. The claim that an auditor, a new hire, or an AI agent can read the specification and know the system is only true because of what the language cannot say.

So the honest closing isn’t “ADL expresses everything that matters.” It’s this: ADL expresses the part of a business application that is data, rules, access, lifecycle, and process (which, for the systems it targets, is most of the building) and it holds a hard line at the edge so that what’s inside the line stays legible. If your application lives mostly inside that line, the trade is extraordinary. If it doesn’t, we just saved you six months, and we’d rather have the credibility than the deal.

The one-line version

ADL can’t express an algorithm, a pixel, or an arbitrary query — and a spec you can actually review is made of exactly those refusals.