2026-09-03 · Architecture
Three floors of business software
Most software ships as one thing. You install it, and inside that one thing are the storage engine, the business rules, the user model and the screens, welded together, changing at wildly different speeds — the storage engine hasn't changed in three years, the rules changed last Tuesday, and both require a release.
Ours is three floors, and they are made of different materials. That distinction is the whole design, so here it is with the bill attached. Every number below is from our production database today.
Floor one — a substrate that has never heard of your business
Our production database has 91 physical tables: users, spaces, projects, files, messages, automations, table_columns, table_rows.
No customers. No deals. No leads. No invoices. No pipelines.
This is a CRM whose schema has never heard of a customer.
Inside those 91 tables live 1,980 logical tables, 36,496 column definitions and 177,408 rows, driving 3,674 widgets. Adding a column to a business object is an INSERT. There is no runtime DDL against user tables anywhere in the backend.
Floor one is code, and it's most of the code: 544,352 lines of interpreters, row store, query path, auth, the permission engine, the rendering layer. It knows about tables, rows, subjects and permissions. It does not know what a customer is, and it never will. That's the point — a floor you don't renovate.
Floor two — the business is rows
Everything that would normally be "the application" lives here as data.
Rules are rows. The automation table has trigger_type, trigger_config, conditions, action_type, action_config — three of them JSON. There are 39 rules in production right now: 16 scheduled webhooks, 12 scheduled notifications, 5 API syncs, 3 that spin a prize wheel, 2 morning briefings, and one that fires when a column changes. Nobody deployed to add any of them. There is no automations codebase — there's an interpreter on floor one and a table on floor two.
A module is a row. We ship 33 of them — the entries in a space's sidebar. Five meaningful columns: which widget, which space, sidebar order, icon, access level. Not a package, not a bundle, not a plugin. A placement.
A new business system is a declaration. A provisioner takes a blueprint — tables, columns, icons, and a seed section so a fresh install opens on a working project instead of an empty one. That's how a discrete-event simulation of a parcel sorting centre came to live inside a CRM: 9,571 lines of simulation logic, zero lines of DDL. 91 physical tables before, 91 after.
Somebody else's database is a row. Point us at a foreign MySQL and we read information_schema, map its types onto ours, and create logical tables carrying data_source_id and source_table_name. 89 logical tables in production come from a working shop's database that way. They hold almost nothing in our own store, because the data never moved — every read goes to the foreign database at query time, and edits in our grid write back to the shop's production table. It isn't import. It's mount.
Policy is a row. Which agent may run which tool, matched by exact string, prefix or regex, with deny winning ties.
And the thing that makes this more than a config trick: the development of this system runs on floor two as well. Tickets are rows. Specs and architecture decisions are document rows with a trigger that rejects any registry entry without an author. The agents' prompts are fields. There is no separate Jira for the CRM, no separate wiki for its architecture, no separate Slack where its team coordinates. The tool and the work on the tool are the same substance, which means an agent that can query the product can query the project that produces it.
How to tell which floor something belongs on. Three questions, and they're decisive:
- Does it change more often than once per release? → row.
- Does it execute on every request where the user can feel the latency? → code.
- Will someone eventually have to explain who decided this and why? → row. A row has an author, a version and an audit entry. A constant in code has
git blame, which tells you about a person, not about a decision.
Floor three — workers, and most of them aren't people
109 accounts. 57 of them are agents. 52 are humans.
Same table. Same login column. Same permission model. Same conversations. The only thing the schema uses to tell them apart is user_type, and by that count the majority of the workforce in our production database is not human.
Creating a new worker costs add_table_row — the identical call you'd use to file a customer record. There's an agent whose entire job is issuing other agents, and its toolkit is exactly two verbs: read rows, write rows. The registry holds 30 agents, 28 of them active.
Here is the honest asymmetry, and it's the only one the system encodes: an agent's row contains its behaviour — prompt layers, model, temperature, whether it speaks always or only when mentioned. A person's row doesn't, because the behaviour is in the person. Everything else — identity, permissions, membership, message history, audit trail — is the same machinery for both.
People also appear on floor two, as subject matter: 22 "People" tables across projects, 90 rows. Someone's staff roster is business data. Someone's account is a subject. The substrate distinguishes those two things clearly, which most systems don't.
And the place where the two kinds of worker meet is a messenger: 1,594 conversations, 2,408 participants, 834,532 messages. 96% of those messages are trace — tool calls, tool results, thinking — hidden from the human view by a WHERE clause rather than by writing a second log somewhere else.
The messenger travels
If workers are accounts and conversation is where they meet, then the chat isn't a feature of the app. It's the interface to the workforce — and it has no business being locked inside one tab.
Into a design tool. A 299-line loader script docks the chat to the right edge of any page. On our Penpot host it is not an overlay: Penpot's editor root is a CSS grid with two auto sidebars and a 1fr viewport, so the loader shrinks that root by the strip width and the gutter is absorbed by the viewport column alone. Both Penpot sidebars keep their intrinsic widths, nothing is covered, and a debounced resize makes Penpot refit its canvas. The designer gets their tool at full size and the same chat, with the same agents, on the right. Drag to resize, collapse to a tab, width remembered.
Into Photoshop. 348 lines of endpoints: log in, list spaces, list the agents in them, list models, upload results back, and read a conversation. That last one filters content_type IN ('text', 'final_text') — the same predicate the web client uses, which is what "one store, many consumers" means in practice: the panel doesn't get a different API, it gets a different WHERE.
Into any agent runtime. 177 lines expose 99 tools over MCP — every table, every document, the dashboards, the chat, the fleet's own memory — to goose, Claude Code, Cursor and whatever ships next. A second door, 353 lines, is anonymous and read-only, built as an allow-list with two boot assertions that kill the process if the advertised tool list and the implemented handlers ever diverge.
Into everything else. A browser extension would take the same loader and put it on pages we don't control — which is the entire difference between "our sites, plus partners who add a script tag" and "wherever you happen to be working." It's designed. It is zero lines of code, and we're not going to describe it as if it exists.
Add the shipping surfaces up — desktop client, plugin API, storefront renderer, MCP server, public MCP door, embed loader:
2,250 lines. 0.41% of the codebase.
That ratio is what three floors buy you. A surface doesn't have to re-answer schema, identity, permissions and audit in its own dialect, because those aren't in the surface. They're downstairs.
The red line
Three things never move to floor two, and the third is the expensive one.
The hot path stays code. Anything executing on every request. An interpreter in the critical path is a bill you pay forever.
Cryptography and authentication stay code. Signing, TOTP, hashes, token verification. Never row-configurable.
Nothing grants itself. An agent does not write to the policy table. The loop is: observe the audit log → propose a policy as a row marked proposed → a human confirms with a second factor → the rule takes effect with an author and a timestamp attached. A system that issues its own permits has no story to tell an auditor, and the first person to check will find it in a minute.
What it costs
If this reads like a system with no bill, we're selling.
Audit is not enforcement. 109,463 authorization decisions recorded since 11 May, across 100 distinct tools and 34 actors — agent, tool, the actual command, the verdict, which rule fired — against exactly one configured policy and two denials. Everything is recorded and explained; almost nothing is forbidden. We built a state before it had citizens.
The read price is unpaid. There's no GIN index on the row store, so queries into the JSON document scan within a table_id. Moving work out of migrations moves it into reads. The standard EAV critique has been right for twenty-five years and it's right here.
A surface is cheap to build and expensive to keep. Our desktop client is 716 of those 2,250 lines, and its shipped binaries are four months old. The auto-update feed points at a GitHub organisation that was never registered, so it returns 404 — the app has no upgrade path. Its default API address is our own hosted instance, which is precisely the wrong default for a product whose pitch is that the server is yours. None of those three defects were predicted by the build cost.
The machinery keeps arriving ahead of the demand. Two foreign databases connected. 22 People tables holding 90 rows between them. Three colour columns per space so a workspace can look like a place instead of a tab — and 96 of our 103 spaces are on the default palette, seven distinct colours across the entire install. The mechanism shipped, the pull didn't. That's the better direction to be wrong in, and it is still being wrong.
And it's a beta — v0.x, self-hosted, MIT core, running one company in production and nobody else's.
The direction
Software has been getting thinner for forty years in one repeating move: something that used to be code becomes data, and the thing that interprets it becomes infrastructure. Queries stopped being loops. Layout stopped being drawing calls. Deployment stopped being a runbook. Each time, the surviving code shrank and the declarations grew.
Business logic is the last large block still shipped as code — the part that changes weekly, welded into glue that hasn't changed in years, so that changing a rule requires a release.
Split it into floors and each one gets to move at its own speed. The substrate stays still. The logic changes daily and keeps a record of who changed it. The workers are accounts, and it stops mattering very much which of them are people.