The mental model behind everything
Archestack has a small number of core concepts that show up everywhere in the product. Each one exists to solve a problem you'd otherwise solve by hand, over and over, in subtly different ways. Spending fifteen minutes here will save you hours of poking around. Each section below introduces one concept, gives you a worked example, and flags the mistakes new users make.
The Schema is the source of truth
Everything starts with the Schema, a JSON description of every table, column, relationship, and index in your application. You don't write SQL by hand. You design tables in the visual Schema Designer, and Archestack converts that into real PostgreSQL tables when you deploy.
Why it exists
A traditional ERP is a thicket of tables that grew organically over a decade. Archestack flips the model: the schema is a versioned artifact you edit explicitly, like source code. Every other tool in the platform, Business Entities, Pages, Events, Scripts, reads from the schema. If a column doesn't exist in the schema, no other tool knows about it.
How it works in practice
- Auto-save vs deploy. Editing in Schema Designer auto-saves your changes (an indicator appears in the bottom-left). Saving doesn't change the database, that only happens when you create a deployment from Database Deployments and click Deploy.
- Generated SQL is editable. The deployment's Generated SQL tab shows the migration SQL the platform produced,
CREATE TABLE,ALTER TABLE, etc. You can read it before deploying, and you can edit it directly if the auto-generated version isn't quite right. - Pre/post hooks. A deployment carries pre-deployment and post-deployment scripts (PostgreSQL), useful for backfilling a new NOT NULL column, or rebuilding an index in a controlled order.
- History is permanent. Every deployment is logged with its status (Draft / Executing / Succeeded / Failed) and the SQL that ran.
Worked example
You add a priority_score column to deal. In Schema Designer that's
selecting the table, opening the Columns tab, typing the name in the "column_name"
field, picking INTEGER from the Type dropdown, clicking Add Column. The
auto-save indicator briefly shows "Auto saving…" then "Saved". Then click Deploy in
the toolbar, you land on a fresh deployment config page. Open the Generated SQL tab:
ALTER TABLE "deal" ADD COLUMN "priority_score" INTEGER; Click Deploy at the top right, done. The column now exists. Until you also add it to the deal Business Entity (next concept), no page or event will see it. That separation is intentional: schema changes are cheap, exposing them is the deliberate next step.
Gotchas
- Renaming a column drops + recreates it. The plan generator can't read your mind. If you rename, edit the SQL on the Generated SQL tab to use
RENAME COLUMN, or do a multi-step deployment: add the new column, write a post-script to copy data, then drop the old column in a follow-up deployment. - Adding a NOT NULL column to a non-empty table will fail. Either provide a default, or add it nullable, backfill, then alter to NOT NULL in a second deployment.
- Foreign keys constrain delete behavior. Use the On Delete dropdown on each relationship deliberately, choices are CASCADE, SET NULL, SET DEFAULT, RESTRICT, NO ACTION.
- Snake_case is the convention. Schema Designer's "Table name" field even hints at it ("Lowercase with underscores recommended"). Tools work with PascalCase too, but every code example in this site assumes snake_case for table and column names.
Business Entities are the curated view
A raw table (customer) is rarely what an end user wants to see. They want
"customer with their last order's total" or "vehicle with its assigned mechanic's
name and phone number". A Business Entity (BE) is a curated view of one
source table plus joined columns from related tables. Pages are built on BEs, not on raw tables.
Why it exists
Two reasons. First, joining tables in every page would be repetitive and inconsistent, different
pages would join the same tables in slightly different ways, and behaviour would drift. The BE
centralises the join logic. Second, you often want multiple "views" of the same source table for
different audiences: the sales view of customer shows revenue and last contact;
the support view shows tickets and SLA breaches. Different pages reference different BEs, all
backed by the same row in customer.
Anatomy
- Master Table - the table the BE is anchored on. Every BE has exactly one. The BE's primary key is the master table's primary key.
- Label Column - a column whose value identifies a record in lists and pickers (typically
name,title, or similar). Picked when you create the BE. - Joins - added via the Add Join button. Each join has a join type (INNER / LEFT / RIGHT), a target table, source/target columns, and a list of which target columns to surface.
- Aggregated columns - toggle Aggregate Mode on a join, then pick an aggregate function per column: COUNT, SUM, AVG, MIN, MAX, COUNT DISTINCT. "Number of open contacts on this Company" is a typical aggregated column.
Worked example
Master table: vehicle. Native columns exposed: vin,
make, model, year. Join to customer via
vehicle.owner_id → customer.id, surfacing customer.full_name as
owner_name. Second join in aggregate mode: count of work_order rows
where vehicle_id matches and status != 'Closed', surfaced as
open_work_order_count.
A page bound to this BE renders a single grid row that looks like a flat fact even though it's pulling from three tables, and it does so consistently across the platform.
Gotchas
- BEs are not write boundaries by default. Saving from a BE-bound form writes to the master table. Joined columns are usually read-only, to edit
owner_namein the example above, navigate to the Customer. - Use Run Preview liberally. The button labeled Run Preview in the BE editor runs the join configuration and shows real rows. If a join column comes back empty, your relationship or column mapping is misconfigured.
- Don't reach across too many levels. A BE that joins three tables deep starts to feel slow on big datasets. If you find yourself joining four or five tables, that's a hint you want a custom Script Module or a database view.
Pages are configured, not coded
A Page is a runtime UI built from one or more Business Entities. You configure
them in the Page Editor by setting a name, a route (e.g.
/companies), a Business Entity binding, and a Published toggle. The platform
auto-generates sections from the BE's columns; you tune the layout from there.
Anatomy
- Visual tab - the WYSIWYG layout editor. Sections, fields, tabs, action buttons.
- Overview tab - list/grid configuration: which columns appear, default sort, filters.
- Create tab - the new-record form. Often differs slightly from the detail form (fewer fields, different defaults).
- Entities tab - additional BE bindings (the page can reference more than one BE for tabs and related grids).
- Events tab - page-scoped event triggers.
- JSON tab - raw JSON for power users.
Field widget types
For each field in the detail form you pick a Type from a Select control. The actual options:
Text- single-line inputTextarea- multi-line inputNumber- numeric inputDate- date pickerSelect- dropdown (use this for foreign-key fields and enum-style columns; for FK fields, set the Entity autocomplete to the referenced BE so users pick by label)Checkbox- booleanEmail- text input with email validation
For each field you also configure Label, Placeholder, Required, Read Only, and Span (grid column width).
Tabs and related grids
A page's detail form supports tabs. Use the Add Tab button to add
one. Inside a tab you can add a RelatedGrid section that shows rows from
another BE filtered by a join (e.g. all contact rows where
company_id = current company's id). This is how you build "Company → Contacts /
Deals" master-detail pages without any code.
Publishing
Each page has a Published Switch in the top header. OFF = draft (only you in the editor see your changes); ON = the published page appears under Published Pages in the sidebar and is what end users see when they navigate to its route.
Frontend Templates: when "configure, don't code" isn't enough
Sometimes the configured templates aren't enough, you want a custom widget, an unusual layout, or a specific visualisation. Frontend Templates let you write a piece of TSX that becomes available in the page editor as a reusable component. They're packageable and live alongside the rest of your configuration. See Reference → Frontend Templates.
Gotchas
- Forgot to flip the Published switch? The page won't appear in the sidebar and end users navigating to its route will see a 404. Easy to miss because the editor doesn't visibly enforce it.
- Width matters. If your BE has 25 columns, the grid will be unusable on a laptop. Hide low-value columns; they're still queryable, just not displayed.
- Tabs trigger separate queries. A page with five tabs makes additional queries when you open a detail row. Usually fine, sometimes a perf concern on large datasets.
Business Events make data react
A Business Event watches a Business Entity for changes and runs actions when its conditions are met. They're the "when X happens, do Y" rules that turn a passive database into a working application.
Trigger timings
The actual options when you create an event (you can tick more than one):
BeforeCreate- fires before a new record is saved. The script can mutateEntityand the changes are persisted.BeforeUpdate- fires before an existing record is saved. Same mutation behaviour.AfterCreate- fires after a new record is committed.AfterUpdate- fires after an existing record is committed.BeforeDelete- fires before a record is deleted.InitialValue- fires when a new form is opened; sets default field values.OnSchedule- fired by Quartz on a cron schedule (see Scheduled Events).Manual- fired explicitly by a user action button on a page.
Action types
The actual action types (RuleActionKind):
ExecuteScript- run a C# script. The most flexible action; this is what you reach for when you want to set a field, do a calculation, call an API, anything custom. The script getsEntity(mutate it to change the record),OldEntity,Log,Db,Modules.Validate- run a C# script that returnsbool.truemeans validation fails and the save is blocked with the configured error message. Optionally highlights specific columns.BlockOperation- hard-stop the operation with a message. No script.CreateEntity- insert a record into another BE. Field values support template expressions.UpdateEntity- update records in another BE matching a condition filter.DeleteEntity- delete records matching a condition (refuses to fire without one, safety).SendEmail,SendWebhook,PublishEvent- defined in the schema as future enhancements; currently logged and skipped.
Template expressions
Action configuration accepts template expressions in {{ … }} braces. The actual tokens:
{{ Entity.column_name }}- current record field ({{ Entity.id }}works too){{ OldEntity.column_name }}- previous value (update triggers only){{ now() }}or{{ getdate() }}- ISO 8601 UTC datetime{{ today() }}- date string{{ guid() }}or{{ newid() }}- fresh GUID{{ year() }},{{ month() }},{{ day() }},{{ timestamp() }}- Aggregates within a join context:
{{ SUM(column) }},{{ AVG() }},{{ COUNT() }},{{ MIN() }},{{ MAX() }} - Arithmetic:
{{ Entity.quantity * Entity.price }}- evaluated post-substitution
Note: there is no {{ user.email }} token, scripts and
template expressions don't have access to the current user. If you need user identity in a
rule, store it on the record at insert time via the front end and read it from there.
Worked examples
- Normalize a field on save.
Trigger: Before Update on Deal. No conditions.
Action: Execute Script with
Entity.title = ((string)Entity.title)?.Trim();to strip stray whitespace. (You don't need a rule forcreated_at/updated_at/created_by/updated_by, the platform stamps those automatically.) - Block low-amount deals being marked Won.
Trigger: Before Update on Deal.
Condition:
stage = 'Won' AND amount < 100. Action: Block Operation with message "Deals under €100 can't be marked Won, log them as Lost or delete them." - Create a Note when a Customer is flagged.
Trigger: After Update on Customer.
Condition:
flagged = true. Action: Create Entity onnotewithtitle = "Customer flagged at {{ now() }}",body = "Auto-generated for {{ Entity.full_name }}".
Simulate before you save
The trigger editor has a Simulate tab with a button labeled Run Simulation. Pick a real record and the platform runs the conditions and actions against it without persisting changes, write operations execute in a transaction that gets rolled back. The output panel shows which conditions matched and what each action would have done. Use it before enabling a trigger on production data.
Gotchas
- Recursive triggers. An After Update trigger that uses Update Entity to update the same record will re-fire itself. Use Before Update + Execute Script +
Entity.field = …instead, that mutates the in-flight write rather than starting a new one. - Order matters across triggers. Multiple triggers on the same event fire in priority order. If behaviour depends on order, set priorities explicitly.
- Disabled events still appear in the list. The Enabled Switch is separate from the trigger config, verify it's on before testing.
Script Modules give you escape hatches
For anything that can't be expressed as a simple condition + action, calculating a complex discount, calling an external API, generating a slug, doing a multi-step database operation, you write a Script Module: a small piece of C# (compiled at runtime by Roslyn) that you can call from a Business Event, from a Scheduled Event, or from the front end.
What scripts have access to
Every script gets these globals (no other names exist in script scope):
Entity- the current record (in trigger contexts) as a dynamic. Access fields withEntity.column_name. MutatingEntityin a Before* trigger is how you "set a field".OldEntity- the previous record (update and delete triggers). Read-only.Log- anILoggerfor diagnostics.Log.LogInformation("…")writes to server logs and the Event Log entry.Db- the database helper. See below.Modules- call other Script Modules:await Modules.CallAsync("OtherModule", new Dictionary<string, object?> { ["param"] = value }).Pdf- render a stored PDF template by name:await Pdf.GenerateAsync("invoice", new Dictionary<string, string> { ["id"] = Entity.id.ToString() })returns the renderedbyte[].Pdf.GenerateBase64Async(...)returns the same payload base64-encoded.
There is no User, Http, or Email global.
What a script looks like
// Parameters declared in the UI: customer_id (int)
var customer = await Db.GetAsync("customer", customer_id);
if (customer == null) return new { ok = false, error = "Customer not found" };
var orders = await Db.From("sales_order")
.Where("customer_id", "=", customer_id)
.Where("status", "=", "Closed")
.ToListAsync();
decimal totalRevenue = 0;
foreach (var o in orders) totalRevenue += (decimal)(o.total ?? 0);
return new { ok = true, customer = customer.name, revenue = totalRevenue };
Most scripts are 5-30 lines. You don't need .NET expertise, you need basic C# control flow and
the Db helper. IntelliSense is on, and after assigning from
Db.GetAsync("customer", …) the editor knows that variable's columns and offers
completions.
When to reach for a script
- Calculations that span multiple tables (a customer's lifetime value, a vehicle's repair history total).
- Calling external APIs (geocoding, payment providers, SMS gateways).
- Bulk operations triggered from a Scheduled Event (nightly recalculation, weekly digest).
- Returning data to the front end that's too custom for a Business Entity.
- Anything where you'd otherwise chain six Business Events together, usually a sign you wanted a script.
Gotchas
- The Db helper is async. Always
await. Forgetting compiles but returns a Task, not the data. - There's no
FirstOrDefaultAsyncorSumAsync. The terminals areToListAsync(),FirstAsync(),CountAsync(). For a sum, fetch the rows and sum in C#. Wheretakes three arguments - column, operator, value. Operators include=, !=, >, >=, <, <=, LIKE, ILIKE, IN, IS NULL, IS NOT NULL.- Update is a top-level call:
await Db.UpdateAsync("table", id, new { field = value }), not chained off aWhere. - Trial scripts run in the same sandbox as production. Don't assume "it's just a trial" means looser limits, your script can still hammer Postgres.
Scheduled Events run on a clock
A Scheduled Event is the same Event Trigger system with the OnSchedule timing,
a Quartz cron expression triggers the rule on a recurring basis instead of in response to a
data change. Use it for nightly summaries, daily data refreshes, weekly cleanups, monthly
reports.
Cron format
Quartz-style 6 or 7 field cron (second minute hour day-of-month month day-of-week
[year]). A few common patterns:
0 0 3 * * ?- every day at 03:00.0 0 9 ? * MON-FRI- weekdays at 09:00.0 */15 * * * ?- every 15 minutes.0 0 0 1 * ?- first of every month at midnight.
Times are server time (UTC on the production stack). The Scheduled Events page shows the next fire time so you can sanity-check before saving.
Gotchas
- Long-running jobs hold their slot. If a job takes 25 minutes and the cron is every 15 minutes, the next run is skipped, Quartz won't fire two instances of the same job concurrently.
- Failed runs don't retry. They log to Event Logs with the exception. Add explicit retry logic to your script if you need it.
Packages bundle config for promotion
Once you've built something, a set of tables, BEs, pages, events, and scripts, you'll want to move it from your trial to a real environment, or share it with another partner. A Package is a ZIP of selected configuration objects (with cascading: pulling in a page automatically pulls in its BE, which pulls in its tables) that you can export and import.
Cascading explained
You add the top-level objects you care about (usually pages). The package builder walks the dependency graph and adds everything those objects need: BEs the pages bind to, tables the BEs reference, Frontend Templates the pages embed, Script Modules the events call. You see the full cascaded list before exporting and can untick anything you want to omit.
Shipping seed data
Packages can optionally include row data, useful for shipping default lookup tables (countries, currencies, status enums) or demo data. Tick Include data on export. On import the rows are upserted by primary key.
Gotchas
- Schema deltas don't auto-deploy on import. If the importing environment is missing tables, the import fails. Deploy the schema first, then import the package.
- Identifiers are by name, not ID. A BE called "customer" in the source environment binds to a BE called "customer" in the destination, not the same numeric ID. Renames on either side break the link.
Business Units control who sees what
For multi-tenant or multi-team setups, Business Units are Keycloak groups that can be assigned to specific resources. A user in Business Unit "Garage A" can only see Pages, BEs, and Triggers assigned to Garage A.
How it works
Each user belongs to one or more BUs (managed in User
Management and Business Units). The front end tracks
the user's active BU and sets the X-Business-Unit HTTP header on every
request. The backend uses that header to filter list endpoints, only resources assigned to the
active BU come back.
When to use it (and when not)
- Use it when different teams or clients need their own slice of the same platform, Garage A's mechanics shouldn't see Garage B's work orders.
- Don't use it as a permission system. BUs filter visibility, not authorization. Roles (admin / owner / editor / user) handle who's allowed to do what.
- Don't use it for row-level security inside a single tenant. That's a job for filters on the Business Entity, not a BU.
Where this goes next
With those concepts in hand, the rest of the docs follow naturally. The first-app tutorial uses every concept on this page. The Reference goes one level deeper into each tool, what to click, what to watch out for, when to reach for it.