ZuloOne
English
Sign in
Menu

ZuloOne Workspace — business layer as files (concept, 2026-08-15)

Updated

Purpose: keep the whole configurable business layer (metadata, scripts, forms) in a git repository as a hierarchy of folders and files — one model per folder, layers stacked on layers — with two-way synchronisation with a running ZuloOne, full script debugging in VS Code, and code delivery through git (clone → import → works).

Status: IMPLEMENTED (VSC-1..VSC-5, 2026-08-15). Below is the concept plus implementation notes; deviations and remaining work are collected in section 6. The next arc — model versions and safe delivery to customers (VSC-6/VSC-7) — is section 7, in progress since 2026-08-17.

1. Workspace layout (model on disk)#

<workspace>/                      ← git repository root (or subfolder)
  model.json                      ← manifest: model name, id, layer, dependencies
  Dictionaries/
    Warehouse/
      Warehouse.object.json       ← structure: fields, EDT, display format, flags
      Warehouse.events.cs         ← event-handler script (user partial)
  Documents/
    GoodsReceipt/
      GoodsReceipt.object.json    ← header, table parts, subtypes, statuses
      GoodsReceipt.events.cs
      Transactions/
        GoodsReceiptTx.cs         ← transaction scripts (user partial)
      PrintForms/
        Invoice.printform.json    ← MetaPrintForm + bindings + LayoutJson
        InvoiceData.cs            ← data script (PrintFormBase)
  Registers/
    Stock/
      Stock.object.json
      StockDriver.cs              ← totals-driver script (accumulation)
    TaxRate/
      TaxRate.object.json         ← registerEngineType: Information → IR_ table
  Commands/                       ← five MIQS command types (user partials)
  Jobs/  Services/  Reports/  Tests/  Widgets/  WidgetAssignments/
  Enums/  EDTs/  Constants/  Resources/  Menu/
  Forms/                          ← MetaFormLayout *.form.json (backlog “web form designer”)
  .generated/                     ← NOT in git (.gitignore): generated for IDE
    Entities/*.cs                 ← entity classes (same as in the assembly)
    Frameworks/*.cs               ← generated partial halves of scripts
    ZuloOne.Workspace.csproj       ← references to ZuloOne.Runtime/Shared → IntelliSense

Principles:

  • code is .cs, structure is .json: code is edited in VS Code with full C# tooling, structure is reviewed in PRs as data;
  • generated files are not stored: generated halves and entity classes are only in .generated/ for IntelliSense, gitignored (our lesson from MIQS GeneratedSource);
  • one object = one folder: review and conflicts are localised by object.

1a. Formats: what lives in which form#

One rule: written by hand → .cs; configured in a designer → .json; generated → not in git (only .generated/ for IntelliSense). The VSC-1 serializer is a bijection between Meta* tables and these files.

Artifact Disk form
Object design (dictionary/document/register/link table) X.object.json: fields (EDT by name, defaults), display format, table parts, subtypes (+IsInitial, tx-script binding), statuses, object constants, references, number sequence, model, caption translations
Object events X.events.cs — ONLY user partial (exactly the Script tab)
Transaction scripts Transactions/XTx.cs — user partial; binding to subtype is in object.json
Framework script parts NOT in git → .generated/Frameworks/*.Generated.cs (second partial half for IntelliSense)
Object class code NOT in git (generated from object.json by RuntimeTypeGenerator) → .generated/Entities/X.cs; edit only through object.json
Services/jobs/commands (5 types)/tests/reports/drivers pair “settings + code”: Name.json (contract, parameters, schedule, bindings, hotkeys, group, rights) + Name.cs (user partial)
EDTs, enums, global constants, number sequences, link types pure .json, one object = one file
Resources binary as is (Resources/Logo.png) + json sidecar; system translations Resources/translations/<lang>.json
Report views Reports/Views/X.view.json — MetaReportView + owner bindings (2026-08-18)
Custom reports Reports/X/X.report.json + datasource script next to it as a json+cs pair (2026-08-18)
Print forms Documents/<Doc>/PrintForms/X.printform.json — MetaPrintForm + bindings + LayoutJson; data script next to it as a json+cs pair (2026-09-05)
Widgets Widgets/X.widget.json — MetaWidget + surface (dashboard | form | both; empty on read = dashboard) + opaque ConfigJson (envelope { chrome, desktop, form }). Guide: wiki/developer/widgets.md
Widget assignments WidgetAssignments/<WidgetName>.<ObjectType>[.<ObjectName>].json — MetaWidgetAssignment (kind: WidgetAssignment): widget × Document/Dictionary × optional object (objectMetaId null = all of that kind). Import matching: modelId + name. import-all and tenant tree install read this kind.
Widget placements WidgetPlacements/<ObjectName>.<Slot>.<n>.json — MetaObjectWidgetPlacement (kind: ObjectWidgetPlacement): slot + configJson + size. Without a phase in ImportAllAsync, files were written and silently lost on the tenant.
Interface templates Interface/X.json; their menu items are in common Menu/menu.json (2026-08-18)
Caption translations INSIDE the object itself: "caption": { "en": …, "ru": … } (see §1.1). Translations/<lang>.json remains for non-caption translations — in the model that OWNS the translated object (MetaTranslation has no own modelId; 2026-08-18)
Languages (MetaLanguage) outside the workspace — stand setting, not a model
Users/roles/rights, licences, sessions outside the workspace — stand
Queues, attachments, audit, test/job runs, assistant conversation outside the workspace — operations
MetaAnalyticSet / VirtualTotalGroup outside the workspace — generated (analytic hash sets, VT groups from compilation)
Web services WebServices/X/X.webservice.json — MetaWebService + request/response parameters; script body next to it as .script.json + .cs. Answers on /api/rest/{Name}
Data Entity DataEntities/X/X.dataentity.json — MetaDataEntity + sources + fields; event script next to it as .script.json + .cs. Outside — /odata/{PublicCollectionName}; from script — GetService<IDataEntityManager>()
Forms Forms/X.form.json (MetaFormLayout as is)
Menu Menu/menu.json (ribbon item tree). Authoritative collection file: an item removed from the file is removed from the DB when applied (upsert-only would resurrect it on next export); a model without items gets an empty envelope so UI deletion of the last item reaches git (2026-08-18). targetType: Group, Dictionary, Document, Register, VirtualTotal, Report, CustomReport, Url, Standalone, UserCommand (leaf launches a user command, not a page). Service is not put into the menu catalog — it is code (GetService), not a UI item
Model dependencies model.json dependencies array. Authoritative for that model's outgoing edges, same contract as the menu: an edge removed from the file is removed from MetaModelDependencies on apply/import/bundle. Upsert-only left Sales → GLIntegration after the file dropped it, and the control-plane uninstall 409'd the retired model (2026-10-04)
Dictionary seed packages {Model}/DataPackages/ — the only source. index.json + JSON packages in POST /api/data-transfer format. Live-sync and apply-file skip them (no kind), export-all does not rewrite them. Tenant image copies this tree on publish (Resources/DataPackagesSeed, do not commit). Authoring process — §1b

Identity: every json has metaId; the file name is human-readable, but import matches by metaId → renaming an object = rename the file with the same id, round-trip remains stable. Links between objects (EDT, target dictionary, script) are by name so diffs are readable. layerId on an object row is not authored: the layer lives only in model.json of the model itself. Object files have no layerId — core writes a copy on save. createdDateTime / modifiedDateTime are also not authored: they are row audit in the database and are absent from json.

1b. Tenant dictionary seed (DataPackages/)#

Authoring is only in workspace {Model}/DataPackages/. A second copy in src/ZuloOne.Core/Resources/DataPackagesSeed is not maintained: MSBuild copies the adjacent zuloone-workspace into publish output, Docker passes it as --build-context workspace=… (compose: additional_contexts.workspace).

A new tenant starts empty. Production dictionaries (countries, currencies, units, country cities) and the legal-entity skeleton are loaded by the admin from inside the tenant: page “Tenant setup” (/tenant-setup), optional banner after login. Demo data is a separate button, not this contour. The control plane does not participate. Package apply/recreate does not look at the working model (X-ZuloOne-Model): seed is restore, not a designer edit. Without bypassing WorkingModelGuard, loading from the SPA header failed with “belongs to another model” (number sequence / Meta* of the package model under another working model).

The package format is not invented: it is exactly what POST /api/data-transfer/export|import writes and reads (page “Data transfer”). Besides dictionaries and documents, the same JSON carries link tables (kind: LinkTable, table LT_*, key = reference pair). A zip of several such JSON files is applied by file name on import. The platform merges indexes of all models and returns IDataPackageService (script does not read disk). A new dictionary set = new file + index row, with no C# change.

Layout#

Common/DataPackages/
  index.json
  catalogs.json          ← Country, Currency, UnitClass, UnitOfMeasure
  cities-SA.json         ← Region + City for country SA
Tax/DataPackages/        ← later: tax directions, etc.
  index.json
  directions.json

On a tenant, the image tree (/opt/zuloone/workspace) is read-only: the process is no longer root and cannot write there. Live install-tree copies DataPackages/ to writable overlay /var/zuloone/workspace. The setup page merges indexes from overlay and from the image tree. Copy failure does not roll back metadata import — packages simply will not appear in the wizard until the overlay is writable.

index.json is the package register of this model:

[
  {
    "id": "catalogs",
    "file": "catalogs.json",
    "caption": "Countries, currencies, units",
    "caption_ru": "Страны, валюты, единицы",
    "when": "once",
    "sort": 10
  },
  {
    "id": "cities-SA",
    "file": "cities-SA.json",
    "caption": "Saudi cities",
    "caption_ru": "Города КСА",
    "when": "country",
    "country": "SA",
    "sort": 20
  }
]

Global package id is {model folder}/{id} (Common/catalogs) so identical ids in Tax and Common do not collide.

A $ref inside a package is resolved against what the tenant ALREADY has, so the target must be created by an earlier package — or by the same one. .github/scripts/check_data_package_refs.py walks every package in apply order (once → the country's → manual, each by sort) and fails on a reference that resolves to nothing. It is blocking in CI, and it earned its place: the demo company pointed at City by name alone when the key is Country + Name, and at PaymentTerm by name when the key is Days — on a real tenant each produced one «target not found by key» per row.

when When the wizard offers it
once First step: load all such packages at once
country After country selection: packages with this country (ISO 3166-1 alpha-2)
manual Only the “more packages” list at the bottom. Demo and any future sets go here
elsewhere Not offered at all: another channel owns the content. The learning catalogue is built for the LMS by tools/build-learning-package.mjs and has no business on a customer's tenant. POST /api/data-packages/apply by id still works for the owner

Packs that are one deliverable split for apply order carry the same group id, and one of them carries groupCaption (plus groupCaption_ru / groupCaption_ar). The tenant setup page shows the group as a single row with a single button and applies the members by sort. The demo company is three packs — reference data, a year of documents, then extras — because the documents must find their items already there; nobody should have to press three buttons in the right sequence to learn that.

{ "id": "showcase-master", "file": "showcase-master.json", "when": "manual", "sort": 895,
  "group": "showcase", "groupCaption": "Demo company: reference data, twelve months of activity and its extras" }

A pack that is a scenario rather than a set of rows names an installer — a model service. The demo company needs its characteristic values written through the characteristics engine and its documents posted through their commands; importing the rows and calling that «applied» leaves a catalogue with empty characteristics and 253 drafts nobody posted. When installer is set:

  • GET /api/data-packages takes applied from the service's IsPackageInstalledAsync(), not from a dry-run import;
  • POST /api/data-packages/apply calls the service's InstallPackageAsync() and returns its report;
  • the installer itself must import rows through ApplyRawAsync, or it calls itself.
{ "id": "showcase-master", "installer": "ShowcaseSeeder", "group": "showcase", "…": "…" }

manifest.entities[].mode for seed = insert: repeated apply does not overwrite manual edits (Name and other fields). upsert is forbidden in seed — the platform sets insert anyway when applying from DataPackages. Matching searches by the stored business key and all its translations (the UI language must not turn Immediate into another word and create a second row). An already existing record is skipped; unique conflict (ID/key) is also skipped, not an error.

POST /api/data-packages/recreate is a dangerous reset: it deletes all rows of package tables, sets number sequences to StartValue, and loads the seed again. If deletion hits an FK or handler, sequences are not reset and apply is not run. On the tenant setup page the “Recreate” button has a warning.

References inside a package are $ref + business key, as in data transfer. If the key itself contains a reference (Region/City: Country + Name), the key also uses $ref, not GUID.

Business keys v1:

Entity Key
Country CodeISO2
Currency Code
UnitClass, UnitOfMeasure Code
Region, City Country + Name (there is no own Code)

Package does not cover the legal-entity/store skeleton: a human enters name and registration number. This is ITenantSetup.ApplyOrgAsync in model Inventory.

How to add a new package (process)#

  1. Fill dictionaries as needed on any stand (designer or an already loaded package). For a new country, only its regions and cities, not the whole world.
  2. Open Data transfer (/datatransfer).
  3. Build an entity profile with keys from the table above. Each entity mode is insert. For cities of one country, set an SQL filter by country, for example Country through a subquery / already selected record. Put the parent country (Country) into the same package only if it is not already in catalogs; otherwise the city package references it by $ref to CodeISO2.
  4. Export JSON. Check manifest.entities[].mode = insert and that references were serialised as $ref + key, not as GUID.
  5. Put the file in {Model}/DataPackages/{id}.json of the model that owns the entities (or delivers them to the tenant).
  6. Add a row to this model’s index.json: id, file, caption / caption_ru, when (once / country / manual), for country — ISO2, sort.
  7. Commit. Live-sync does not apply the file (it is not an envelope). The wizard picks up the index from the workspace disk without rebuilding Core. Rebuild is needed only if IDataPackageService itself changed.

Repeated apply of the same package: inserted = 0, existing rows are not changed. A wizard step error does not roll back previous steps.

The first delivery in git contains the full ISO catalog (catalogs) and one city package cities-SA. Other countries are added by the same export. The first catalogs.json was built by script scripts/generate-common-catalogs.py (the stand was empty); after that — only export from a stand.

Example Documents/GoodsReceipt/GoodsReceipt.object.json (shortened):

{
  "metaId": "5f0c…",
  "name": "GoodsReceipt",
  "caption": { "en": "Goods receipt", "ru": "Приходная накладная" },
  "displayFormat": "{Number} от {DocumentDate:d}",
  "fields": [
    { "name": "WarehouseID", "edt": "WarehouseRef" },
    { "name": "Comment", "type": "String", "length": 255 }
  ],
  "tableParts": [{ "name": "Lines", "fields": [
    { "name": "Item", "edt": "ItemRef" },
    { "name": "Quantity", "type": "Decimal", "scale": 4, "default": "1" }
  ]}],
  "subtypes": [{ "name": "Purchase", "isInitial": true,
                 "transactionScripts": ["Transactions/PurchaseTx.cs"] }]
}

1.1 Multilingual captions inline#

A translatable property (caption of object and field, subtypeCaption of subtype) keeps translations INSIDE itself — as a language object, not as sibling keys:

"caption": { "en": "Goods receipt", "ru": "Приходная накладная", "uk": "Прибуткова накладна" }
  • en is the base: this is the object’s own Caption column and the UI fallback when there is no translation for the user language.
  • Other keys are MetaTranslations rows (PropertyName = Caption / SubtypeCaption).
  • If there are no translations, the property remains a simple string: "caption": "Quantity". Both forms are valid on read; export chooses by the presence of translations, so untranslated objects do not grow.
  • The file is authoritative: a language removed from the object is deleted from the database.
  • A language not registered on the stand (MetaLanguages) is ignored on import — a delivered model does not create tenant languages.
  • A language registered LATER gets its captions at once, not with the next model version: POST /api/metadata/languages reads them from the installed model tree (the image tree, the overlay a pushed install keeps at /var/zuloone/workspace, or Workspace:Path on a developer stand), and a boot fills any registered language that still has no caption rows (WorkspaceLanguageCaptions). Only models installed on the tenant are read; nothing is deleted there.

The old form — sibling keys caption_ru / caption_ar next to caption — is still READ so a workspace written before the change imports as is. But it is no longer written: the first export rewrites it nested.

2. Two-way synchronisation#

  • File → ZuloOne: server WorkspaceSyncService (FileSystemWatcher on the folder, debounce) — the changed file is parsed and applied through EXISTING paths (MetadataService / scripts API), so all invariants work: EnsureSystemId, EnsureParentField, compilation invalidation, audit.

  • ZuloOne → file: a hook after saving metadata/script serialises the object to a file.

  • Echo suppression (two stages, the second is load-bearing):

    1. a path just written by export is ignored by the watcher for 3 seconds — a cheap input filter, nothing more;
    2. content: export remembers the EXACT bytes it left at each path (WorkspaceWriteTracker.IsOwnEcho), and the applier checks this at apply time, with no expiry. If bytes on disk are exactly what we exported there, the author did not edit the file, and applying it is forbidden: the DB row may have moved forward, and import would silently return it to the past.

    One time window is NOT enough: file-side detection delay is unbounded (whole-tree scan every 2 seconds through container bind mount), and on 2026-08-30 a leaked echo rolled back 109 objects in one thirty-second pass. Explicit calls (apply-file, apply-files, force: true, deploy rollback) bypass stage 2 — intent is declared; the watcher cannot declare it and never forces.

  • Blast radius: a batch of changed files larger than Workspace:MaxAutoImportFiles (default 25) is a wholesale rewrite of the file side (checkout, merge, restored snapshot), not a live edit. Such a batch is rejected WHOLE, and sync freezes in BOTH directions until a human decides which side is right: GET /api/dev/workspace/sync-status, then POST /api/dev/workspace/sync-resume {"apply": true|false} (true — files won, false — the database will win on next export).

  • Metadata deletion must pulse the export signal itself: bulk-delete (ExecuteDeleteAsync) bypasses SaveChanges, the interceptor does not fire, and without an explicit pulse, files of the deleted object remain on disk as the freshest thing there — the watcher reads them and resurrects rows.

  • Conflict (both ends changed between syncs): last write wins, the second copy is saved nearby as *.conflict, warning in Console dock.

  • Mode is strictly opt-in: Workspace:Path in appsettings.Development.json; production lives without a watcher.

  • Bootstrap: POST /api/dev/workspace/export-all (materialise everything into the folder) and import-all (apply folder to a clean database).

2a. Direction policy: Core only syncs down#

Models are mapped to root folders of the workspace (Core/, <ModelName>/…), and each model has its own sync policy:

  • User models — two-way sync (file ↔ ZuloOne).
  • Core/system model — sync ONLY down (ZuloOne → files): core is always visible in the repository and versioned, but editing its files is not applied unless core development mode is enabled on the stand.

Mechanics:

  • the existing gate decides: CoreDevMode (ZuloOne:CoreDevelopmentMode, switched at runtime) — the same flag that already protects Core from edits in designers. On a public tenant this flag must not be enabled: it also opens API without token. A fleet developer container is marked in the panel as developer stand (ZuloOne:ProductModelDevMode) — Zulo models are editable, anonymous API is not opened;
  • double execution: (1) WorkspaceSyncService checks the target object’s model and without dev mode rejects import with a clear warning in Console dock, then RE-EXPORTS the file from DB (the folder always reflects truth; local edit is visible in git diff as reverted); (2) even if sync has a bug, the row hits SystemModelGuardInterceptor on DbContext — a structural backstop, EF cannot bypass it;
  • files of read-only models are exported with the read-only file attribute — a hint to the editor before save;
  • import-all skips the Core model without dev mode (and says so); with dev mode it imports everything;
  • on GitHub, folder Core/ is additionally protected by CODEOWNERS — core changes pass only through owner review.

3. Debugging in VS Code#

The foundation already exists: RuntimeCompiler builds scripts with OptimizationLevel.Debug and embedded portable PDB. Missing pieces:

  • in workspace mode, script compiles with CSharpSyntaxTree.ParseText(code, path: <workspace file path>, encoding: UTF8) (+ EmbeddedText) — then PDB points to real files;
  • attach from VS Code (C# extension) to process ZuloOne.Core → breakpoint in Documents/GoodsReceipt/Transactions/GoodsReceiptTx.cs binds on the next compilation/posting; a .vscode/launch.json template is put into the workspace on export-all. Limitation: after script edit, the assembly is recompiled — old breakpoints rebind to the new assembly on the next hit (normal for plugin hosts).

4. Delivery#

The workspace folder is the delivery artifact: git clone → import-all → Compile. Bundle export (MetadataBundleController) eventually reduces to “workspace zip”; form settings (MetaFormLayout) are delivered by the same channel.

5. Phases — all implemented (2026-08-15)#

  • VSC-1 ✅: WorkspaceExportService/WorkspaceImportService (25 envelope kinds, whole designed surface), POST /api/dev/workspace/export-all and import-all (path from request or Workspace:Path), core test “Workspace: export-all → import-all round-trip” (idempotence).
  • VSC-2 ✅: WorkspaceSyncService (watcher + debounce) + WorkspaceFileApplier (update semantics, Core gate with re-export) + WorkspaceDbInterceptor (pulse on each Meta* save → hash-suppressed export-all). Echo is suppressed twice: by own-write tracker and by content comparison. Core test “file edit applies to metadata”. Checked live both ways (~4 s).
  • VSC-3 ✅: WorkspaceScriptPathResolver — transaction scripts/drivers/tests compile with the REAL workspace file path (embedded portable PDB + byte-for-byte content → VS Code breakpoint binds after attach); export-all writes one-time .vscode/launch.json (attach to ZuloOne.Core).
  • VSC-4 ✅: .generated/Entities (exact ZuloOne.Runtime.Generated sources), .generated/Frameworks (generated partial halves of scripts and tests), GlobalUsings.cs, ZuloOne.Workspace.csproj in root (references to binaries of the RUNNING server), one-time .gitignore.
  • VSC-5 ✅: documentation, registry, full gate run; the dev stand uses (Workspace:Path → D:\Sources\zuloone-workspace in appsettings.Development.json).

6. Implementation notes and deliberate deviations#

  • csproj is in the WORKSPACE ROOT, not in .generated/ (as drawn by the concept): default MSBuild globbing takes **/*.cs relative to csproj and EXCLUDES dot folders, so from root one <Compile Include=".generated/**"/> solves everything. csproj is maintained by export (hash-suppression), not committed to git.
  • Json links are by metaId (plus human-readable names in rows themselves); import matches by MetaId, then by name for top objects. Full by-name resolution (moving between independently created stands) is a future refinement; it is not needed for the git flow “one source of ids”.
  • Deletions through the live sync do not propagate: a row missing from a file only writes a warning — deletion on a dev stand remains with designers (there is impact preview and cascade), because switching a git branch must not drop tables.
  • A model install removes what the model no longer ships (WorkspaceModelPruner). Boot upgrade and install-tree delete every row of an installed, non-system model whose metaId no file of the tree carries: dictionaries, documents, registers, link tables and table part types through the designer cascade (their tables are dropped), fields (schema sync then drops the column), extensions, commands, scripts, jobs, services, tests, print forms, custom reports, form layouts, data entities, web services, global constants, resources, enums, EDTs and number sequences. Guards: the incoming version must be the same or newer than the installed one (a downgrade removes nothing); the import must report no error (an unreadable file would read as a deletion); system fields, the tenant-minted ParentId EDT and ID sequence, user form layouts and anything still referenced (an extension from another model, a field typed by the EDT, a script derived from the script) stay and are named in the warnings. The install response lists what went under removed. Not pruned yet: document subtypes, statuses and transitions, widgets, interface templates, analytics, total drivers, virtual totals. Menu items, model dependencies and register analytic bindings have their own authoritative prune (LocalizationUkraine moved UaVatFirstEvent from analytics to dimensions; before that prune every sales invoice on the tenant failed «Analytic 'Customer' is required by this register»). *.conflict markers are not materialised yet: file edit applies (last write wins), rejected Core edit is re-exported from DB and remains visible in git diff.
  • Sync warnings go to server log; output in frontend Console dock comes with a future server notification channel.
  • Service scripts: Frameworks half is only ServiceScriptBase (next/nextAsync for IDE). Contract IFoo lives in-memory and is not written to workspace.
  • Whole-workspace compilation (dotnet build ZuloOne.Workspace.csproj) on the dev stand: 5 errors, all meaningful — demo scripts with duplicate class names (Handler, MyReport; MIQS added id suffixes exactly for that) and a job on an in-memory contract. IntelliSense is complete per file.
  • Bundle: bundle export/import (MetadataBundleController) remains for selective transfer; “delivery = git clone + import-all” covers whole models. Reducing bundle to “workspace zip” is for the next versioning pass (backlog item 5).
  • Forms (Forms/*.form.json) appear with the web form designer (backlog item 4) — the layout slot already exists; MetaTranslation translations go there too.

7. Model versions and delivery to customers (VSC-6/VSC-7, concept 2026-08-17)#

Scenario: a vendor develops a module in a workspace repository (including through an agent in VS Code) and delivers it to DIFFERENT customers — each with its own application version, model set, and customisations. Before import onto a stand, there must be proof that nothing breaks.

7.1. Versions#

  • Source of truth is git: MetaModel.ModelVersion (semver, field already exists) lives in model.json and is bumped in the same change as the model’s objects, not “later on release”. Convention: patch — code/script fixes; minor — additive changes (new fields/objects); major — breaking changes (type changes, renames, deletions).
  • Why this is not a formality. On tenant start, WorkspaceLayerInstaller compares MetaPackages.PackageVersion with modelVersion from the image tree. If equal and this model was actually loaded (IsActive, date Installed, plus MetaModels.ModelVersion equals) — the model is current and is not imported. Equal only in MetaPackages after a failed attempt is not current, so retry. PackageVersion stamp is per model, after its CompilationStatus=Ok. Failed LocalizationSaudiArabia does not hold Inventory; MinVersion in Ukraine does not hold Sales. Object changed, version forgotten — on the dev stand (live sync) everything exists, on tenants the load silently will not happen.
  • “What is currently installed” = MetaModels.ModelVersion on the stand; the model list UI shows version, publisher, and compilation status.
  • Core is inseparable from the application: Core is not delivered as files at all (sync only down is already implemented); the application sets its ModelVersion on start equal to the assembly version. “Update Core” = “update ZuloOne”.
  • TestBench / TestBenchExt are not product. They are core test fixtures: CI imports them as a gate, treeExclude does not put them into the dist image, the panel does not offer to apply them, boot-install and live-sync skip them. If a model already sticks out on a tenant — remove it through Uninstall in the panel, do not “update” it.
  • User models declare dependencies in model.json (dependsOnModelMetaId + minVersion; edge owner is this file, core sets modelMetaId/modelId/layerId itself); MetaModelDependency.MinVersion (field already exists) starts being checked: dependency on Core with minVersion = required application version.
  • Import log: who, when, model, version from→to, source git-ref, validation verdict. Without a log, customer incidents cannot be analysed.

7.2. Ecosystem rule#

A customer customises IN THEIR OWN model on top of the vendor model (dependency/BaseModel), never inside it — upgrading the vendor model does not wipe customer changes. Validation checks that customer models do not reference something being deleted/renamed.

7.3. Delivery — three phases#

Delivery artifact = model folder from git (tag or zip); module tests (Tests/) travel in the package.

  1. Validate (dry-run, no writes): POST /api/dev/workspace/validate — manifest (is stand Core version sufficient? dependencies present in required versions?), version direction (upgrade ok, same idempotent, downgrade blocked without force), resolving all metaId links (inside package or on stand), diff preview created/updated, separately — breaking changes (field type change, length narrowing, name collisions). Verdict: green / warnings / blocked.
  2. Apply: automatic snapshot of current state (export-all aside) → phased import → Ensure* invariants for created objects → Compile affected models by dependency graph. Compilation failure → rollback by snapshot (import-all snapshot + Compile).
  3. Verify: run tests from package on the stand + check that no model went DependencyFailed; write result to the log.

The same validate/apply/report pipeline is the agent’s development loop: wrote files → validate → apply → read machine-readable report (created/updated/warnings/errors + compilation errors) → fix → repeat.

7.4. Phases#

  • VSC-6 ✅ (2026-08-17) — pipeline: (a) Ensure* invariants after creating objects through workspace (applier and import-all call them themselves, invariantsAdded counter in reports); (b) POST /api/dev/workspace/validate — dry-run report (versions/dependencies, metaId links of created and updated rows, diff, breaking changes, name collisions; verdict green/warnings/blocked); (c) POST /api/dev/workspace/apply — validate → snapshot whole stand → import + invariants → schema sync → entity build → compile models by graph; any failure after snapshot rolls back the stand (created rows are deleted by apply-manifest.json in reverse order, design is restored by reapplying snapshot files, stand is recompiled). POST rollback — manual rollback by snapshot. Remaining: physical tables created by schema sync are not dropped on rollback (same non-destructive policy as disabling a model).
  • VSC-7 ✅ (2026-08-17) — versions: Core.ModelVersion = assembly version on each start (ExecuteUpdate bypassing guard — runtime bookkeeping); semver gate against downgrade and enforce MinVersion in validate (block apply); deployment log MetaWorkspaceDeployLogs (operation, source, snapshot, model versions with direction, verdict, counters, errors) is written on every apply/rollback, read by GET /api/dev/workspace/deploy-log; versions and publisher are already visible in the Models UI (Models page).

8. Extensions — VSC-8 concept (2026-08-18)#

Scenario: a user model adds its own fields and ITS OWN event handler to a Core object (dictionary Country). Fields are row-layered by ModelId. Events are Chain of Command (§8.1): top layer starts first, next(...) goes down to the owner.

8.1. Model#

  • Chain of Command by layers: EventExecutor keeps a LIST of handlers per object, sorting = owner first, then model layer, then name. Owner is the handler in the model OF THE OBJECT itself; it is always link 0, even if an extension is on the same layer (Sales and Costing both on layer 2 — an earlier name order once put the foreign handler inner). The list is inner→outer. A hook calls the outer link that declared the override; next(same arguments) continues down and returns result. Work after next() sees what lower links wrote — the same side-effect order as the old “left to right” subscription. Forgotten next on a link above owner is ZOCOC001; owner does not call next, there is nobody below. A handler on an object of ANOTHER model must declare class attribute [ExtensionOf("Object")] (name is checked against script envelope) — otherwise ZOCOC004. Declaration, not computed position: the extension remains an extension when new models arrive. [Replace] swallows the chain. Failure (Cancel/Error) of a lower link is usually returned upward (if (!prior.Success) return prior). EventService loads ALL EventHandler scripts of the object from the effective set (disabled model removes its link). Classes are siblings; they do not inherit from one another. Services with one contract name IFoo are the same onion: GetService<IFoo>() starts from the top layer, next<T>(...) goes down. Forgotten next on an extension is ZOCOC002. A single implementation is not wrapped. Two implementations on one layer are an error, not “whichever is first”.
  • MetaDictionaryExtension, MetaDocumentExtension, MetaTablePartExtension, MetaRegisterExtension and MetaLinkTableExtension are first-class DESIGN-TIME aggregates, thin: name, target, modelId, description. Children (fields, handler, links, table-part columns, register dimensions/resources/analytics, link-table columns) are existing rows with optional ExtensionMetaId; RUNTIME DOES NOT CHANGE (effective set/codegen/schema sync work by rows as now). Extension = packaging, ownership, UX. A column on another model's table part, a dimension on another model's register and a column on another model's link table are the same kind of child as a header field.
  • Extension name = <Target>.<ModelName> (Country.WMS) — globally unique by construction, one extension per (target, model).
  • Rule: a field in an object of ANOTHER model only inside an extension (in your own — directly, as before). Existing “bare” foreign fields are migrated into an implicit extension by a startup sweep (idempotent, like EnsureSystemId).

8.2. Workspace layout — by types, mirroring base#

Decision 2026-08-18: NOT a common Extensions/ folder, but type folders next to the base ones; folder and files carry the full extension name:

WMS/
  Dictionaries/…                      ← model’s own objects
  DictionaryExtensions/
    Country.WMS/
      Country.WMS.extension.json      ← aggregate: metaId, target (metaId+name), fields
      Country.WMS.events.cs           ← handler; in chain after Core
  Documents/…
  DocumentExtensions/
    GoodsReceipt.WMS/…
  TablePartExtensions/
    SalesOrderLines.WMS/…             ← columns added to another model's table part
  RegisterExtensions/
    Inventory.WMS/…                   ← dimensions, resources, analytic bindings
  LinkTableExtensions/
    ItemTags.WMS/…                    ← columns added to another model's link table

Target is resolved by metaId with fallback by name (Core ids can differ between stands; separate item — fixed metaId in Core seed).

8.3. Creation#

  1. Designer, lazily (human): work in the context of your model, open Country — base is read-only; “+ Field” creates aggregate Country.WMS (if missing) and a field in it in one action. Events tab — “extension handler” the same way. No wizard.
  2. Files (agent): writes DictionaryExtensions/Country.WMS/* in its model, then usual validate → apply.
  3. API: POST /api/metadata/extensions { targetType, targetMetaId, modelId }.

8.4. UI — without new designers#

Existing designers get ROW read-only (today the gate is object-level — readOnlyModelIds already propagated): base rows read-only with model badge (ModelTag exists), own rows editable. Events tab shows chain in layer order. The only new screen is extension registry (name · target · model · composition · delete with impact preview) + “extended by: …” node on object and a section on model card.

8.5. Hard dependency gate#

Rule: an inter-model reference (extension target, EDT field, target dictionary, script on foreign object) is legal only inside the TRANSITIVE closure of MetaModelDependency of the owner. Enforcement points:

  1. creation (designer/API): no WMS→Core edge — refusal (UI offers to add dependency); layer of extending model strictly above target layer;
  2. validate/apply: reference outside closure = blocked with missing edge named; MinVersion on edge is already checked (VSC-6b);
  3. dependency edge deletion is blocked while extensions/references use it;
  4. startup sweep: legacy violations first as warnings, after stabilisation — hard.

8.6. Work order (status 2026-08-18)#

  • (a) Event chain ✅ — Chain of Command: top layer first, next(...) down, PreviousResult after next, ZOCOC001 / ZOCOC004 / [Replace]. EventService loads all EventHandler scripts of the object from the effective set, including objectType: TablePart (document lines: OnValidateField / OnFieldChanged, parent in Owner<T>(context) / Data["header"]) and objectType: Dictionary / Document (card: same hooks; header has lines in header.Lines if the form sent the graph). Live: POST /api/events/tablepart/field, POST /api/events/dictionary/field, POST /api/events/document/field without persist. Bench dogfoods: TestBenchExt (layer 2) with extension handler on TBWarehouse. Services IFoo ✅ — same onion, next<T> / ZOCOC002.
  • (d) Depends gate ✅ — ModelDependencyService (closure, violation scan); validate checks references of created/updated rows by closure (ZuloOne:StrictModelDependencies: false — warnings, true — block) + layer discipline §9.2; deleting a carrying edge — 409; startup sweep.
  • (b) Extension aggregates ✅ — MetaDictionaryExtension/MetaDocumentExtension/MetaTablePartExtension/MetaRegisterExtension/MetaLinkTableExtension, ExtensionMetaId on fields/scripts, lazy creation in designer path, startup sweep migration of “bare” rows, layout §8.2 (DictionaryExtensions, DocumentExtensions, TablePartExtensions, RegisterExtensions, LinkTableExtensions — each <Target>.<Model>/), registry /api/metadata/extensions, MetaModel.IsSealed + SealedDevMode in guard.
  • (e) Code inheritance ✅ — MetaScript.BaseScriptMetaId; extension compiles into base assembly (ancestor chain + root framework part), runtime instantiates the MOST DERIVED class; scripts with base do not execute on their own; validate warns on override without base.(). Bench dogfoods: TBReceiptTxExt inherits TBReceiptTx (marker 777.77).
  • (c) UI — partially ✅: registry /extensions (list, cascading delete); working model already existed (ModelContext) — default taken away from protected models, ModelType removed from UI (§9.2). CLOSED 2026-08-18: row read-only in field designers (common readOnlyModelIds in ModelContext — system/sealed/foreign under working model; “+ Field” on a foreign object creates field in working model → lazy extension), handler chain in Events slot (GET /api/metadata/scripts/event-chain without model scope, click a link, “add extension handler”).

Original plan

(a) event chain + (d) depends gate are independent of the new object and strengthen current delivery immediately; then (b) MetaDictionaryExtension + DictionaryExtension envelope + layout §8.2; then (c) UI. Core tests: chain order and parent value visibility; disabling a model removes handler; creating extension without dependency is rejected; layout §8.2 round-trip.

8.7. Extending CODE: CoC (events, services, driver, command, tx)#

The mechanism is one — Chain of Command (next Method(args)). Scripts are real C# classes. One ideology: the top layer starts first, next(...) down, after it you see what the owner did. [Replace] swallows. A tie on layer is an error.

  • Events — Chain of Command (§8.1): sibling classes by layer, owner always link 0. Forgotten next on a link above owner — ZOCOC001; undeclared foreign-object handler — ZOCOC004 ([ExtensionOf("…")]). Events do not inherit from each other.
  • Services — same by contract name IFoo. Forgotten next on extension — ZOCOC002.
  • Driver / command / one tx — vertical CoC on this script (strip “Add yours above”). Forgotten next on extension — ZOCOC003. Owner calls engine base. or writes postings / ExecuteAsync itself. A second adjacent driver, a second command “with the same meaning”, or a new tx instead of a wrapper is not this mechanism.
  • Several tx on a subtype — horizontal list by executionOrder (store + costing). They are not glued into one onion.
  • Wrapper script is MetaScript of ITS OWN model; live-sync both ways. New links use next / nextAsync.

8.8. Creating code extensions and protecting the lower model (2026-08-18)#

Creation — designer (button “Add yours above” on script strip; template with next, for events also [ExtensionOf("Object")]), files + envelope, API.

Protection of lower-model code has three layers:

  1. Write: row gate by row model already works (Core: designers/API + SystemModelGuardInterceptor + sync only down). Generalisation — MetaModel.IsSealed: a model installed from package is sealed on customer stand by the same gate (unseal is deliberate mode, like CoreDevMode). Rule: foreign code is not edited anywhere — change by new package release, add by extension in your own model.
  2. Surface: C# encapsulation — base author controls contract (private/protected virtual/sealed); we do not need CoC-style “wrap everything”.
  3. Compatibility: extension compiles against base assembly — signature change by vendor fails compile in apply pipeline → automatic rollback by snapshot with clear report; semver major bump signals contract change.

9. Model-development discipline — concept (2026-08-18)#

9.1. Current development model#

Per-user setting “working model” (selector in Studio header; stored in user settings). All designers create new objects in it by default; it is also the reference point for the row overlay mode §8.4 (foreign object read-only, “+ Field” creates an extension in the CURRENT model). Selection gate: current model cannot be IsSystem (without CoreDevMode) or IsSealed — developing in a sealed model is impossible by construction. API: modelId not passed → user’s working model. Workspace does not change: the folder where the agent writes is the model.

9.2. Model types: ModelType is a leftover#

Domain types (CRM/WHS/Tax) are name/purpose and belong in Name/Description/Publisher. Functional typing is covered by three orthogonal attributes: IsSystem (core?), IsSealed (can it be edited on this stand?, §8.8), LayerId (who overlays whom?). Even the Core/Custom pair duplicates IsSystem. Decision: remove ModelType from UI and create contract (column remains until final migration, value derived). Layer convention: 1 = Core, 2 = vendor modules, 3 = partner/integrator, 4 = customer. Layer is assigned on creation and must be NOT BELOW dependency layers — part of depends gate §8.5 (an extension is always in a layer above the extended object).

9.x. Status (2026-08-18)#

§9.1 working model — ✅ CLOSED: PROFILE setting (MetaUser.WorkingModelMetaId, PUT /api/auth/me/working-model, /me returns it) follows the user between machines; frontend (ModelContext) initialises from profile and saves selection on server, localStorage is only cache until profile load; enforcement is the same — header X-ZuloOne-Model + WorkingModelGuardInterceptor. Default under “all models” removed from system/sealed. §9.2 — ModelType removed from UI and create form (DB column remains until clean migration). §9.3 licences — ✅ CLOSED 2026-08-18: MetaModelLicense + ModelLicenseService (signature ECDSA P-256 instead of Ed25519 — native in .NET; trust store = Licensing:TrustedPublishers, outside git; a stand without trust store does not require licences); enable gate in ModelsController; hourly runtime sweep ModelLicenseSweepService — non-destructive disable + audit record; licence installation in model card (document+signature). Remaining: advisory warning in workspace-validate.

9.3. Model licences#

Licence = publisher-signed document { modelMetaId, name, publisher, stand/customer, term, limits }; publisher public keys are in the stand trust store (not in git). Storage — MetaModelLicense, upload through UI/API. Enforcement points: validate/apply (missing/expired → warning or block, publisher policy in manifest); enabling a model only with a valid licence; runtime — periodic check, expiration → non-destructive disable (IsEnabled: columns and data live) + banner. Deployment log records which licence was used. Reincarnation of MIQS LicenseFile at model level.

Was this page helpful?