🏛️ Platform architecture
Updated
The nested reference for this map is Kernel (models, metadata, identity, scripts, schema, runtime) and Kinds (document, dictionary, register, command, …). This page is the long-form of the same architecture.
The idea: metadata instead of code#
ZuloOne is a low-code, metadata-driven platform. An application object (a
dictionary, a document, a register…) is described by metadata (rows in the
Meta* tables plus JSON), and its behaviour by C# scripts
(events/commands/services). Out of that description the platform generates:
- database tables (
SchemaSynchronizer), - entity classes and contracts,
- a REST API (the generic controllers
/api/data,/api/documents, …), - the user interface (forms/lists out of metadata).
Adding a module means adding metadata and scripts, not writing a new layer of code.
Record identity (it matters for transfer)#
- The PK of every row is
MetaId(a Guid), generated by the application → different on every instance.ID(out of the number sequence) is a separate human-readable key. - References are stored as the target's bare
MetaId(a Guid, with no type tag); the target's type is taken from the metadata. - The consequence: internal keys are not portable. The transfer of data and
customizations is built on business keys and names rather than on
MetaId(see the data transfer framework and the forms system).
Kinds of metadata#
| Kind | Purpose |
|---|---|
| Dictionary | a dictionary (a list of records) plus its fields |
| Document | a document: a header + table parts + subtypes-as-states + commands. From the UI, once the document is saved, the subtype is changed only by a command (checks → the target subtype → the postings) |
| Register | a register of totals/movements (balances, turnovers, FIFO lots) or of information (Information, the IR_ table) — see the information register |
| EDT | the data type of a field (a scalar, a Reference to a dictionary, an Enum) |
| TablePart | the type of a table part (a document's lines) |
| LinkTable | a link table (M:N) |
| NumberSequence | number sequences (auto-numbering of the ID field; on a dictionary its caption is "Code", on a document "Number". The Number column in the table is a shadow of the same value, there for document.Number in scripts). The counter is committed separately from a test's ambient transaction — otherwise rolling the case back returns NextValue onto IDs the data package has already handed out (IX_TaxCode_ID). If the sequence did land in an occupied range, an insert pulls the pointer up to MAX(ID)+1 once; on PostgreSQL the conflict is caught in a savepoint, otherwise 25P02 aborts the whole transaction |
| GlobalConstant | global constants |
| Enum | enumerations (stored as an integer) |
| WebService | RPC /api/rest/{Name} — an operation, not a collection. See web services |
| DataEntity | a public OData collection /odata/{Name} — a dictionary/document projection. See Data Entity |
| MetaFormLayout | a form layout (JSON) — see the forms system |
In-app guides#
The ⓘ drawer reads MetaGuide rows (Key, Title, Body). The source of the
default rows is wiki/guides/<key>.md: first line # <Title>, then the
Markdown body. GuideSeeder embeds those files into ZuloOne.Core as
ZuloOne.Guides.*.md resources and loads them on startup.
Seeding inserts missing keys. It also upgrades an existing row only when the
row still has the exact pre-file Russian default text: Core stores SHA-256
fingerprints of Title + "\n" + Body, not the old prose, and compares the row
against them. A user-edited guide has a different hash and is never overwritten.
Run node tools/check-guide-keys.mjs when adding or renaming a key. The check
fails when a literal guideKey in frontend/src has no file, when a non-grid
guide file is unused, or when the guide tree is empty. --coverage lists
StandardGrid ids that still have no grid.* guide; those are coverage gaps,
not build failures.
Layers and models#
Metadata is organized into models (Model) with layers (Layer): the
kernel → Solution → the application modules. A model is the unit of ownership
and of transfer: its metadata (shared form layouts and scripts included) is
exported and imported as a whole (the workspace export).
The SPA sends the X-ZuloOne-Model header: a record owned by another model is
read-only (the designer). A tree import, apply-file, compiling a virtual
total, seeding the totals bench (POST /api/dev/totals-testbench) and
running an integration test write rows of the owning model
(MetaVirtualTotalGroup groups — derivatives of the DSL; TestBench /
TestBenchExt; a constant's SetAsync, a deployment rollback). This is not
editing in the designer: without the bypass, the lock blocks "Totals bench" and
a run from Local. Editing the text of the DSL or a constant in the designer is
still possible only in the owning model.
The kernel's key services#
- The assistant —
POST /api/assistant/chat(an NDJSON stream). Chat sessions live in theMetaAssistantConversationstable. They are per user and do not travel into the workspace. List/read/write/delete:/api/assistant/conversations. The panel docks on the right, in the same row as the work area (not an overlay), and squeezes it; "Open in a new window" opens/assistantin a separate window —open_recordgoes to the main window through BroadcastChannel. In the panel, tool calls are compact lines (as in Claude Code): a click expands the result. Aquery_recordstable stays open (it is the answer);list_entities/describe_entityare collapsed.export_file(xlsx) builds the workbook through the same read asquery_records(permissions and row security) and returns the bytes on a "Download" chip — the model composes no URL and invents no rows. Print forms:list_print_forms/get_print_form/set_print_form_layout(ZuloReportLayoutJsononly, the Update permission) /preview_print_form(a URL plus an excerpt, not a PDF). An Html script, an Xlsx template or a ZuloReport engine are still in the designer; see print-forms.md.query_recordsaccepts a name on a reference field (Customer =Иванов, not a guid) — Russian transliteration and a typo (виджет/ивджет= Widget) — andincludeLineson a document (the header plus the lines).lookup_docssearches thewiki/businessandwiki/workpages (embedded into Core;roadmap.mdexcluded; lessons excluded because their check blocks carry answer keys) and the ⓘ guides; queries are in English, answers in the user's language. Clarifications go through theask_userwidget (buttons, not free text);open_recordopens a card through the ClientAction allowlist. Object captions inside the tools are localized byX-ZuloOne-Lang, and the model's answer comes inlang. In the system prompt, the rules are followed by a catalog of the objects the user is allowed to read (documents with their lines,SalesOrder.Lines). The history the model sees is a server-side transcript (ModelTranscriptJson):query_recordstables are written into the tool turn and are sent with the next question as<prior_tool_result>. The client sends only{role, text}andconversationId— tool rows are not accepted from the browser (they can be forged). TheMessagesJsonblob is for rendering the panel and nothing more. IDataService(src/ZuloOne.Core/Services/DataService.cs) — CRUD over the physical tables:QueryAsync/InsertAsync/UpdateAsync/DeleteAsync. A row is aDictionary<string, object?>keyed by column names.Image/Binaryfields accept a data URL (data:image/…;base64,…from the card widget) or raw base64 and are written asbyte[](VARBINARY/BYTEA). On save it sends the real-timeRecordChangedevent.DeleteAsyncdoes not delete a row that another record still references (RecordReferenceGuard: a Reference EDT, table parts, link tables, register dimensions,ParentId, a dictionary-listOtherKey) — the API returns409with the list of references. There are no FKs in the database; this is the only data integrity check there is.IMetadataService— introspection of the metadata (dictionaries, documents, fields, EDTs, table parts…).IAccountingPeriodService— the posting boundary: two dates per tenant (closed / audit). Not theFiscalPerioddictionary. In the UI: Administrator → Posting boundary. See the posting boundary.ITotalDriverProvider— for kernel drivers (StockBaseTotalDriverand the rest of those compiled into Core) there is no owner script: the engine IS the handler. A script in a model above is only a CoC wrap withnext(), not apartial classof the same name.SchemaSynchronizer— brings the physical database into line with the metadata (creates and alters tables and columns).EDTResolver— a field's type → a column's type (e.g.Reference→uniqueidentifier,Enum→ an integer).- The script runtime (
src/ZuloOne.Runtime) — the execution of events/commands/services; the result is handed to the client, typed ClientActions included. - Compiled code never leaves memory.
RuntimeCompilerloads every assembly into oneAssemblyLoadContextthat is never unloaded, so each load stays until the process restarts, outside the managed heap the GC manages. A stand that recompiles without unloading assemblies holds 13,000+ assemblies and ~5 GB of working set against an 87 MB managed heap. Each «Compile model» adds ~490 more, until the container's memory ceiling kills the process (exit 137). The rules that keep it bounded:- A check does not load. Editor Check, live check, «Compile model»,
compile-by-kind, the ZR and test checks all compile with
checkOnly: true. Only code that will run is loaded. - Unchanged entities are not re-emitted. An entity assembly is re-emitted
only when its generated sources changed (
SourceSetHash), or when a model before it in topological order was re-emitted in the same pass. SoEntitiesGenerationmoves only on a real metadata change, and the caches keyed on it (service registry, contracts, reports, jobs, form modules) stay. - Event handlers are reused. A handler with the same text, entity class,
scope and generations gets the type it already has
(
RuntimeCompiler.TryGetReusableType). - A per-call compile reuses what it loaded. Integration test runs, report
runs, document print scripts, analytic-column providers and command wraps
compile with
reuseLoaded: true. An identical compile returns the loaded assembly: same parsed texts, framework parts included, same references and generations. The caller then finds its types by the returnedAssemblyName, not the name it asked for. A side effect: static fields in a reused test or report survive between runs. - A new per-call compile uses
reuseLoaded: trueorCompileCachedAsync, never only a freshGuidin the assembly name.
- A check does not load. Editor Check, live check, «Compile model»,
compile-by-kind, the ZR and test checks all compile with
Real-time#
The SignalR hub /hubs/zuloone (src/ZuloOne.Web/Hubs/ZuloOneHub.cs)
broadcasts the RecordChanged {table, recordId, changeType}} event, which
DataService sends on Insert/Update/Delete. The frontend client is
frontend/src/lib/realtime.ts (a per-record subscription, auto-reconnect, a
fallback to polling).
Data transfer#
The framework for exporting and importing rows between instances:
src/ZuloOne.Core/Services/DataTransferService.cs +
DataTransferController (/api/data-transfer). On export, references are
resolved to the target's business key; on import, they are resolved again
to the local MetaId (with an upsert in topological order). Dictionaries, link tables and documents are supported (a header plus table
parts; movements are not copied — they are regenerated by reposting). A reference
whose target is not included in the export is not written into the package (the
published-version pointer is filled by handlers during import). A zip with
several JSON files is applied by file name. POST /api/data-transfer/export?zip=true
returns an archive.
The front end#
React + Mantine + react-query + react-router. The page registry is
frontend/src/pages/pageRegistry.tsx; the ribbon is
frontend/src/layout/Ribbon.tsx; the API wrappers are
frontend/src/api/api.ts and the types frontend/src/api/models.ts.
Build and deploy#
Docker Compose (v1: docker-compose, with the hyphen). The services: core
(:5257, MSSQL), web (the SignalR hub, :5049), frontend (:5173), plus the PG
stand (core-pg :5258, frontend-pg :5174, the pg profile). Migrations are
applied at startup (dbContext.Database.Migrate()). The dp-keys volume holds
the ASP.NET Data Protection key ring: without it the AI agent's keys in
MetaSystemSettings stop decrypting after the container is rebuilt (the page
shows "key unreadable"). The usual rebuild:
docker-compose up -d --build core frontend