Architecture
A small core that runs business applications described in configuration. No products table, no customers table, no hardwired processes.
One sheet, whole system
The core doesn't know what a product or a customer is. The definition tells it. What the core knows is how business software behaves.
Guiding principles
Quality by design
Acceptance criteria, invariants and tests are part of every spec before code exists.
API first
Every capability is an API before it is anything else. UI, partners and agents share one contract.
Open and closed API
The API grows by adding, never by changing. No breaking changes within a version; breaking changes only in a new major version with a deprecation period.
All logic behind the API
The React frontend has no business logic, so every end to end test is an API test.
One front door
Authentication, authorization, rate limits and audit in one place, for every client.
Event driven
Every change emits an event in the same transaction. Nothing gets lost.
Configuration over code
Entities, workflows and processes are data, versioned in Git.
Open for extension
Customize through configuration, API, webhooks, workers and extensions. Never by patching the core.
CI/CD from day one
Every merge is linted, tested, contract checked and deployed.
AI ready
The docs are designed so customers and partners can build dashboards and extensions with AI tools by prompting.
Simplicity over performance
On purpose, and documented. See the note to architects below.
Traits: capabilities you switch on
No table is hardwired. A plain entity becomes a document, a numbered record or a stock item because its definition says so. Generic traits live in the core, trade traits such as stock and prices ship in the trade package.
| Trait | Adds |
|---|---|
auditable | Created and updated stamps, full change history |
stateful | Status field, state machine, actions as API endpoints |
numbered | Gapless numbers from a number range |
document | Header and lines, totals, tax, immutable once posted |
effectiveDated | Validity, no overlaps, queries as of a date |
stockable trade | Stock ledger, available vs physical stock |
reservable trade | Reservations with expiry against stockable items |
priceSource trade | Declares an entity a price source with kind and precedence |
entity: quote
traits:
- auditable
- numbered: { range: QT, pattern: "QT-{yyyy}-{seq:5}" }
- document:
lines: { item: { type: ref, to: product }, qty: quantity, price: money }
tax: perLine
- stateful: { machine: quoteStatus }
fields:
customer: { type: ref, to: customer, required: true }
validUntil: { type: date }
source: { type: ref, to: orderRequest }
The front door
User accounts log in to the UI, system accounts use API keys. Both map to the same groups, roles and scopes. Rate limits belong to groups.
GET /api/v1/supplier-orders?filter[status]=new
Authorization: Bearer sk_live_supplier_acme_...
HTTP/1.1 200
X-RateLimit-Remaining: 4987
{ "items": [ { "id": "019a2f3c-7b41-7e8a-9c1d-5f2e8a4b6c10",
"number": "SO-2026-00017", "status": "new", "lines": 3 } ],
"page": { "number": 1, "size": 50, "hasNext": false },
"links": { "self": "/api/v1/supplier-orders?filter[status]=new&page=1" } }
POST /api/v1/supplier-orders/019a2f3c-7b41-7e8a-9c1d-5f2e8a4b6c10/actions/ship
Authorization: Bearer sk_live_supplier_acme_...
Idempotency-Key: 7f9c0d1e
{ "trackingNumber": "1Z999AA10123456784" }
HTTP/1.1 200
{ "id": "019a2f3c-7b41-7e8a-9c1d-5f2e8a4b6c10", "number": "SO-2026-00017", "status": "shipped" }
# emits supplierOrder.shipped, the dropshipping extension informs the customer
API evolution without breaking anyone
Partners, extensions and AI agents build on the API. So the API follows the open closed principle: open for extension, closed for modification.
- Additive only within a major version: new endpoints, new optional fields, new events
- Never rename, remove or change the meaning of a field in place
- Breaking changes only in a new major version, both versions run side by side
- Deprecation with a published sunset date and a
Sunsetheader - CI compares the generated OpenAPI with the last release and fails on breaking changes
GET /api/v1/sales-orders/019a2f3b-1d22-7c40-8a77-3e9b0c5d2f18 HTTP/1.1 200 Deprecation: true Sunset: Wed, 30 Jun 2027 00:00:00 GMT Link: </api/v2/sales-orders/019a2f3b-1d22-7c40-8a77-3e9b0c5d2f18>; rel="successor-version" # v2 splits shipTo into address objects. v1 keeps answering # unchanged until the sunset date; clients migrate on their schedule.
Hooks and events
Five ways to customize, in this order
- Configuration in the application definition
- API and webhooks, no code in the system
- External workers triggered by queue events
- Extensions with functions, hooks, traits and processes
- Changing the core: not intended. If it seems necessary, an extension point is missing.
hooks:
pre.update.customer:
handler: extensions/credit-check#validateLimit
timeoutMs: 800
onFailure: reject # or: allow
post.transition.order.status:
webhook: https://erp-bridge.example.com/order-status
secret: ${WEBHOOK_SECRET} # HMAC SHA-256 signature
retry: { max: 8, backoff: exponential }
A core without trade knowledge
The core does not know what a price, a delivery or a warehouse is. It knows three building blocks and how to run them. Everything a trading company needs ships as a package on top, and can be replaced.
Types
Rules for one value. Base types like string, decimal and date are in the core. Money, percent, quantity, address, IBAN and VAT id are type extensions with code, shipped as standard and versioned.
Traits
Behavior of a record and its siblings: numbered, stateful, document, valid over time. Traits also declare meaning: a priceSource tells the pricing logic that this entity holds prices.
Strategies
Replaceable decisions with a typed contract: which price applies, which warehouse ships, which box to use, what shipping costs.
export const money = defineType({
name: "money", version: 1,
schema: { amount: "decimal", currency: "currency" },
validate: (v, ctx) => ctx.currencies.has(v.currency)
&& decimals(v.amount) <= ctx.minorUnits(v.currency),
normalize: (v, ctx) => ({ ...v, amount: round(v.amount, ctx.rounding(v.currency)) }),
add: (a, b) => a.currency === b.currency
? { amount: a.amount + b.amount, currency: a.currency }
: fail("different currencies"),
compare: sameCurrencyOnly,
operators: ["eq", "ne", "lt", "lte", "gt", "gte"],
format: (v, locale) => formatCurrency(v, locale),
});
entity: promotionPrice
traits:
- effectiveDated: { key: [product, channel, price.currency] }
- priceSource: { kind: sales, amount: price, for: product,
precedence: 10 } # wins over the list price
fields:
product: { type: ref, to: product, required: true }
channel: { type: ref, to: salesChannel, required: true }
price: { type: money, required: true }
Types are versioned. Configuring and extending is always allowed; replacing a type creates a new version, stored values keep theirs, and posted documents are never recalculated.
AI ready by design
Once installed, customers and partners should be able to build on the platform by prompting. That is mostly a question of documentation and access, so both are product features.
"Create a dashboard showing daily sales stats"
A partner hands the API docs to a tool like Lovable. Complete OpenAPI, examples and recipes make the first try work.
"Create a returns handling process"
A customer prompts Claude Code. The extension guide, a context file template, a scaffold and a validator keep the result inside the extension points.
# ERP Self-Test API > Business API for customers, products, orders, stock and processes. > REST and JSON. API keys for systems, tokens for users. ## Start here - Authentication: /docs/auth.md - Entities of this installation: /docs/entities.md # includes custom fields - Actions and processes: /docs/processes.md - Recipes: daily sales, top customers, open orders: /docs/recipes.md ## Machine readable - OpenAPI 3.1: /api/v1/_meta/openapi.json - AsyncAPI 3.0 (events, webhooks): /api/v1/_meta/asyncapi.json - Arazzo 1.0 (multi step flows): /api/v1/_meta/arazzo.json - Events: /docs/events.md
# in a project created with: npx core extension new returns Read AGENTS.md and docs/extensions.md. Create a returns handling process: a customer requests a return for an invoiced order, sales approves above 500 EUR, stock is booked back on receipt, a credit note is created and the customer is informed. Use only extension points. Run core validate and the tests before you finish.
Docs are tested like code: a fixed set of reference prompts must produce working results with every release.
Data model, honestly
A word to the architects reading this
You would not build a production ERP on this data model. All configured entities live in one generic JSONB table. It is slower, harder to optimize and less strict than a fully modeled schema.
That's a deliberate trade: minimal code, a domain that is pure configuration, and model changes without migrations. This experiment asks how far agents get, not how fast the database is.
Neutral system tables cover what needs hard guarantees: number ranges, stock ledger, reservations, process state, audit and outbox.
The documented path to scale
Not built. All behind one storage adapter, so API, definitions and extensions don't change.
- Generated physical tables per entity, JSONB only for custom fields
- Partitioning of documents, lines and movements by time
- Read models from events for lists, search and reporting
- Read replicas, pooling, caching of master data
- Horizontal workers, outbox onto a high volume broker
- Load tests with target values as a pipeline step
Multi tenancy: deliberately out
It shapes data model, security, cost and operations more than any other choice. For the question behind this experiment it isn't needed.
| Option | Effort now | Verdict |
|---|---|---|
| Ignore | none | fits the question, risky without a plan |
| Build now | high, isolation in every query | contradicts lean core |
Prepare with tenantId everywhere | medium, touches every query | a half solution that still adds complexity |
| Ignore and document the path | low | proposed one database per tenant, routed by the front door |