This is the full developer documentation for Genie # Genie > Describe your data and screens in small XML files. Genie turns them into working, model-driven tables and forms — with auth, RBAC, workflow, wizards and more already built in. ## The 30-second version [Section titled “The 30-second version”](#the-30-second-version) You write small XML models — entities, views, navigation, SQL. **`Genie.Source`** turns entities into EF Core tables at compile time; **`Genie.Engine`** serves them as JSON (metadata + data + RBAC) at runtime; **`genie-engine-ui`** renders them in React. The backend never returns HTML. Author XML Entities, views, navbar and SQL. One `*.view.xml` describes a grid *and* a form — capabilities are inferred from the SQL blocks present. Compile to tables A Roslyn source generator materialises `*.entity.xml` into EF Core entities so a normal `dotnet ef migrations add` creates real tables. Serve as JSON The engine resolves views per request, runs the SQL, enforces RBAC, and returns metadata + values — never markup. Render in React `createGenieApp({...})` wires a configurable React 19 app: tables, forms, views, navbar, auth, theme and an eval-free expression engine. ## Explore the guide [Section titled “Explore the guide”](#explore-the-guide) Introduction The mental model, the three packages, and the JSON-not-HTML rule. [Start here](/introduction/overview/). Model Authoring The complete XML contract — [entities](/model-authoring/entities/), [views](/model-authoring/views/), layout, actions, imports and SQL. Integration Drop Genie into your [backend](/integration/backend/) and [React frontend](/integration/frontend/). Security [RBAC](/security/rbac/), the disclosure gate, [multi-tenancy](/security/multi-tenancy/) and identity. # Architecture > The unified Object model, the facade over chunk services, and how a request flows. This page explains how the engine is put together — enough to know *where* a feature lives and *how* a request turns into JSON. For the day-to-day authoring surface, jump to [Model Authoring](/model-authoring/files-and-conventions/). ## The unified Object model [Section titled “The unified Object model”](#the-unified-object-model) A `View` used to be two parallel types (`TableView` / `FormView`). It is now **one `ObjectView`** (defined in `Genie.Source`), with capabilities inferred from which SQL blocks are present. The runtime surface is a **facade over focused chunk services** (interface segregation): ```text ┌─▶ ObjectGridService grid / table ops ObjectController ─▶ IObjectService ──┼─▶ ObjectFormService structure · values · submit (the facade) └─▶ IViewResolver ──┬─▶ ViewStore (ObjectView JSON) └─▶ IViewRegistry (system views) ``` `ObjectService` is a thin delegator — the real logic lives in the chunk services and their helpers (`FormServiceHelper`, `TableServiceHelper`, `ParameterSanitizer`) under `Services/Object`. Views are resolved either from the **`ViewStore`** (the EF table holding polymorphic `ObjectView` JSON, populated by model migration) or from a hardcoded **`IViewRegistry`** for system views (Auth, Workflow, Wizard) — each shipping both SQL Server and PostgreSQL variants. ## How a request flows [Section titled “How a request flows”](#how-a-request-flows) 1. The React client calls `GET /object/{name}/metadata` **once** to get the SQL-free schema, then decides whether to render a table, an editable form, or a read-only view. 2. For a grid it calls `POST /object/{name}/table` with filters/paging; for a record it calls `POST /object/{name}/form` or `/view`. 3. The controller delegates to `IObjectService`, which resolves the `ObjectView`, runs the appropriate SQL block through the dual-DB query path, and applies RBAC + parameter sanitisation. 4. The result is wrapped in a `SuccessDataResult` envelope. The UI merges **structure** (from metadata) with **values** (from form/view) client-side. See the [API Reference](/api-reference/overview/) for the exact endpoint contracts. ## Metadata vs data: the disclosure boundary [Section titled “Metadata vs data: the disclosure boundary”](#metadata-vs-data-the-disclosure-boundary) This split is central to the security model — respect it when adding features: * **Metadata** (`GET /object/{name}/metadata`) is SQL-free structure. It never returns SQL or record values, and is ungated beyond authentication. * **Data** (`POST /object/{name}/{table,form,view}` and the mutation endpoints) is gated by the RBAC **data-disclosure gate**: loading record values requires View/Update, `submit` requires Create/Update, `delete-row` requires Delete. Any `permissions` / `canSubmit` flags in a DTO are **UI hints only** — the server re-validates on every operation. See [The disclosure gate](/security/disclosure-gate/). ## Errors [Section titled “Errors”](#errors) A global exception handler turns any unhandled exception into a consistent JSON envelope `{ success: false, error, traceId }`, with a status code mapped from the exception type (`UnauthorizedException` → 401, `NotFoundException` → 404, `ArgumentException`/`RuntimeException`/`GenieException`/`InvalidOperationException` → 400, else 500). Throw the right exception type from services and let the handler shape the response. See [Errors & exception handling](/integration/errors/). ## Persistence [Section titled “Persistence”](#persistence) `GenieContext` (EF Core) runs over SQL Server **and** PostgreSQL, selected by `Genie:Datasource`. The `ViewStore` table holds polymorphic `ObjectView` JSON. Traits add audit, tenant (`CompanyId`) filtering, and soft-delete. Redis backs the cache (refresh tokens, MFA gates) and the DataProtection key ring. Dual database Anything touching SQL must work for both SQL Server and PostgreSQL — hardcoded system views ship both dialects. Keep this in mind when authoring `*.sql` objects and view SQL blocks. # Overview > What Genie is, the mental model, and the single rule that shapes the whole codebase. Genie is a **low-code framework**: you describe your data and screens in small XML files, and Genie turns them into working, model-driven **tables and forms** backed by a real database — with auth, RBAC, workflow, wizards, sequences, imports/exports and notifications already built in. This repository is a **re-platform** of the original server-rendered Genie into a **JSON API + React front end**. The single rule that shapes the whole codebase follows from that: The one rule The backend returns **data + model metadata as JSON — never HTML**. All rendering lives in the React UI. Structure and values come from the engine; layout, DOM and presentation are owned by the UI. ## The mental model [Section titled “The mental model”](#the-mental-model) ```text author XML compile + run render *.entity.xml ─┐ ┌─ Genie.Source (source gen) ─┐ ┌─ genie-engine-ui (React) *.view.xml ─┼──────▶ │ → EF entities + tables │ JSON │ GenieTable / GenieForm / *.sql ─┤ ├─ Genie.Engine (runtime) │ ─────▶ │ GenieView / GenieNavbar *.navbar.xml ─┘ └─ → metadata + data + RBAC ─┘ └─ from createGenieApp({...}) ``` 1. **You write XML models** — entities (tables), views (grids/forms), navigation, and SQL objects. 2. **`Genie.Source`** (a Roslyn source generator + the schema types + the XML parsers) turns `*.entity.xml` into strongly-typed EF Core entities at **compile time**, so a normal `dotnet ef migrations add` materialises real tables. 3. **`Genie.Engine`** (the runtime) loads the views/entities at **startup** (model migration), resolves them per request, runs the SQL, enforces RBAC, and returns **JSON** (metadata + values). 4. **`genie-engine-ui`** (React) consumes that JSON and renders the tables, forms, views, navbar, auth screens and theme — configured entirely in code via `createGenieApp({...})`. ## A view is one unified object [Section titled “A view is one unified object”](#a-view-is-one-unified-object) A `*.view.xml` file (root element ``) describes **one object**. Whether it behaves as a grid, an editable form, or a read-only view is **inferred from which SQL blocks it declares** — there is no separate “table view” vs “form view” type. Internally this is the unified `ObjectView`, and the runtime is a facade over focused query / form / action services. ## Endpoints: structure vs data [Section titled “Endpoints: structure vs data”](#endpoints-structure-vs-data) A boundary worth knowing up front — the React client reads the **schema once**, then loads data: * **`GET /api/v1/genie/object/{name}/metadata`** — the object’s **SQL-free schema** (type, capabilities, columns, fields, layout). Static and cacheable; no SQL, no per-user row values. The UI decides *table vs form vs view* from this. * **`POST /api/v1/genie/object/{name}/table`** — a page of grid **rows** (+ pagination + per-user permission facts). * **`POST /api/v1/genie/object/{name}/form`** / **`/view`** — a single record’s **field values**, editable or read-only. * **`POST /api/v1/genie/object/{submit,delete-row,execute-row-action,export-data,import-data,…}`** — mutations and bulk operations, behind the RBAC data-disclosure gate. Every response is wrapped in a `SuccessDataResult` envelope. See the [API Reference](/api-reference/overview/) for the full list. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * **[Quickstart](/getting-started/quickstart/)** — run the sample and add your first entity + view in minutes. * **[The three packages](/introduction/packages/)** — what `Genie.Source`, `Genie.Engine` and `genie-engine-ui` each do. * **[Model Authoring](/model-authoring/files-and-conventions/)** — the complete XML authoring reference (every field type, layout, action, filter, badge). * **[Integration](/integration/backend/)** — drop Genie into your own backend + frontend. # The three packages > What Genie.Source, Genie.Engine and genie-engine-ui each do. This monorepo publishes three packages. Install/auth details are in [Consuming the packages](/packages/consuming/). | Package | Type | Path | | ------------------------------------- | ---------------------- | ------------------------ | | `Genie.Source` | NuGet (netstandard2.0) | `src/api/Genie.Source` | | `Genie.Engine` | NuGet (net10.0) | `src/api/Genie.Engine` | | `@orbyn-technologies/genie-engine-ui` | npm (React 19 + TS) | `src/ui/genie-engine-ui` | ## `Genie.Source` — the contract & code generator [Section titled “Genie.Source — the contract & code generator”](#geniesource--the-contract--code-generator) The **compile-time** half and the source of truth for the XML contract. * **XML parsers** (`ViewSchemaXmlParser`, `EntitySchemaXmlParser`, `NavbarXmlParser`, …) — turn the `*.entity.xml` / `*.view.xml` / `*.navbar.xml` files into strongly-typed schema objects. * **The view-model schema** — `ObjectView`, `ObjectSql`, the `EditorField` family, the `FormControl` layout tree (Section / Tab / Item / Line / Sub / Grid / Row / Column), and the `EntitySchema` / `Field` model. * **The Roslyn incremental generator** — reads `*.entity.xml` files passed as `AdditionalFiles` and emits, into the **consuming** project: * an EF entity class per entity (deriving `EntityTraits`), with enums for `Select` fields and FK navigations for `Lookup` fields; * an `EntityBaseConfiguration` per entity (table / schema / indexes / relationships); * a `DbSet<>` for each entity, emitted as a **partial of your host `DbContext`** (the class name is configurable via the `GenieDbContextName` build property; default `ZedContext`). Because the generated configurations are `IEntityTypeConfiguration` in your assembly, your `GenieContext`-derived context registers them automatically. ## `Genie.Engine` — the runtime [Section titled “Genie.Engine — the runtime”](#genieengine--the-runtime) The **net10.0** runtime that host apps reference. Seven concern roots: ```plaintext Api/ Controllers + SignalR hubs (the HTTP surface), segregated per area Core/ Foundation, no business logic: Abstractions, Framework, Persistence Services/ Engine-internal runtime services: Object, ViewResolution, Authorization, … Features/ Bounded capabilities: Identity, Workflow, Wizard, Assistant, Reports, … Contracts/ Request/result DTOs Handlers/ Pluggable export/import handlers Hosting/ Exception handler, middleware, security, DI composition ``` Highlights: * **Unified `ObjectView`** — a view is one polymorphic object; whether it behaves as a grid or a form is inferred from which SQL blocks it declares. The runtime is a facade (`IObjectService`) over focused chunk services (query / form / action). * **Model migration on startup** — the migration service scans the `models` directory and applies each file via a per-type **strategy** (entity → store, view → `ViewStore`, SQL → executed, navbar → synced). See [Model migration](/integration/model-migration/). * **Identity & RBAC** — JWT + cookie auth, MFA/TOTP, sessions; per-object View/Create/Update/Delete verbs re-validated on **every** operation. Also workflow, wizard, reports, sequences, search and notifications. ## `genie-engine-ui` — the React front end [Section titled “genie-engine-ui — the React front end”](#genie-engine-ui--the-react-front-end) A configurable React 19 + TypeScript app. You call `createGenieApp(config)` and it renders: * `GenieTable` / `GenieForm` / `GenieView` — driven by the engine’s metadata + values; * `GenieNavbar` / `GenieLayout` / `GenieRouter` — `/table|form|view/{name}` routing; * the auth experience (login, MFA, profile), theme, and an eval-free expression engine for field required/disabled/hidden/value rules. The DOM/container ids are generated **client-side** — the server ships no markup. See [Frontend & Theming](/frontend/components/). # Quickstart > Run the Inventory reference app, then author your own entity and view in about ten minutes. Get the reference app running, then add your own entity + view. About 10 minutes. New here? Read the **[Overview](/introduction/overview/)** first for the mental model — XML models in, JSON metadata + data out, React renders it. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * **.NET SDK** matching [`global.json`](/introduction/packages/) (net10.0, SDK 10.0.300+). * **Node 22+** and npm (for the React UI). * **SQL Server** reachable at `Server=.` (the local default instance, integrated security). No Redis required — the Inventory sample runs its cache **in-memory**. Adjust the connection string in `sample/Inventory/api/appsettings.json` for your environment: ```json "ConnectionStrings": { "SqlServer": "Server=.;Database=InventorySample;Integrated Security=true;TrustServerCertificate=True;MultipleActiveResultSets=True" } ``` ## 1. Build [Section titled “1. Build”](#1-build) ```bash dotnet build src/Genie.slnx # .NET libraries + source generator cd src/ui/genie-engine-ui && npm ci && npm run build # the React package ``` ## 2. Run the reference app (Inventory) [Section titled “2. Run the reference app (Inventory)”](#2-run-the-reference-app-inventory) ```bash # backend → http://localhost:5184 # (creates the InventorySample DB, applies the migration, and seeds sample data on first run) dotnet run --project sample/Inventory/api # frontend → http://localhost:5183 (proxies /api, /files, /hubs to the backend) cd sample/Inventory/ui && npm install && npm run dev ``` Open : you get the login page, then the permission-filtered navbar and the engine’s table/form views. First login Sign in as `system` with `Genie:Auth:PasswordEncryption:SeedAdminPassword` (default `Admin@123`). Seeded sample users `buyer` and `approver` share the same password. Full sample wiring and notes are in `sample/Inventory/README.md`. ## 3. See the model files [Section titled “3. See the model files”](#3-see-the-model-files) The sample’s low-code models live in `sample/Inventory/models/`: ```plaintext models/ entities/ *.entity.xml Category, Product, Supplier, Warehouse, StockMovement, PurchaseOrder, PurchaseOrderLine (→ EF tables) views/ *.view.xml grids + forms for each object sql/ *.sql functions, stored procedures, triggers, reporting views navbar/ *.navbar.xml the navigation tree wizards/ *.wizard.xml multi-step wizards workflows/ *.workflow.xml workflow definitions ``` They are wired in `sample/Inventory/api/Inventory.Sample.Api.csproj`: `*.entity.xml` are fed to the source generator as `AdditionalFiles`, and **every** model file is copied to the build output so the runtime model migration can find it. ## 4. Add your own entity [Section titled “4. Add your own entity”](#4-add-your-own-entity) Create `sample/Inventory/models/entities/Brand.entity.xml`: ```xml ``` Don’t declare an `Id` Every entity inherits `Id` plus audit / tenant (`CompanyId`) / soft-delete (`IsDeleted`) columns from `EntityTraits`. The full field reference is in [Model Authoring](/model-authoring/entities/). The generator picks it up on the next build (it matches the existing `AdditionalFiles` glob) and emits a `Brand` entity, its EF configuration, and a `DbSet Brands` on `InventoryContext`. ## 5. Materialise the table (EF migration) [Section titled “5. Materialise the table (EF migration)”](#5-materialise-the-table-ef-migration) The generated entity classes are part of `InventoryContext`, so a normal migration creates the table. **Stop the running host first** (EF rebuilds the project, which can’t overwrite a locked, running binary): ```bash dotnet ef migrations add AddBrand \ --project sample/Inventory/api \ --startup-project sample/Inventory/api \ --context InventoryContext \ --output-dir Migrations/InventoryMigrations ``` Run the host again; the migration is applied at boot (`Database.Migrate()`). ## 6. Add a view [Section titled “6. Add a view”](#6-add-a-view) Create `sample/Inventory/models/views/Brands.view.xml` — the root element is `
`, and whether it behaves as a grid, a form, or a read-only view is inferred from which SQL blocks it declares: ```xml
``` Views are loaded into the engine’s `ViewStore` by the **model migration** at startup. The Inventory sample already enables it in `sample/Inventory/api/appsettings.json`: ```json "Genie": { "Migration": { "MigrationExecution": "Forced", "ModelsPath": "models" } } ``` `No` skips migration, `Yes` applies only changed files (hash check), and `Forced` re-applies every file regardless of hash. `ModelsPath` says where the model files are — relative to the app output (as here, where the sample copies `../models/**` into `bin/.../models`) or an absolute path. See [Model migration](/integration/model-migration/). ## 7. Use it [Section titled “7. Use it”](#7-use-it) Point the browser (or the navbar) at `/object/brands` — the UI route uses the view’s kebab-case `Slug`. To add it to the navigation, drop an `` into `sample/Inventory/models/navbar/inventory.navbar.xml`: ```xml ``` Under the hood The React client reads the object’s SQL-free schema once from `GET /api/v1/genie/object/brands/metadata`, then loads data with `POST /api/v1/genie/object/brands/{table,form,view}`. Structure and values are separate calls, merged client-side. To make records findable from the **global header search** (enable it with `createGenieApp({ modules: { headerSearch: true } })`), mark a field `Searchable="true"` and add a `` block to the entity — see [Model Authoring](/model-authoring/entities/). *** ### Recap of the loop [Section titled “Recap of the loop”](#recap-of-the-loop) | Want to… | Edit | Then | | ---------------------------- | -------------- | --------------------------------------------------- | | Add/lengthen a column | `*.entity.xml` | rebuild → `dotnet ef migrations add` → run | | Change a grid/form | `*.view.xml` | run with `MigrationExecution` = `Yes` (or `Forced`) | | Add a stored proc / SQL view | `*.sql` | run with `MigrationExecution` = `Yes` | | Change navigation | `*.navbar.xml` | run with `MigrationExecution` = `Yes` | Next: the complete authoring reference → **[Model Authoring](/model-authoring/entities/)**. Ready to ship? **[Deployment](/integration/deployment/)** shows how to serve the built UI from the API host (`UseGenieSpa` — one deployable, zero CORS preflights) or behind a reverse proxy. # Object endpoints > The complete object surface — metadata, grid data, record values, mutations, import/export and uploads. All object operations live on a single controller, `ObjectController`, under: ```plaintext /api/v1/genie/object ``` Every endpoint is `[Authorize]`. An object is addressed by its **name** (the view name/slug). The former separate `/table` and `/form` controllers, and the legacy `GET /metadata/{name}` and `POST /object/values` routes, no longer exist — everything is unified here. ## Schema & data [Section titled “Schema & data”](#schema--data) | Method | Route | Returns | Purpose | Gate | | ------ | ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------- | | `GET` | `/{name}/metadata` | `MetadataResult` | SQL-free schema (the `ObjectView` with SQL stripped). The UI decides table vs form vs view from this. Static & cacheable. | auth only | | `POST` | `/{name}/table` | `TableResult` | A page of grid rows + pagination + per-user permission facts. | View | | `POST` | `/{name}/form` | `FormResult` | A record’s field values for an **editable** form (first row of the form SQL). | View / Update | | `POST` | `/{name}/view` | `FormResult` | A record’s field values for a **read-only** view (view mode forced). | View | `POST /{name}/table` accepts a `TableRequest` body (filters, search text, sort, page/pageSize). `POST /{name}/form` and `/{name}/view` accept a `FormRequest` body (the record’s primary-key / parent parameters). An omitted body is treated as an empty request. The `TableResult`/`FormResult` **permission facts** (`Permissions`) carry the standard verb booleans (`Create`/`Update`/`Delete`/`Export`/`Import`) plus a `Capabilities` list naming the caller’s [business capabilities](/security/rbac/#capabilities) beyond them (e.g. `["Approve"]`). Rows returned by `/table` and `/form`/`/view`, and exports, are already [row-filtered](/security/rbac/#filters) by the caller’s `List` filter, and each row may carry `Permit__` cells answering [per-row conditions](/security/rbac/#conditions). Read schema once The React client calls `/{name}/metadata` a single time, caches the structure, then repeatedly calls `/table`, `/form` or `/view` for data. Structure and values are merged client-side. ## Mutations [Section titled “Mutations”](#mutations) | Method | Route | Returns | Purpose | Gate | | ------ | --------------------- | -------------------- | ------------------------------------------------------------------ | --------------- | | `POST` | `/submit` | `ObjectActionResult` | Create or update a record (`SubmitRequest` — requires `FormName`). | Create / Update | | `POST` | `/delete-row` | `ObjectActionResult` | Delete a row (`DeleteRowRequest`). | Delete | | `POST` | `/execute-row-action` | `ObjectActionResult` | Run a declared row action’s SQL (`ExecuteRowActionRequest`). | per action | Mutation results can carry **dispatch envelopes** (e.g. `refreshTable`, `showAlert`) that tell the UI what to do next — see [Actions & row actions](/model-authoring/actions/). ### Optimistic concurrency (409) [Section titled “Optimistic concurrency (409)”](#optimistic-concurrency-409) When an entity declares [`Concurrency="Enabled"`](/model-authoring/entities/#concurrency), the record’s `RowVersion` stamp is loaded with the form and **sent back** in `SubmitRequest.Parameters` (and, for the atomic authored-SQL path, `DeleteRowRequest.RowVersion`). If the stored stamp has moved on since the record was loaded, the mutation is rejected with **HTTP 409 Conflict** (envelope `{ "success": false, "error": "This record was changed by someone else…" }`) instead of silently overwriting the other change. `RowVersion` is a reserved parameter — it passes the mass-assignment sanitizer and is never written as data. ### Form load time [Section titled “Form load time”](#form-load-time) `FormResult` (the `/{name}/form` and `/{name}/view` payload) carries **`FormLoadTime`** — the UTC instant (ISO round-trip) at which the record was loaded, stamped server-side. Like `RowVersion` it is a reserved parameter the client sends back in `SubmitRequest.Parameters`, and the submit binds it as `@FormLoadTime` for the authored SQL (falling back to “now” when a caller supplies none). It is never written as data. Being client-round-tripped it can be replayed — treat it as an audit/heuristic value, not a security control. See [Parameters the submit blocks always receive](/model-authoring/views/#parameters-the-submit-blocks-always-receive). ### Field validation (400) [Section titled “Field validation (400)”](#field-validation-400) A submit (or `/import-data`) that fails field validation — required, `RegexValidation`, a type bound, or an author-declared `` rule — is rejected with **HTTP 400** whose envelope carries `fieldErrors` (field name → message) alongside `error`, so the client can highlight and focus the offending field. Rules declared `Type="Sql"` run server-side only, which is exactly why the response has to name the field. See [Validation](/model-authoring/editor-fields/#validation). ### Idempotency (safe retry) [Section titled “Idempotency (safe retry)”](#idempotency-safe-retry) Every mutation above (plus `/import-data` and `/upload`) honours an optional **`Idempotency-Key`** request header. Send a unique key with a write and the engine remembers the first response (scoped per user + company, for `Genie:Idempotency:Ttl`, default 24 h); a repeat of the same key **replays that response** instead of re-running the mutation — so a network retry or double-submit can’t create duplicate rows. A duplicate that arrives while the first is still in flight gets a 409. Configure via [`Genie:Idempotency`](/integration/configuration/); omit the header to opt out per request. ## Export & import [Section titled “Export & import”](#export--import) | Method | Route | Returns | Purpose | Gate | | ------ | ------------------------------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------- | | `POST` | `/export-data` | `ObjectActionResult` | Generate an export file under `wwwroot/Exports` (`ExportTableRequest`). | View / Export | | `GET` | `/export-file/{fileName}` | file stream | Download a previously-generated export as an authenticated attachment. Bare file name only; traversal rejected. | auth | | `POST` | `/import-data` | `ObjectActionResult` | Bulk import (`ImportTableRequest`, multipart form). Body cap from `Genie:Uploads:MaxImportBytes` (default 1 GiB). | Import | | `GET` | `/{name}/import-template?format=xlsx\|csv` | `ObjectActionResult` | Generate a header-only import template; download via `export-file`. | Import | Import/export column mapping and custom handlers are authored in the view — see [Bulk import & handlers](/model-authoring/import/). ## Field datasets, sequences & uploads [Section titled “Field datasets, sequences & uploads”](#field-datasets-sequences--uploads) | Method | Route | Returns | Purpose | | -------- | --------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `POST` | `/field-dataset` | `List>` | Options for a dependent `Select` field (`FieldDataSetRequest` — requires `FormName` + `FieldName`). | | `POST` | `/sequence-number` | `string` | Preview/reserve a formatted sequence number (`SequenceNumberRequest`). See [Sequences](/platform/sequences/). | | `POST` | `/upload` | `List` | Upload temporary attachment files (multipart: `objectName`, `fieldName`, `viewId`). Size limit 100 MB. | | `DELETE` | `/upload/{uploadKey}` | message | Remove a temporary uploaded file before submit. | | `GET` | `/label/{objectName}` | `string` | The view’s display label. | ## Related object controllers [Section titled “Related object controllers”](#related-object-controllers) * `GET/POST /api/v1/genie/object-explorer/...` — the object explorer surface. * `/api/v1/genie/rpc/...` — stored-procedure / RPC calls (`ProcedureCallController`). See [Other endpoints](/api-reference/other-endpoints/) for the non-object controllers. # Other endpoints > The non-object controllers — auth, navigation, search, reports, notifications, wizards, workflows and admin. Beyond the [object surface](/api-reference/object-endpoints/), the engine exposes these controller areas. Each base route hosts the operations for one feature; follow the linked guide for behaviour and payloads. ## Identity & access [Section titled “Identity & access”](#identity--access) | Base route | Controller | Covers | | ------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/api/v1/auth` | `AuthController` | Login, refresh, logout, MFA challenge/verify, password reset. | | `/api/v1/genie/auth` | `AuthorizationBoardController` | The Access Dashboard: statistics, the access workspace (`/board/principals` for the rail and scope options, `/board/matrix` for one principal’s access, `/board/apply` for a batch of toggles), the access-explorer trees, and RBAC import/export. `/board/matrix` accepts a `Scope` of `All`/`Global`/`Company` that only ever narrows the caller’s own scope. Open to `System`, `Admin` and `AccessManager`; import/export is `System`-only. | | `/api/v1/genie/company` | `CompanyController` | Tenant/company listing & switching. | | `/api/v1/impersonation` | `ImpersonationController` | Start impersonating a user (System role) and stop again (any authenticated session — it runs as the impersonated user). | | `/api/v1/genie/timezone` | `TimeZoneController` | Get/set the user’s IANA time zone. | See [Identity](/security/identity/), [RBAC & permissions](/security/rbac/) and [Multi-tenant company scoping](/security/multi-tenancy/). ## Navigation, search & content [Section titled “Navigation, search & content”](#navigation-search--content) | Base route | Controller | Covers | | ----------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/api/v1/genie/navbar` | `NavbarController` | The permission-filtered navigation tree (badge-free, returns fast). | | `/api/v1/genie/navbar/badges` | `NavbarController` | Computed badge values (counts / dots) keyed by item name, fetched separately so the tree renders first. | | `/api/v1/genie/search` | `SearchController` | Global search (SQL or Meilisearch provider). | | `/report` | `ReportController` | Code-defined reports: `GET /report` lists them, `GET /report/{key}` generates one (`?inline=true` to preview rather than download). A **root** route, not under `/api/v1/genie`. | | `/files/{path}` | `FileController` | Streams a stored file (attachment/export/temp), inline where the type allows. Also a root route. | See [Navigation](/platform/navigation/), [Search](/platform/search/) and [Reports](/platform/reports/). ## Notifications [Section titled “Notifications”](#notifications) | Base route | Controller | Covers | | --------------------------- | ---------------------------- | --------------------------------- | | `/api/v1/notification` | `NotificationController` | A user’s notifications. | | `/api/v1/notifications` | `NotificationApiController` | Notification API surface. | | `/api/v1/push-subscription` | `PushSubscriptionController` | Web-push subscription management. | Real-time delivery is over SignalR — see [Notifications](/platform/notifications/). ## Wizards & workflows [Section titled “Wizards & workflows”](#wizards--workflows) | Base route | Controller | Covers | | ------------------------ | ---------------------------- | ------------------------------------------------- | | `/api/v1/genie/wizard` | `WizardController` | Wizard execution and form generation. | | `/api/v1/genie/workflow` | `WorkflowDesignerController` | Workflow definitions, instances and the designer. | See [Wizards](/wizards/overview/) and [Workflows](/workflows/overview/). ## Report pages [Section titled “Report pages”](#report-pages) Two calls, mirroring the object endpoints’ structure/data split: structure is SQL-free and fetched once, data is gated and re-fetched per filter change. | Method & route | Returns | Gate | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | | `GET /api/v1/genie/reports/{key}` | The report’s filters, widgets, layout and dataset names/modes. Every `[SqlBody]` property is stripped, so no query text leaves the server. | Authentication only | | `POST /api/v1/genie/reports/{key}/data` | Every dataset a visible widget reads, executed once each. | `List` on the report’s RBAC resource | `{key}` is the report’s `Name` or `Slug`. Plural `/reports` — distinct from the singular [`/report/{key}`](#files-jobs--scripting) at the API root, which belongs to the unrelated code-defined PDF feature. ```jsonc // POST /api/v1/genie/reports/inventory-overview/data { "Arguments": { "FltWarehouse": "3", "FltRangeFrom": "2026-08-27", "FltRangeTo": "2026-09-09" } } ``` ```jsonc // → SuccessDataResult { "success": true, "data": { "Name": "InventoryOverview", "RequiredFilterMissing": false, "DataSets": { "DsKpis": { "Columns": [{ "Name": "StockValue", "ClrType": "Decimal" }], "Rows": [{ "StockValue": "1284750.00" }] } }, "AccessibleWidgets": ["TlStockValue", "ChCategoryValue", "VwLowStock"] } } ``` Three things to note about the response: * **`RequiredFilterMissing: true` means no dataset ran at all.** A blank required filter gates the whole page; `DataSets` is empty and the client shows a prompt. * **`AccessibleWidgets` is authoritative.** A widget withheld by `RolesAllowed` is absent, and so is any dataset only it read — the client closes the layout over it. * **Every cell is a string or null**, numbers included, matching the grid. Client code must parse and guard `NaN`. An embedded `` widget is *not* served by these endpoints: it fetches through the ordinary [object endpoints](/api-reference/object-endpoints/) under its own resource, so embedding never launders a permission. See [Report pages](/reports/overview/) and [Runtime & sources](/reports/runtime/). ### Report designer [Section titled “Report designer”](#report-designer) Ten more calls, behind `ReportDesignerController`. Separate from the two above on purpose: those **run** a report for a viewer and are gated on the report’s own resource, these **edit** the document and are gated on the definitions grid’s resource — merging them would put a viewer’s read path and an author’s write path behind one permission. | Method & route | Returns | Gate | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | | `GET /api/v1/genie/report-designer/definitions/{key}` | The definition’s identity plus its source XML. `DefinitionXml` is `null` for a row stored before the XML was retained. | `View` on `ReportDefinitions` | | `POST /api/v1/genie/report-designer/definitions/save` | Upserts by the document’s own `Name`, so one call both creates and updates. A parse error is a 4xx carrying the parser’s message. | `Update` on `ReportDefinitions` | | `GET …/definitions/{key}/versions` | Every version, newest first, with the current one flagged. | `View` on `ReportDefinitions` | | `GET …/definitions/{key}/versions/{versionHash}` | The document as that version saved it. | `View` on `ReportDefinitions` | The draft lifecycle. `{key}` addresses the stored report by `Name` or `Slug` — never the draft document’s own name, which the author may have changed: | Method & route | Returns | Gate | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | | `GET …/definitions/{key}/draft` | The report’s draft, or `null`. Includes `IsStale`, true when the published document moved since the draft forked. | `View` on `ReportDefinitions` | | `POST …/definitions/{key}/draft` | Creates or replaces the draft. Not versioned, and **not parsed** — a draft is allowed to hold text the parser would reject. | `Update` on `ReportDefinitions` | | `DELETE …/definitions/{key}/draft` | Discards the draft. `false` when there was none — idempotent, so a stale grid’s second click is not a 404. | `Update` on `ReportDefinitions` | | `POST …/definitions/{key}/publish` | Promotes the draft: appends a version, repoints the report, clears the draft. `Unchanged: true` when the draft matched what was live (nothing appended, draft still cleared). A 4xx when the draft will not parse, or when publishing would rename the report onto a name another one holds. | `Update` on `ReportDefinitions` | | `GET …/definitions/{key}/draft/structure` | The **draft’s** structure, SQL-free. | `Update` on `ReportDefinitions` | | `POST …/definitions/{key}/draft/data` | The **draft’s** datasets. | `Update` on `ReportDefinitions` | The last two are `Update`, not `View`, and they match each other deliberately. Running an unpublished document is more than reading one; and giving structure the weaker gate would let a client render a layout it can never fill. They exist as their own endpoints rather than a `?draft=1` flag on `GET /reports/{key}` because that call is **ungated beyond authentication** — right for a published layout, a disclosure for an author’s work in progress. Row-level security in a draft preview resolves against the **stored report’s** resource name, so renaming a draft is not a way out of a row filter. ```jsonc // POST /api/v1/genie/report-designer/definitions/save { "DefinitionXml": " … ", "Notes": "Added the supplier-mix chart" } ``` ```jsonc // → SuccessDataResult { "success": true, "data": { "Name": "InventoryOverview", "Slug": "inventory-overview", "Created": false, // True when the incoming text matched what was stored: nothing written, no version appended. "Unchanged": false, "VersionHash": "4f9c2a" } } ``` Restoring a version is a `save` with the text `…/versions/{versionHash}` returns — it appends a new version rather than rewriting history, which is why there is no restore endpoint. See [The report designer](/reports/designer/). ## Files, jobs & scripting [Section titled “Files, jobs & scripting”](#files-jobs--scripting) | Base route | Controller | Covers | | ---------------------------------------------------- | -------------------------- | ---------------------------------------------- | | `/api/v1/genie/hosted-services` | `HostedServicesController` | Start/stop/inspect background hosted services. | | `/api/v1/hangfire` | `HangfireController` | Hangfire job info. | | `/api/v1/genie/execute-script` | `ExecuteScriptController` | Execute a registered server script. | | `/api/v1/genie/object/list`, `/api/v1/genie/scripts` | `ModelCatalogController` | Read the model catalog (see below). | | `/api/v1/genie/handlers` | `GenieHandlersController` | Pluggable export/import handlers. | | (files) | `FileController` | File download/serving. | ### The model catalog (System role) [Section titled “The model catalog (System role)”](#the-model-catalog-system-role) The read counterpart of `execute-script` — deploy writes the catalog, these read it back. All three are gated to the **System** role, because script bodies contain the authored SQL that the metadata endpoints deliberately never expose: | Endpoint | Returns | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `GET /api/v1/genie/object/list` | Every deployed object (ViewStore): `Name`, `Slug`, `Label`, `ViewType`, `VersionHash`. | | `GET /api/v1/genie/scripts?type=` | Executed scripts, **latest per name**, optionally filtered by type (`ObjectView`/`Entity`/`Navbar`/`StoredProcedure`/`Rbac`). | | `GET /api/v1/genie/scripts/{name}` | One script’s latest execution **including its verbatim authored body** (matched by `Name` or `FileName`). | This is what lets an external client (GenieClient CLI/MCP) enumerate a deployment with no local checkout, export the deployed XML, and diff a local model file against the source that is actually live (`VersionHash` is the MD5 of the deployed body, so an identical hash ends the question). See [Background jobs & hosted services](/platform/background-jobs/). ## The AI assistant [Section titled “The AI assistant”](#the-ai-assistant) The assistant chat is delivered over a SignalR hub rather than a REST controller, with a supporting natural-language-query endpoint. See [AI Assistant](/assistant/overview/). Discovering payloads The engine ships OpenAPI/Swagger annotations (`[SwaggerTag]`, `[SwaggerOperation]`). When running a host with OpenAPI enabled, the generated spec is the authoritative, always-current request/response reference for these controllers. # API overview > Base URL, authentication, the response envelope, and the structure-vs-data model. The engine exposes a JSON HTTP API. Everything below is served by ASP.NET Core controllers under `Genie.Engine/Api/**`. There is **no HTML** — the React UI owns all rendering. ## Base URL & versioning [Section titled “Base URL & versioning”](#base-url--versioning) All engine routes are versioned under `api/v1`. The main object surface is: ```plaintext /api/v1/genie/object/... ``` Other areas (auth, navbar, search, reports, notifications, wizard, workflow, …) live under their own `api/v1/...` prefixes — see [Other endpoints](/api-reference/other-endpoints/). ## Authentication [Section titled “Authentication”](#authentication) Every controller is `[Authorize]` unless noted. Authenticate with a short-lived **JWT bearer** token obtained from the auth endpoints, sent as: ```plaintext Authorization: Bearer ``` Tokens are RS256-signed and refreshable. See [Identity](/security/identity/) for the login/refresh flow and MFA. A `X-TimeZone` header (IANA zone) is honoured for session-local date handling; see [Timezone handling](/platform/timezones/). ## The response envelope [Section titled “The response envelope”](#the-response-envelope) Successful responses are wrapped in a `SuccessDataResult`: ```json { "success": true, "data": { /* the T payload */ } } ``` Errors are shaped by a global exception handler into a consistent envelope: ```json { "success": false, "error": "Human-readable message (for expected 4xx).", "traceId": "00-…" } ``` A **field validation** failure (400) additionally carries `fieldErrors` — field name → message — so a client can highlight and focus the offending field instead of only showing the joined text (see [Validation](/model-authoring/editor-fields/#validation)): ```json { "success": false, "error": "This code is already used by another product.", "traceId": "00-…", "fieldErrors": { "Code": "This code is already used by another product." } } ``` The HTTP status is mapped from the thrown exception type: `UnauthorizedException` → 401, `NotFoundException` → 404, `ArgumentException` / `RuntimeException` / `GenieException` / `FieldValidationException` / `InvalidOperationException` → 400, `ConflictException` → 409, otherwise 500. Whether 5xx responses expose the real message is config-driven (`Genie:Errors` — on in dev, off in prod). See [Errors & exception handling](/integration/errors/). ## The structure-vs-data model [Section titled “The structure-vs-data model”](#the-structure-vs-data-model) The single most important thing to understand about the object API: the client fetches the **schema once**, then loads **data** as needed. | Concern | Endpoint | Gated by | | ----------------------------------------------- | ------------------------------------ | ------------------------ | | **Structure** (schema, columns, fields, layout) | `GET /object/{name}/metadata` | authentication only | | **Grid data** (rows + paging) | `POST /object/{name}/table` | View | | **Record values** (form/view) | `POST /object/{name}/form` · `/view` | View / Update | | **Mutations** | `POST /object/{submit,delete-row,…}` | Create / Update / Delete | The metadata endpoint is SQL-free, static and cacheable, and never returns record values. Data endpoints run the view’s SQL and are gated by the RBAC **data-disclosure gate**. Any `permissions` / `canSubmit` fields in a response are **UI hints only** — the server re-validates on every operation. See [The disclosure gate](/security/disclosure-gate/). Full object endpoint reference: [Object endpoints](/api-reference/object-endpoints/). # Configuration & providers > Selecting and configuring an Assistant provider, wiring the backend and frontend, and tuning persistence, titles and error masking. The Assistant is configured entirely through the root **`Assistant`** configuration section (bound to `AssistantOptions`) plus two host calls on the backend and one module flag on the frontend. This page covers the providers, the wiring, and the runtime behaviour you can tune. For what the feature does, see the [AI Assistant overview](/assistant/overview/). For DB-enforced tenant isolation, see [Tenant isolation & SQL hardening](/assistant/sql-hardening/). ## Models [Section titled “Models”](#models) A chat picks the model that answers it, from the list under **`Genie:Assistant:Providers`**: ```jsonc "Genie": { "Assistant": { "Providers": [ { "Name": "DeepSeek R1", "Provider": "DeepSeek", "Endpoint": "https://api.deepseek.com/v1", "Model": "deepseek-reasoner", "MaxTokens": 10000, "IsDefault": true }, { "Name": "Local Llama", "Provider": "Ollama", "Endpoint": "http://localhost:11434", "Model": "codellama:7b" } ] } } ``` An **array**, so the order is the order the model picker lists — and the index an out-of-band [API key](#api-keys--keep-them-out-of-source) targets. `Name` is free text: it is what the picker shows and what a chat remembers, so it can read like a name rather than a model id. It must be unique; `IsDefault` marks the one a new chat starts on; `Enabled: false` takes a model out of service without deleting the entry that documents it. Why per-chat rather than per-deployment: the useful choice differs by question. A reasoning model is worth its latency for a report someone is about to publish, and wasted on “how many suppliers do we have”. Every model is reached through **`Microsoft.Extensions.AI`’s `IChatClient`**. The built-in kinds are all served by the OpenAI client pointed at the kind’s endpoint — DeepSeek, Ollama and Gemini speak OpenAI Chat Completions — and a host can bring any other provider as a `ChatClient` entry ([below](#bringing-your-own-ichatclient)). The kinds, and what goes with each: | `Provider` | Default `Endpoint` (optional) | Example `Model` | API key | | -------------------- | --------------------------------------------------------- | ------------------------------------ | ---------------------------- | | **OpenAI** | `https://api.openai.com/v1` | `gpt-4o-mini`, `gpt-5-mini` | required | | **DeepSeek** | `https://api.deepseek.com/v1` | `deepseek-chat`, `deepseek-reasoner` | required | | **Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.5-flash` | required | | **Ollama** | `http://localhost:11434` (served under `/v1`) | `qwen3`, `llama3.1:8b` | not required (local) | | **OpenAICompatible** | none — **required** | whatever the server serves | optional | | **ChatClient** | not used | optional (sent as `ModelId`) | the host’s client handles it | `Endpoint` is the **API root**. Leave it out and a hosted kind uses its own service. A trailing `/chat/completions` is accepted and stripped, so entries written with the full completions URL keep working; Ollama’s root gets `/v1` added, and Gemini’s native root (`…/v1beta`) is pointed at its OpenAI-compatible surface (`…/v1beta/openai`). Gemini is called through that surface on purpose: its native API took the key in the query string, where proxies and HTTP logs record it — the compatible surface takes it as a bearer header. Every entry is validated at **startup** — a missing or duplicate `Name`, or a `Provider` that is not one of the kinds above, fails there rather than on the first question someone asks. Per-entry settings beyond those: `MaxTokens`, `Temperature`, `TopP`, `TimeoutSeconds`, `StreamIdleTimeoutSeconds`, `EnableThinking`, `ReasoningEffort`, `OrganizationId`. What is the same for **every** kind, because it is decided once in the provider layer rather than per provider: * **Retries.** A `408`/`429`/`5xx` or a transport failure is retried twice, with backoff, before the turn fails. A rejected request (`400`/`401`/`404`) is never retried — it is refused identically every time — and is reported with the status, the URL dialled and the server’s own explanation (with a key-length hint on a `401`, never the key). * **Running out of tokens.** An answer cut off at `MaxTokens` triggers the assistant’s compaction retry on every kind. (Before, only DeepSeek and OpenAI’s reasoning path noticed.) * **Reasoning never becomes the reply** (see the thinking notes below). * **A streamed answer that goes silent** is abandoned after `StreamIdleTimeoutSeconds`. * **Keys stay out of errors and logs**: any echo of the key in a provider’s error body is redacted before it reaches an exception, the [turn trace](#tracing-a-turn) or the log. * **OpenTelemetry**: each model call is an `Activity` on the `Genie.Assistant` source (a no-op unless a listener is attached), recorded **without** message content — the turn trace remains the only place chat text is logged. Newer OpenAI models are adapted to automatically The GPT-5 and o-series families reject settings the older ones accept — `max_tokens` must be `max_completion_tokens`, and neither `temperature` nor `top_p` is supported at all. You do not configure this. Those families are **known up front**, so a `gpt-5-*` or `o*` model on OpenAI’s own endpoint sends the right request first time, with no probe. Anything else — a new family, or an OpenAI-compatible server with its own rules — is learned from the server’s own rejection: the provider corrects itself, retries, and remembers the answer for that model on that endpoint, so a correction costs one round trip once rather than one per turn. The assumption is only made for OpenAI’s own service. A self-hosted server may answer to the name `gpt-5-mini` while accepting the ordinary parameters, and a guess is not evidence. Every other server is sent `max_tokens`, the name llama.cpp, LM Studio, DeepSeek and Ollama understand. It only ever drops parameters Genie chose to send, and only when the error names that parameter with an `unsupported_parameter` / `unsupported_value` code. A rejection about `model` or `messages` teaches it nothing and is reported as-is, because those are the request rather than a tuning knob. ## Running a local model (llama.cpp, Ollama, vLLM, LM Studio) [Section titled “Running a local model (llama.cpp, Ollama, vLLM, LM Studio)”](#running-a-local-model-llamacpp-ollama-vllm-lm-studio) Ollama has its own kind; any other server that exposes the OpenAI API (`llama-server`, vLLM, LM Studio, a proxy) is an **`OpenAICompatible`** entry with its API root as `Endpoint`: ```jsonc { "Name": "Local Qwen", "Provider": "OpenAICompatible", "Endpoint": "http://my-host:18437/v1", "Model": "qwen2.5-coder-7b" } ``` No `ApiKey` is needed for either. Beyond the endpoint: * **Size the options to the server’s context window**, not to a hosted model’s. For a `--ctx-size 8192` server, the 10000-token `MaxTokens` default is larger than the entire window; llama.cpp will reject or clamp it. The prompt and the reply share that budget: | Prompt component | Approx. tokens | | ------------------------------------------ | -------------- | | Built-in rules and formatting instructions | \~1,400 | | Tool definitions (5 information tools) | \~600 | | Schema context at `SchemaTableLimit: 5` | \~600 | | User context, app-docs index | \~400 | That leaves roughly 5,000 for history, the question and the answer. Lower `SchemaTableLimit` and `ConversationHistoryLimit` to buy headroom; the tables you drop stay reachable via `get_table_schema`. * **CPU inference is minutes, not seconds.** Token generation is memory-bandwidth bound, so a 7B Q4 model runs at single-digit tokens/second whatever the core count. With `EnableThinking: false` the call does not stream, so raise `TimeoutSeconds` to cover a whole response body, and raise `RequestTimeoutSeconds` either way. Turn the [trace](#tracing-a-turn) on to see where the time actually goes. * **Thinking models stream their reasoning.** With `EnableThinking` on (the default), Ollama and `OpenAICompatible` entries stream, and a reasoning model’s `reasoning` / `reasoning_content` field is shown in the **Thinking** panel — never as the reply. Report authoring needs a large context window The report tools put the whole report definition in the prompt and ask the model to return a whole revised one. A real report is 13–14 KB — roughly 4,000 tokens each way — so on an 8k-context server the input alone leaves no room to answer. Small local models can run the `information` category comfortably; report authoring needs a model with a large window. Raising `--ctx-size` on the server is what buys the headroom — the window is the server’s, and Genie’s options only decide how much of it the prompt spends. A small report (1–2 KB) is workable at 8k; the 13 KB ones are not. DeepSeek thinking mode `EnableThinking` (default `true`) only applies to **`deepseek-reasoner`**: it streams a chain-of-thought (`ReasoningEffort` `"high"` by default, or `"max"`) that the UI shows as a live, collapsible **Thinking** block. On `deepseek-chat` there is no chain-of-thought, so the assistant uses the plain non-streaming completion regardless of `EnableThinking`. Any model name DeepSeek serves is accepted — a new model needs no engine release. (An earlier allowlist of three names existed only to dodge an empty, never-ending stream; the `StreamIdleTimeoutSeconds` deadline covers that for every kind now.) The chain-of-thought **never becomes the reply**. A reasoner can end a pass having written a full set of working notes and no answer at all; the notes stay behind the Thinking toggle and the model is asked once to write the answer it skipped. If it still writes nothing, the turn reports that plainly — what it must never do is hand the user the model’s notes to itself. GPT-5 / o-series thinking mode `EnableThinking` (default `true`) also applies to OpenAI’s reasoning families — `gpt-5*`, `o1`, `o3`, `o4` — but **only against OpenAI’s own hosted API**. Chat Completions (`/v1/chat/completions`) never exposes a reasoning trace for these models at any `ReasoningEffort`. The only place OpenAI streams anything about a model’s thinking is the separate **Responses API** (`/v1/responses`), so a reasoning-family model on `api.openai.com` is routed there instead, and its reasoning summary is streamed through the same Thinking panel DeepSeek uses. The request asks OpenAI to keep **no** server-side copy of the turn (`store: false`): Genie persists the conversation itself. What streams is a **summary** the model writes about its own reasoning, not the underlying chain-of-thought — OpenAI does not return that from any endpoint. `gpt-5-mini` in particular has been observed to return an empty summary on straightforward turns; that is the model choosing to say nothing, and the Thinking panel is simply empty for that turn. A self-hosted or proxied server answering to a reasoning-family model name (a local llama.cpp claiming `gpt-5-mini`, say) is **not** routed to `/v1/responses` — it is not guaranteed to implement that API at all. Only OpenAI’s own endpoint (a blank `Endpoint`, or one containing `api.openai.com`) takes this path; everything else, and `EnableThinking: false`, uses Chat Completions. ## Bringing your own `IChatClient` [Section titled “Bringing your own IChatClient”](#bringing-your-own-ichatclient) Any provider with a `Microsoft.Extensions.AI` implementation — Azure OpenAI / Foundry, Amazon Bedrock, Anthropic’s C# SDK, Google’s `Google.GenAI`, OllamaSharp, or your own — can back a model in the picker without engine code. Declare the entry with `Provider: "ChatClient"`, and register the client under the **entry’s `Name`** after `AddGenieAssistant` / `AddGenieApp`: ```jsonc { "Name": "Company Claude", "Provider": "ChatClient", "Model": "claude-sonnet-5", "MaxTokens": 8000 } ``` ```csharp // Program.cs — after AddGenieAssistant / AddGenieApp: builder.Services.AddKeyedChatClient("Company Claude", sp => /* your IChatClient */); ``` The entry keeps everything configuration gives the built-in kinds — its place in the picker, `IsDefault`, `Enabled`, `MaxTokens` / `Temperature` / `TopP` (sent as `ChatOptions`), `Model` (sent as `ModelId` when set), `ReasoningEffort` (when it names a `ReasoningEffort` level) and the idle deadline — and the same shared rules: truncation, reasoning kept out of the reply, and the turn trace. With `EnableThinking` on it is called streaming, and any `TextReasoningContent` it yields goes to the Thinking panel. The host owns the client’s lifetime (and its own timeouts and retries); the engine never disposes it. An entry whose client is not registered fails the turn with a message naming the `AddKeyedChatClient` call to make. ## Backend wiring [Section titled “Backend wiring”](#backend-wiring) The umbrella `AddGenieApp` already registers the Assistant module and maps the hub. A host that composes the engine directly adds two calls: Program.cs ```csharp services.AddGenieAssistant(builder.Configuration); // provider + MCP tools + services // … app.MapAssistantHubs(); // the /hubs/assistant-chat SignalR hub ``` Both entry points also accept **code-first overrides** (config-first + in-code override, code wins — including registration-time values like `Provider` and `ChatWidgetEnabled`): ```csharp // Direct composition: services.AddGenieAssistant(builder.Configuration, o => { o.Provider = "OpenAI"; o.ChatWidgetEnabled = true; o.WelcomeChips = ["Which products are low on stock?"]; }); // Umbrella AddGenieApp — via the fluent GenieBuilder: builder.Services.AddGenieApp(genie => genie .LoadFromConfiguration(builder.Configuration) .ConfigureAssistant(o => o.Model = "deepseek-chat")); ``` `AddGenieAssistant` (`ServiceCollectionExtensions`) binds `AssistantOptions`, registers the one HTTP client every built-in kind sends through (`GenieAssistant`), the core services (`SchemaContextBuilder`, `NaturalLanguageQueryService`, `ConversationService`, `ChatHistoryService`, `AppDocsService`, `AssistantQueryExecutor`, …), the four MCP tools, the `McpOrchestratorService`, the selected provider, and the **`AssistantChat` authorization policy** that gates the hub. The policy authenticates with **Cookie or JWT Bearer** (the same scheme pair as the notification hub’s `ApiPolicy`), so the SignalR `?access_token=` authenticates the connection and an unauthenticated negotiate gets a `401` — not a cookie login redirect. ## Configuration reference [Section titled “Configuration reference”](#configuration-reference) Configuration lives in **`Genie:Assistant`**: appsettings.json ```jsonc "Genie": { "Assistant": { "Providers": [ // the models the picker offers — see Models above { "Name": "GPT-4o mini", "Provider": "OpenAI", "Endpoint": "https://api.openai.com/v1", "Model": "gpt-4o-mini", "IsDefault": true } ], "ConversationMode": true, // keep chat context across turns "AutoGenerateTitle": true, // AI-summarise the first message into a chat title "DocsPath": "AssistantDocs", // optional: a dir of *.md help docs (merged over baseline) "ChatWidgetEnabled": true, // gates the AssistantChat auth policy / hub "ChatWidgetEnabledForRoles": "*", // "*" or a list/CSV of role names, e.g. ["Admin","Analyst"] "RequestTimeoutSeconds": 120, // overall deadline for one turn (MCP loop + streaming) "MaxIterations": 5 // tool-calling passes before the assistant must answer } } ``` Other notable `AssistantOptions` knobs (all optional, sensible defaults): * **Per-model** (on a `Providers` entry, not here): `MaxTokens` (10000), `Temperature` (0.1), `TopP` (0.9), and `TimeoutSeconds` (60) — the timeout for a **single** provider HTTP request (connect + headers, and the full body of a non-streaming completion), applied per attempt. * **`StreamIdleTimeoutSeconds`** (90, per-model) — how long a **streamed** answer may go without delivering a token before the call is abandoned. `TimeoutSeconds` cannot cover this: it ends at the response headers, and an SSE body arrives after them. Observed against DeepSeek on 2026-09-15 — `200 OK`, then a `: keep-alive` comment every 12 seconds and no tokens at all, on both `deepseek-reasoner` and `deepseek-chat`, with a funded account. A keep-alive deliberately does not reset the clock: it proves the connection is alive, which is the one thing not in doubt. The caller is told the provider accepted the request and then sent nothing, and which model did it. Set to `0` to disable. * **`ReasoningEffort`** (per-model; unset by default) — `minimal`, `low`, `medium` or `high`, sent as `reasoning_effort`. Worth setting on the GPT-5 and o-series families, whose default is `medium` and which pay for it on **every pass** of the tool-calling loop rather than once per answer: measured against GPT-5 Mini, passes ran 20–76 seconds each and an eight-pass turn hit the request deadline with no answer. The trade is smaller than it sounds, because most passes are “read the schema block, emit one tool call” — a formatting decision, not a reasoning problem. Unset sends nothing, and a server that rejects the parameter has it dropped after one round trip, so it is safe on an entry pointed at a proxy or a local model. (A `deepseek-reasoner` entry still sends `high` when this is unset, as it always has.) * **`MaxIterations`** (5) — how many tool-calling passes one turn may take before the assistant is made to answer with what it has. Raise it for genuinely multi-step work: editing a report can spend a pass reading the draft, one per source it checks, one previewing and one applying, and running out mid-edit leaves the user with an explanation instead of a change. * **`RequestTimeoutSeconds`** (120) — the **overall** deadline for one assistant turn: the whole multi-iteration MCP tool-calling loop plus the provider’s streamed reasoning read. A streamed SSE body isn’t covered by `TimeoutSeconds` once the response headers arrive, so without this a stalled provider stream would leave the chat stuck on “Thinking” forever; when it elapses the caller is told **“The assistant timed out.”** Set to `0` to disable. * **`ConversationHistoryLimit`** (3) — how many recent Q\&A pairs (beyond the system prompt) are sent each turn, to control token usage. * **`UserMemoryMaxEntries`** (0 — **off**) — how many of the user’s recent questions, taken from *their other chats*, are injected into every conversation’s system prompt. A chat’s own history is always sent in full and is unaffected; this is the cross-thread bleed, and it is off because a new chat that already knows what the last one was about is the opposite of what opening one is for. Set a small positive number to turn it on. (Before this it could not be turned off at all: `0` selected the old default of 8 instead of disabling it.) * **`SchemaTableLimit`** (15) — how many tables are described inline in the system prompt; the rest remain reachable via `get_table_schema`. * **`ToolResultMaxChars`** (8000) — cap on a single tool result before truncation. A tool can opt out with `IAssistantTool.TruncateResult => false`, and the report tools do: half a report is not a cheaper report, it is one with no closing tag, and the model cannot tell that what it was handed was incomplete. Keep the default for anything row-shaped, where the first N rows still answer the question. * **`WelcomeMessage`** / **`WelcomeChips`** — the greeting and quick-start suggestion chips for an empty chat, served to the React UI via **`GET api/assistant/config`**. Per-field precedence: the host UI’s `assistant.*` config > these server values > built-in defaults — and there are **no default chips**: when neither the host UI nor the server provides any, no chips render. Widget gating `ChatWidgetEnabledForRoles` accepts a JSON array **or** a single `*`/comma-separated string. `*` (or an empty list) allows any authenticated user; otherwise the caller must hold one of the named roles. When `ChatWidgetEnabled` is `false` the chat is off regardless of roles. The rule is enforced by the `AssistantChat` policy on **both** transports: the hub **and** every conversational REST endpoint (`POST /query`, `POST /generate-sql`, and all `/chats` thread CRUD). Only `GET /config` stays open to any signed-in user, because it is how the UI learns the answer — it reports `ChatEnabled` for the caller, and the panel shows a “not enabled for your account” notice instead of a composer when it is `false`. (Previously the REST endpoints ignored this setting, so a refused hub connection fell back to REST and the assistant kept answering — without its charts.) ### API keys — keep them out of source [Section titled “API keys — keep them out of source”](#api-keys--keep-them-out-of-source) ASP.NET Core layers the environment-variable provider **after** the JSON files, and nested keys map via the `__` separator. `Providers` is an array, so a key targets an entry by its **index**: ```powershell # the FIRST entry in Genie:Assistant:Providers setx Genie__Assistant__Providers__0__ApiKey "sk-…" # then restart the shell/IDE so the process inherits it ``` The index is positional: reorder the array and an override follows the position, not the model that used to be there. A source’s connection string works the same way (`Genie__ReportingSources__1__ConnectionString`). Ollama is local and needs no key. A key addressed by *name* rather than index — `…Providers__DeepSeekR1__ApiKey` — **fails at startup** rather than being ignored. Configuration is a flat key-value store, so that path binds as an entry with a key and no name; refusing to start is the right outcome, because a secret that is silently dropped looks identical to one that worked until the provider rejects the request. Missing key fails gracefully The API key is validated lazily on the first provider call — a missing key no longer crashes the request pipeline. The thread and user message are still saved, and the failure flows through the normal role-gated error path (below). ## Tool categories [Section titled “Tool categories”](#tool-categories) Every active tool’s name, description and parameter schema is written into the system prompt on **every** provider call. A flat registry therefore grows the prompt with each capability added — and prompt size is what decides whether a smaller local model still emits a well-formed `TOOL_CALL:`. So tools declare a category, and only the categories relevant to what the user is doing are offered: | Category | Tools | Active | | ------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `information` | `execute_query`, `get_table_schema`, `get_app_doc`, `request_clarification`, `chart_result` | always | | `reports` | `report_get_draft`, `report_preview_data`, `report_apply_change` | only in the report designer | | `wizards` | `wizard_get_definition`, `wizard_apply_change` | only in the wizard designer (gated on `Update` of `WizardDefinitions`) | | `workflows` | `workflow_get_definition`, `workflow_apply_change` | only in the workflow designer (gated on `Update` of `Wf_DefinitionsView`) | Five tools on an ordinary page, eight in the report designer, seven in the wizard or workflow designer — never the full set everywhere. The React shell derives the mode from the current route and sends it with each message; nothing needs configuring. A host tool keeps working unchanged, because `IAssistantTool.Category` is a default interface member returning `information`: ```csharp public sealed class MyTool : IAssistantTool { public string Name => "my_tool"; // Category not declared -> "information", offered on every turn. } ``` Declare `AssistantToolCategories.Reports`, `Wizards` or `Workflows` to be offered only alongside that designer. A mode also changes the **instructions**, not just the tool list. The bulk of the system prompt is written for the data path — query the database, answer in business language — and on the designer none of it applies. So report-designer mode appends its own block at the very end of the prompt, naming the open report and stating the rules that override what came before: read the report with `report_get_draft` before saying anything about it, never offer a change as XML in the reply (the user has no way to apply it), and put every change through `report_apply_change` as a complete document. Without that block a smaller model treats a designer request like any other question and simply answers it. `report_get_draft` returns the **report XML contract** after the document — the elements and attributes a report may use, condensed from [Report authoring](/reports/authoring/). It is carried on the tool result rather than in the prompt because it is reference material, and because it is then fetched once, on the call the prompt already requires before any change. Without it the assistant’s only evidence for what an element accepts is whatever the open report happens to contain, so anything the report does not already use is a guess drawn from ordinary charting-library conventions — `Type` for `Kind`, a `Series` attribute, a `SplitBy` that does not exist — and each wrong guess costs a whole round trip, re-sending the document and waiting on another completion. Three of them is the turn. Give a designer turn room to finish An edit is several round trips *by design*: read the draft, preview the query, apply the change, then answer. On a reasoning model each is 15–50 seconds, so the default `RequestTimeoutSeconds` of 120 — sized for a chat — can expire mid-edit and hand the user a timeout instead of an Apply button. A host that enables the designer should raise it, along with `MaxIterations` and the provider’s own `TimeoutSeconds`; `sample/Inventory` uses 300, 8 and 120. The mode is a hint, not authority It arrives from the browser, so a caller can claim any mode. Every tool a mode unlocks re-checks the caller’s permissions itself — the report tools each verify `Update` on `ReportDefinitions`, the same gate `ReportDesignerController` applies. The worst a forged mode achieves is surfacing a tool that then refuses. An unrecognised mode falls back to `information` only. ## Which databases a chat reads [Section titled “Which databases a chat reads”](#which-databases-a-chat-reads) A chat reads **one or more** of the sources configured under [`Genie:ReportingSources`](/integration/configuration/#geniereportingsources--named-read-only-datasources), picked from the checkbox list under the chat’s title bar. `Default` is the application’s own database, described from Genie’s modeled entities and filtered to the tables the caller may reach through a permitted view; every other source is described by a host-written schema (see below). The picker only offers sources the caller may actually query — the source name **is** the RBAC resource, so “may I see it in the list” and “may I query it” are the same question. Every name the browser sends is re-validated server-side against what exists, what is enabled, what the caller may view, and how many sources one chat may read at once (four). A name that fails **fails the turn** rather than being dropped: answering from a narrower set than the user believes they ticked would look identical to there being no data. A turn may span sources; a statement may not With two sources ticked, the prompt carries both schemas, each labelled with its dialect, and every `execute_query` call names the one source it runs against. Nothing joins across them — they are separate databases on separate servers — so an answer needing both is two queries the model combines itself. The prompt says this explicitly, because a model handed two schemas will otherwise try to join them. The selection is fixed for the duration of a turn and free to change between them. Fixed within a turn because a source decides both the connection opened and the tenant column rows are filtered on, so a mid-turn switch is a cross-tenant hazard rather than an inconvenience. A chat remembers its selection, so reopening a thread does not silently answer the next question from a different database than the ones above it in the transcript. ### Report designer [Section titled “Report designer”](#report-designer) Opening the assistant on a report pre-ticks **every source that report’s datasets already read**. This is what lets it edit a report at all: an assistant that can only see one database cannot write a dataset for another, and before this it would produce a correct query for the wrong source and be rejected at the last step. With more than one source in context, the assistant is told to **ask** which source a new dataset should use rather than choose — “total products” against a live table and against a replica are different numbers, and only the user knows which they meant. ### Describing a source [Section titled “Describing a source”](#describing-a-source) A source other than `Default` has no modeled entities, so nothing can generate a schema from it — a host writes one. That is an **`ISourceSchemaProvider`**, bound to a source by its own `Source` property: ```csharp public sealed class AnalyticsSchemaProvider : ISourceSchemaProvider { public string Source => "Analytics"; public SourceSchema GetSchema() => new( [new SourceTableSchema("wh_Inventory_Products", "Product master. | Column | … |")], Rules: "ALWAYS read with FINAL and add IsDeleted = 0."); } ``` ```csharp services.AddSingleton(); // after AddGenieAssistant ``` The binding is a property the compiler sees, not a type name in configuration — so there is nothing to activate by reflection and no path-versus-classname ambiguity. Providers are applied in registration order: a later one overrides an earlier one’s table of the same name and appends its rules, so a small correction can layer over a large description. One that throws is logged and skipped, never allowed to take the assistant down. Resolved once **per source**, as a singleton. Do not put per-user or per-request logic here; that is what [`IAssistantContextContributor`](#extending-the-assistant-host-seams) is for. #### From a file [Section titled “From a file”](#from-a-file) `SourceSchemaFile.Load(path)` reads the same record from disk, for when the description is documentation a data engineer should edit without a rebuild: ```csharp public SourceSchema GetSchema() => SourceSchemaFile.Load("Assistant/analytics.schema.xml"); ``` It dispatches on what the path is — `.xml`, `.md`, or a directory of either (plus a reserved `_rules.md` taken whole as source-wide rules). Both formats produce the same record, so nothing downstream can tell which was used. The XML format: ```xml
ALWAYS read with `FROM <table> FINAL` and add `AND IsDeleted = 0`.
``` | Element | Meaning | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Checked against the source the provider serves, so a file copied and re-pointed cannot quietly describe the wrong database. | | `` | `Name` is what the assistant writes in SQL. `Summary` is the one line in the prompt’s index; omit it and the first sentence of the body is used. | | *the table body* | **Opaque** — handed to the model verbatim. There is deliberately no `` element: the half of a description worth having is the part a column grid cannot hold, like the note above about on-hand being a SUM. | | `` | Rules for the source as a whole, rendered as a list beneath the table index. | Markdown is the same information with less ceremony — `## TableName` declares a table and everything under it is the body. What XML earns over it is **load-time validation, naming the file**: a duplicate table name, a `
` with no name, an empty ``, or a root `Source` that disagrees with the provider all throw at load rather than surfacing later as an assistant that cannot find a table. Name the table the way the target holds it [Warehousing](/platform/warehousing/) creates `wh_{Schema}_{Table}` — `wh_Inventory_Products`, not `Products` — and keeps no unprefixed copy, so a description using the source table’s name produces SQL against a table the warehouse does not have. Derive it from `WarehousingConventions.TargetTableName` rather than hand-copying the convention into a string. The catalog is the assistant’s **only** knowledge of a source: nothing probes the database, so a table nobody describes is a table it cannot name. A source with no provider at all is reported as undescribed rather than silently answered from the application database. ### Query rules [Section titled “Query rules”](#query-rules) `SourceSchema.Rules` is where the requirements that make a query *correct* rather than merely valid belong — and for a Genie-warehoused ClickHouse target there is one that matters enormously: ```plaintext - Every table here is a ReplacingMergeTree holding every version of a row, and a soft-deleted record keeps its last values rather than leaving. ALWAYS read with `FROM
FINAL`, and ALWAYS add `AND IsDeleted = 0` on a table that has the column. - Bind parameters the ClickHouse way — `{Name:Type}`, not `@Name`. ``` Without `FINAL` a query sees every historical version of every row; without `IsDeleted = 0` it sees rows someone deleted. Neither fails — they just inflate the answer. ### Declaring a source in code [Section titled “Declaring a source in code”](#declaring-a-source-in-code) `Genie:ReportingSources` entries can equally be declared or adjusted through the builder: ```csharp services.AddGenie(genie => genie .LoadFromConfiguration(configuration) .UseSource("Analytics", source => { source.Dialect = SourceDialect.ClickHouse; source.Connection = "Analytics"; // a ConnectionStrings key, or a literal source.TenantColumn = "CompanyId"; })); ``` This overload **configures** rather than replaces, so anything `LoadFromConfiguration` bound survives unless you set it. That is deliberate: an overload that rebuilt the entry would silently drop `TenantColumn`, and a source with no tenant column refuses every non-System caller — safe, but baffling if all you meant to change was a password. The three-argument `UseSource(name, dialect, connection)` delegates to it and behaves the same way. ### Access and tenant scoping [Section titled “Access and tenant scoping”](#access-and-tenant-scoping) The two protections the primary database provides do not reach a warehouse, so they move into the engine: * **RBAC** — the source is its own resource. A caller without `View` on a resource named after the source is refused. (Warehouse tables sit behind no view, so the permission filtering that scopes the modeled schema has nothing to bite on.) * **Tenant** — on the primary database the caller’s company is pushed into a DB session context and row-level-security filters rows whatever SQL the model wrote. ClickHouse has no such policy, so Genie **wraps** the query instead: ```sql SELECT * FROM ( ) AS genie_scope WHERE genie_scope."CompanyId" = {genie_tenant:Int64} ``` An outer predicate constrains every row the inner query could produce, through any join, union or grouping. The cost is that the query must project the tenant column — the schema context says so up front, and if it is missing the database says so and the model corrects itself. Failing in that direction is the point: a filter that silently did not apply would be worse than none. `System` callers cross tenants and are not filtered. `Admin` callers **are** — they administer one company, and RLS scopes them on the primary path too. Declare how the source is scoped, or non-System users are refused With neither `TenantColumn` nor `SingleTenant` set, the assistant refuses warehouse queries for everyone but System, naming both settings in the refusal. That is deliberate: the failure of guessing wrong is one company reading another’s data, and nothing downstream would catch it. ### What else follows the source [Section titled “What else follows the source”](#what-else-follows-the-source) * **The dialect** — the prompt’s SQL rules, the wrong-dialect backstop and the row cap all switch to the source’s engine, and `execute_query`’s own description names it. * **Report datasets** — a change the assistant proposes puts every `` on the active source, and `report_apply_change` rejects a document that mixes sources. A hand-authored report may still mix them; the restriction is on what the assistant writes, because it has read one schema. ## Tracing a turn [Section titled “Tracing a turn”](#tracing-a-turn) One user message fans out across the hub, the conversation service, the MCP iteration loop and every tool it calls. Reconstructing “what happened to this message” from ordinary log lines means stitching a dozen events together by conversation id and hoping none interleaved with another user’s turn. `Genie:Assistant:Trace` fixes that from both ends: it collects the whole turn into **one** tagged block, and it stamps a short key on **every other line** the turn writes, so the whole message is one grep. ```jsonc "Genie": { "Assistant": { "Trace": { "Enabled": true, // default false "Verbosity": "Io", // Steps | Io | Full "Live": false, // also echo each step as it happens "MaxDetailChars": 20000 // per-payload cap; 0 = unlimited } } } ``` ### The block [Section titled “The block”](#the-block) The event is written under the **`Genie.Assistant.Trace`** source context — route it to its own Serilog sink, or filter it out — carrying `Key`, `TurnId`, `Outcome`, `ConversationId`, `UserId`, `CompanyId` and `TotalMs` as properties, with the block itself in the message. At `Verbosity: "Io"`: ```plaintext [Assistant] A7C3F1 Answer in 43258.9ms — 42:7 (qwen2.5-coder-7b) [IN] [A7C3F1]-42:7 User Input — 28 chars, mode=report-designer key=PurchasingPerformance | show me low stock products [CTX] [A7C3F1]-42:7 Schema:CacheHit 1589 chars [ITER] [A7C3F1]-42:7 Iteration 1 (41074 ms) [LLM-REQ] [A7C3F1]-42:7 2 messages, 5400 chars (40915 ms) [THINK] [A7C3F1]-42:7 1204 chars | The user wants products below reorder level… [LLM-RES] [A7C3F1]-42:7 142 chars | TOOL_CALL: execute_query | PARAMS: {"sql":"SELECT …"} [TOOL-CALL][A7C3F1]-42:7 execute_query | {"sql":"SELECT …"} [TOOL-RES][A7C3F1]-42:7 execute_query — 0 rows, 128 chars | TOOL_RESULT: 0 rows [OUT] [A7C3F1]-42:7 Answer — 142 chars, 0 block(s) | No products are currently below their reorder level. ``` | Tag | What it marks | | ------------- | --------------------------------------------------------------------------------- | | `[IN]` | The user’s message, verbatim | | `[CTX]` | Context assembly — schema, history, memory, the active tool set | | `[SYS]` | The system prompt handed to the model | | `[ITER]` | One pass of the MCP loop; everything below it nests under the pass that caused it | | `[LLM-REQ]` | What was sent to the provider this pass | | `[THINK]` | The model’s chain-of-thought, when it emits one | | `[LLM-RES]` | The provider’s raw response, before any sanitising | | `[TOOL-CALL]` | A tool invocation and its parameters | | `[TOOL-RES]` | What the tool returned, including an `ERROR:` verdict | | `[BLOCK]` | A rich block (chart, action card) emitted toward the UI | | `[OUT]` | How the turn ended, and the answer text | | `[ERR]` | A provider exception, a malformed payload, an unhandled throw | Lines beginning `|` are verbatim captured text belonging to the step above them, indented so a multi-line prompt cannot be mistaken for a run of sibling steps. It flushes on **every** exit — an answer, a clarification, a pipeline error, the overall-deadline timeout, a user cancel, a dropped connection, an unhandled exception — because the turns worth reading are usually the ones that failed. ### The key [Section titled “The key”](#the-key) `A7C3F1` identifies the **chat session**; `42:7` is the chat id and the user message’s sequence number within it. So `grep A7C3F1` gives you the whole conversation and `grep 'A7C3F1-42:7'` gives you exactly one message. The key is **derived** from the conversation id rather than generated, so it is the same key every time that conversation is resumed and after a restart — a key copied out of yesterday’s log still finds the live chat. The same `A7C3F1-42:7` is pushed onto Serilog’s `LogContext` for the duration of the turn, so every line the provider, the schema builder and each tool writes carries it too: ```plaintext [23:25:17 INF] [A7C3F1-42:7] [Assistant:AppDocs] Loaded 3 app doc(s) [23:25:18 INF] [A7C3F1-42:7] Sending request to OpenAI — Model: qwen2.5-coder-7b, MessageCount: 2 [23:26:01 INF] [A7C3F1-42:7] [Assistant] A7C3F1 Answer in 43258.9ms — 42:7 [23:26:02 INF] [------] Hangfire server heartbeat ``` Two config details decide whether you see any of this **The template must render the property.** Serilog only prints what an `outputTemplate` names, so add a `[{Turn}]` column: ```jsonc "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] [{Turn}] {Message:lj}{NewLine}{Exception}" ``` **`FromLogContext` must come first in `Enrich`.** Serilog enrichers use `AddPropertyIfAbsent`, so whichever runs first wins. The `WithProperty` default that produces the `------` placeholder on lines outside a turn has to come *after* it, or it wins every time and the key never appears: ```jsonc "Enrich": [ "FromLogContext", { "Name": "WithProperty", "Args": { "name": "Turn", "value": "------" } } ] ``` Three lines are emitted before the turn’s message row exists and so cannot carry the key: the hub’s receive and conversation-open lines (both `Debug`, and reproduced as the block’s `[IN]` and `[CTX]` steps) and `Created assistant thread …`. ### How much to keep [Section titled “How much to keep”](#how-much-to-keep) | Verbosity | Keeps | Size | | --------- | --------------------------------------------------------------------------------------------- | ---------------- | | `Steps` | The outline and timings — which pass called which tool, and where the time went | negligible | | `Io` | **+ the question, the thinking, every provider response, every tool payload, the answer** | \~5 KB a turn | | `Full` | + the system prompt, the schema, the docs index, and the whole message array resent each pass | 50–100 KB a turn | `Io` is the day-to-day setting: it answers “what did the model actually say?” without dragging in the prompt and schema that dominate `Full` and barely change between turns. Reach for `Full` when the question is about the prompt itself. `MaxDetailChars` caps any single captured payload and marks the cut explicitly (`… [truncated 3100 chars]`), so a clipped 13 KB report can never be mistaken for a short one. `Live: true` additionally echoes each step as a `Debug` line the moment it happens, on top of the block. Use it when a turn is slow or hanging and the question is “where is it right now?” — a 40-second provider call otherwise looks identical to a deadlock until the turn ends. `Io` and `Full` write your data to the log The block contains the user’s question, whatever business data the tools returned and — at `Full` — the database schema. It belongs in a developer’s console or a private sink, not a shared production log. That is why tracing is off by default and why `Steps` is the default verbosity once it is on. When disabled the accumulator records nothing and no event is written, so the pipeline pays nothing. This replaces the former per-iteration Debug transcript, which wrote one event per provider call. ## Extending the assistant (host seams) [Section titled “Extending the assistant (host seams)”](#extending-the-assistant-host-seams) The assistant is designed to be extended and overridden from the caller project, entirely through DI — no forking: * **Business context in the prompt — `IAssistantContextContributor`.** Register any number of implementations; each renders as a titled `=== {Title} ===` section in the system prompt (after the user-context block, before the app-docs index), ordered by `Priority` (lower first). A contributor that throws is logged and **skipped** — it never fails the turn. Contributors run on *every* assistant turn, so keep sections short and cache expensive queries: ```csharp public sealed class StockSummaryContextContributor(MyContext db, IMemoryCache cache) : IAssistantContextContributor { public string Title => "INVENTORY SNAPSHOT"; public async Task BuildContextAsync(AssistantUserContext user, CancellationToken ct) => await cache.GetOrCreateAsync("stock-summary", async e => { e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); return $"Current totals: {await db.Products.CountAsync(ct)} products."; }); } // Program.cs — after AddGenieAssistant / AddGenieApp: services.AddScoped(); ``` * **Custom / replacement tools — `IAssistantTool`.** `services.AddScoped()` adds a tool to the MCP loop (its `Name`/`Description`/`ParameterSchema` are injected into the prompt automatically). Tool names are case-insensitive and **the last registration wins**, so a host tool named `execute_query` replaces the built-in. * **Replacing the provider — `IAssistantProvider`.** Register your own implementation *after* `AddGenieAssistant` and single-service resolution takes the last registration: `services.AddScoped();` — the whole pipeline (orchestrator, titles, SQL generation) flows through it. The [Inventory sample](/getting-started/quickstart/) ships a working contributor (`StockSummaryContextContributor`) plus server-configured welcome chips. ## Frontend `createGenieApp` module [Section titled “Frontend createGenieApp module”](#frontend-creategenieapp-module) Mount the assistant by enabling the module. It renders app-wide over every route — an **AI icon in the header** (left of the notification bell) opening a themed **bottom-right** panel. An optional top-level `assistant` block overrides the panel content: ```tsx createGenieApp({ apiBase: "/api/v1", modules: { assistant: true }, assistant: { title: "Inventory Copilot", // header title subtitle: "Online", // status line welcome: "Hi! Ask me anything.", // empty-state greeting suggestions: ["Low stock?"], // quick-start chips ([] renders none, beating server chips) placeholder: "Ask Copilot…", // composer placeholder }, }); ``` `welcome` and `suggestions` fall back to the server’s `Genie:Assistant:WelcomeMessage` / `Genie:Assistant:WelcomeChips` (fetched once from `GET api/assistant/config` when the panel first opens), then to the built-in greeting — with **no built-in chips**. The panel is multi-conversation: a conversations list (open / rename / delete / new, plus multi-select bulk delete) and the conversation view — user/assistant bubbles as sanitised Markdown (GFM tables, lists, code) via `marked` + DOMPurify, a live **Thinking** disclosure, a collapsible **SQL** block and result-table preview for data answers, suggestion chips, a `Shift+Enter` composer, and a **stop** control while a reply is in flight. It is accent-/theme-aware. The UI talks to the backend over `AssistantChatHub` when connected and falls back to REST (`api/assistant`: `GET /config` for the welcome content, `POST /query`, thread CRUD `GET/POST /chats`, `GET/PATCH/DELETE /chats/{id}`, `POST /chats/delete` for bulk delete) otherwise — clarification pills and inline charts work on both transports. `GenieAssistant` and `GenieAssistantButton` are also exported for hosts that want to place or configure them manually (custom `title`, `subtitle`, `suggestions`, `welcome`). See [Frontend configuration](/integration/frontend/). ## Persistence, titles and error masking [Section titled “Persistence, titles and error masking”](#persistence-titles-and-error-masking) * **Persistence.** Conversations live in the database (schema `Genie`, tables `AssistantChat` + `AssistantChatMessage`), owned per user (`UserId` + `CompanyId`). Non-admins only see their own threads. A thread is saved only once its first reply succeeds; a failed first turn is rolled back. **Host apps must add the migration** for the two tables (`dotnet ef migrations add AddAssistantChat -c YourContext`), and again after an engine upgrade that adds a column to them — the nullable `AssistantChatMessage.ProviderName`, which records the model that produced each reply so the UI can label it, arrived that way. * **Titles.** New threads show a truncated placeholder immediately. With `AutoGenerateTitle` on (default) the provider summarises the first message into a concise title after the first reply, pushed live to the list (`ConversationRenamed`); on failure the placeholder is kept silently. A manual rename (`PATCH /chats/{id}`) always overrides a generated title. * **Error masking.** Internal error detail (a missing/invalid key, a raw provider failure) is surfaced only to users holding the **`System`** role (`AssistantErrors.ForUser`). Everyone else sees a generic *“The assistant is temporarily unavailable…”* message. Generated SQL is likewise returned to admins only. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * **[Tenant isolation & SQL hardening](/assistant/sql-hardening/)** — the read-only login + RLS setup that makes tenant scope DB-enforced. * **[AI Assistant overview](/assistant/overview/)** — the tool-orchestration model and prompt context. # AI Assistant overview > The natural-language-to-SQL chat assistant — its MCP tool orchestrator, RBAC-scoped execution, SignalR streaming and DB-backed multi-chat. The **Genie Assistant** is a natural-language chat that answers questions about your application’s data and how to use the app. A user asks in plain language (“how many tickets are open this week?”); the assistant generates SQL, runs it against the database, and replies with the answer as sanitised Markdown — figures, bullet lists, or a result table. It is named **Assistant** everywhere in code (`Genie.Engine/Features/Assistant`). Under the hood it is a **provider-agnostic, MCP-style tool orchestrator** streamed over SignalR and scoped to the caller’s RBAC permissions — so it can only see and describe what the user is already allowed to see. Where it lives Backend: `src/api/Genie.Engine/Features/Assistant/`. Frontend: the `assistant` module of `createGenieApp` (an AI icon in the header opening a bottom-right chat panel). See [Configuration & providers](/assistant/configuration/) to turn it on. ## What it does [Section titled “What it does”](#what-it-does) * **Natural language → SQL → answer.** The model turns a question into a read-only `SELECT`, the engine validates and runs it, and the model phrases the rows back in business language (never leaking table or column names). * **App how-to questions.** Beyond data, it answers “how do I add/edit X in the app?” from Markdown help docs (baseline engine docs + host docs). * **“What can I access?”** It is given the caller’s roles and full permission map, so it can explain what the user may do and avoid suggesting actions they aren’t permitted to perform. * **Charts, in the conversation.** Ask it to show, plot or compare something and the answer comes with a chart under it (see below). * **Report authoring.** Open the report designer and it can read the report you are editing, preview what a dataset returns, and propose changes you apply with one click — see [Assistant-assisted authoring](/reports/designer/#assistant-assisted-authoring). ## Rich blocks: charts and Apply cards [Section titled “Rich blocks: charts and Apply cards”](#rich-blocks-charts-and-apply-cards) Most replies are Markdown. Some carry a **block** rendered underneath the prose: * **A chart.** Produced by the `chart_result` tool, which runs the `SELECT` itself rather than asking the model to re-emit the rows — cheaper, and it removes the most likely thing for a model to get wrong. It goes through the *same* safety chain as `execute_query`: SELECT-only validation, the row cap, and the tenant-scoped read-only executor. Charting is a way to display data, never a second way to query it. Rendering reuses the report page’s own chart component, so there is no charting dependency in the package and the colours follow your theme and accent. The chart is drawn at the panel’s real width — labels stay the size the stylesheet asks for however narrow it is — and a bar chart of long or numerous category names is turned on its side, where each name gets its own row. * **An Apply card.** A proposed change with **Apply** and **Dismiss**. Applying is entirely client-side: the card names a handler that the owning page registered, and that page writes to its own in-memory state. The server never applies anything. If the owning page isn’t open the card says so and stays put. Because a chart needs room, the panel header has an **expand toggle** that widens it, and the panel’s top edge can be **dragged** to change its height (double-click the handle to reset). The panel can also be **moved**: drag it by its title bar and it stays where you drop it, clamped so it can never end up off-screen. Once it has been moved, a **reset-position** button appears in the header to send it back to its default corner. Position, width and height are per-session choices — they are not persisted, so a reload starts from the default again. ## Attaching context [Section titled “Attaching context”](#attaching-context) Above the composer, a **+** button offers whatever the current page can contribute to the conversation. On the report designer that is **Current report**, on the wizard designer **Current wizard**, and on the workflow designer **Current workflow** (not while previewing a running instance); other pages offer nothing and the button is hidden. Attaching is what tells the server which tools the turn may use, so the chip is not decoration — it is the switch. **Nothing is attached automatically.** Arriving on the report designer changes nothing about the conversation: until you attach the document, the turn gets the information tools and the assistant answers questions. Attach it and the authoring tools appear; remove the chip and they go again. That is deliberate. Attaching is what turns the assistant from something you ask questions of into something that rewrites your document, and it also puts a multi-kilobyte document within the model’s reach — neither should follow from the route you happen to be on. It is one click, in the **+** menu, and the chip shows you it happened. One context is attached at a time; picking from the menu replaces what is there, and navigating to a page that no longer offers it clears it rather than carrying it along. ## Answering in the chat while a document is attached [Section titled “Answering in the chat while a document is attached”](#answering-in-the-chat-while-a-document-is-attached) The first way to get a chat answer is simply not to attach the report — see above. But while you are working on one you will want it attached, and a question will still come up mid-edit. So: add **“reply in chat”** to the message — or tell it not to change the report, wizard or workflow — and that turn answers in the conversation and proposes nothing, attachment or no attachment: no Apply card, no XML. This is enforced, not requested. When the message carries that instruction the change-proposing tool is **not offered to the model at all**, so there is nothing for it to call however the rest of the prompt reads. The reading tools stay, because the question may well be about the open document (“summarise this report, just reply in chat”), and so does `chart_result` — a chart is drawn in the conversation and changes nothing, so “plot the last 24 hours, reply in chat” is answered with a picture and a sentence. Phrasings that switch it on: | You write | What happens | | -------------------------------------------------------- | --------------------------------------------------------------- | | “…, reply in chat” / “answer in the chat” / “chat only” | Answered in the conversation; nothing proposed | | “don’t change the report, just give me the figures” | Same | | “show me the trend without changing the report” | Same | | “don’t change the report **title**, add a chart instead” | **Ordinary authoring** — this asks to change something specific | That last row is the line the rule is drawn on: the instruction has to end the clause. A request to change one particular thing reads, word for word, like a request to change nothing, so only where the phrase stops decides which it is. And whichever way it goes, the failure is cheap in one direction only — an answer in the chat when you wanted an edit costs one message; an edit you asked nobody to make costs an undo. Each chat is its own context A conversation is sent its own history and nothing else. Recalling a user’s recent questions from their *other* chats is a separate, opt-in setting ([`UserMemoryMaxEntries`](/assistant/configuration/), default 0) — so a new chat starts genuinely new, rather than answering as though the previous subject were still on the table. A chart comes back; an Apply card does not Reopening a conversation restores its charts — they are stored with the message that carried them, so a thread you come back to still shows the picture you asked for rather than only the sentence underneath it. Apply cards are not restored, and that difference is the point. A chart records what the data was at a moment that has already passed. An Apply button is a live offer to change a document, and one restored hours later would propose an edit against a draft that has moved on — with nothing on the card to say so. ## One clarification, not a funnel [Section titled “One clarification, not a funnel”](#one-clarification-not-a-funnel) The assistant may ask a follow-up question when a request is genuinely ambiguous — but only one, and answering it ends the matter: `request_clarification` is withheld from the turn that carries your answer, so the next thing that happens is a query. That is enforced rather than asked for, because the rule as a prompt line had a hole in it. “At most one clarifying question per user message” is true of every message individually, and an answer to a clarification *is* a new message — so a model could narrow indefinitely and still be following the rule. It did: one request to plot an availability trend was met with which series, then which technology set, then how many sites, without a row being read, and the turn that finally had everything it needed ran out of time. Anything still open after one question is assumed rather than asked about, and the answer says what was assumed. Scope, row counts and the choice between near-identical metrics are explicitly named in the prompt as things to decide rather than ask about. ## The MCP tool-orchestration model [Section titled “The MCP tool-orchestration model”](#the-mcp-tool-orchestration-model) Rather than a single prompt round-trip, the assistant runs a **multi-turn tool-calling loop** (`McpOrchestratorService`). The provider protocol is embedded in the system prompt: to call a tool the model replies with exactly ```plaintext TOOL_CALL: PARAMS: {"key": "value"} ``` A reply may carry **up to three** of these when the calls do not depend on each other — they run in the order written and the results come back together, which is what a model expects when it batches two lookups. Anything past three is not run and the model is told which ones, by name. The orchestrator parses those directives, dispatches each named tool, appends the results as `tool` messages, and loops — until the model returns a plain-text answer, requests clarification, or the iteration cap (5) is reached. This keeps the design **provider-agnostic**: any provider that can emit text can drive the tools; no provider-specific function-calling API is required. ```text ┌─ McpOrchestratorService · loop, max 5 turns ──────┐ │ system prompt: schema + app-docs + RBAC + rules │ │ │ ┌──────────┐ │ ┌─────────┐ ── TOOL_CALL ▶ ┌────────┐ │ ┌──────────────┐ │ User │──┼─▶ │ model │ │ tool │ ├─▶ │ Markdown │ │ question │ │ │ │ ◀─── result ── │ │ │ │ answer │ └──────────┘ │ └─────────┘ └────────┘ │ └──────────────┘ └───────────────────────────────────────────────────┘ ``` ### The four tools [Section titled “The four tools”](#the-four-tools) Each tool implements `IAssistantTool` (name, description, parameter schema, `ExecuteAsync`): | Tool | What it does | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`execute_query`** (`ExecuteQueryTool`) | Validates a generated `SELECT` (SELECT-only, no dangerous keywords, row-capped) and runs it through `IAssistantQueryExecutor`, which enforces tenant scope at the DB layer. Returns rows as JSON. | | **`get_table_schema`** (`GetTableSchemaTool`) | Returns the column definitions for a specific table when they weren’t already in the compact schema context. | | **`get_app_doc`** (`GetAppDocTool`) | Returns the full Markdown body of an application help document by key (from the app-docs index in the system prompt) — for “how do I use X” questions. | | **`request_clarification`** (`RequestClarificationTool`) | Asks the user one follow-up question when the request is ambiguous, with 2–4 suggested answers the UI renders as **one-tap pills** (tap to answer) plus an **“Other…”** pill that focuses the composer for a free-text reply. Only the latest clarification is answerable — history renders the pills inert. Returns a sentinel the orchestrator relays back to the user. **Not offered on the turn that answers one**: see below. | Efficiency by construction The system prompt already lists columns for the most relevant tables, so the model is instructed to call `execute_query` directly when it can and only reach for `get_table_schema` for tables not already described. Tool results are capped (`ToolResultMaxChars`) to protect the context window. ## RBAC-scoped execution [Section titled “RBAC-scoped execution”](#rbac-scoped-execution) The assistant never sees more than the user does. Two layers enforce that: * **Permission-filtered schema context.** The schema library the model receives is built from Genie’s modeled metadata (`EntityStore`), not the raw DB catalog, and is filtered to tables the caller can reach through a view they hold View/List on. Engine and identity internals are never modeled as domain entities, so they are never described to the model. If the caller can access no tables — or asks about data outside their access — the model is instructed to answer plainly that they don’t have permission, rather than guess table names. * **DB-enforced tenant isolation.** For real isolation the generated SQL runs through a dedicated **read-only, least-privilege** DB login, and the caller’s `CompanyId`/`UserId`/`IsSystem` is pushed into a DB session context so **Row-Level Security** filters rows regardless of what SQL the model wrote. See [Tenant isolation & SQL hardening](/assistant/sql-hardening/). This mirrors the engine-wide rule: prompt text is a hint, not an authorization boundary — the database re-enforces access. See the [Security model](/security/overview/). ## SignalR streaming [Section titled “SignalR streaming”](#signalr-streaming) The chat runs over a dedicated SignalR hub, `AssistantChatHub` (`/hubs/assistant-chat`), separate from the notification hub. As the model works, the hub streams intermediate output to the caller: * **`ReceiveReasoningChunk`** — chain-of-thought reasoning streamed live while the model thinks (shown in an expandable **Thinking** disclosure that collapses once the answer lands). * **`ReceiveMessage`** — the final assistant reply (Markdown, plus generated SQL for admins and a result table for data answers). * **`ConversationStarted` / `ConversationRenamed`** — a brand-new thread is announced only once its first reply exists, then its AI-generated title is pushed live. * **`ReceiveError` / `MessageCancelled`** — role-masked errors and cancellation of an in-flight reply (`CancelMessage`). If the hub is unavailable the UI falls back to REST (`POST /api/assistant/query`). The fallback carries the reply’s rich blocks (charts) just as `ReceiveMessage` does, and it is gated by the same `AssistantChat` policy — so it is a fallback for a dropped connection, never a way around `ChatWidgetEnabled`. ## DB-backed multi-chat [Section titled “DB-backed multi-chat”](#db-backed-multi-chat) Conversations are **persisted in the database** (schema `Genie`, tables `AssistantChat` and `AssistantChatMessage`) — not Redis — so threads survive restarts and each user can keep multiple chats. Key behaviours: * Each thread is **owned by its user** (`UserId` + `CompanyId`); non-admins only ever see their own. * A thread is persisted only once its **first reply succeeds** — a failed first turn is rolled back, so there are no empty ghost threads. * **Titles** start as the first message truncated, then (when `AutoGenerateTitle` is on) the provider summarises the first message into a concise title that updates live; a manual rename always wins. * **A reply belongs to the thread that asked for it.** Leaving a conversation while it is still being answered — to the thread list, to another chat, or to a new one — no longer carries the “thinking” state along with you, and the answer lands in its own thread rather than whichever one is on screen when it arrives. The thread list marks the conversation still being answered, and opening it shows the pending question with the reply still coming. * **One reply at a time.** The hub runs one turn per connection, so while any thread is being answered the composer in the others will not send: doing so would cancel the running reply server-side. * **Each reply names the model that wrote it** (beside its timestamp), stored per message. The model picker stays live for the life of a thread, so a transcript can hold answers from more than one model and each is labelled with its own. Host migration required The two chat tables live in the host’s `GenieContext`. A host must add the migration for them (`dotnet ef migrations add AddAssistantChat -c YourContext`), and re-run `migrations add` after an engine upgrade that adds a column — `AssistantChatMessage.ProviderName` (which model answered) arrived that way, and is nullable, so existing rows simply carry no label. ## Prompt context: what the assistant knows [Section titled “Prompt context: what the assistant knows”](#prompt-context-what-the-assistant-knows) Every request injects four things into the system prompt, assembled by the orchestrator: 1. **A schema library** from `EntityStore` (dual-database safe, modeled types/enums/FK targets, permission-filtered as above; falls back to a raw catalog read only when no entities are modeled). 2. **An app-documentation index** — a compact list of Markdown help docs; the model pulls a full doc on demand via `get_app_doc`. The engine ships a baseline set; a host adds its own via `Genie:Assistant:DocsPath` (host wins on collision). 3. **Host-contributed business context** — any `IAssistantContextContributor` implementations the caller project registers in DI render as titled sections (live business signals, KPIs, domain glossaries…). See [Extending the assistant](/assistant/configuration/#extending-the-assistant-host-seams). 4. **The caller’s roles and full permission map** (from `IPermissionManager`) plus mandatory security rules (SELECT-only, soft-delete filtering, and — for non-admins — a hard `CompanyId` filter requirement). ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * **[Configuration & providers](/assistant/configuration/)** — pick and configure a provider, wire the backend and the `createGenieApp` module, and tune persistence/titles/error masking. * **[Tenant isolation & SQL hardening](/assistant/sql-hardening/)** — make tenant scope enforced by the database, not the prompt. * **[Security model](/security/overview/)** — how the assistant fits the layered security model. # Tenant isolation & SQL hardening > Making the AI assistant's generated SQL tenant-safe by the database — a read-only login plus row-level security — instead of trusting the prompt. The [Genie Assistant](/assistant/overview/) lets users ask questions in natural language; the model generates SQL and the engine runs it. **By default the generated SQL runs on the app’s connection and tenant scope is only *suggested* to the model in its system prompt.** That is convenient for a trusted or single-tenant demo, but a model that hallucinates, is jailbroken, or is prompt-injected could omit the `CompanyId` filter and read another tenant’s rows. Prompt text is not an authorization boundary A rule in the system prompt is a hint, not a guarantee. For real multi-tenant isolation the database — not the model — must enforce which rows come back. This mirrors the engine-wide [disclosure gate](/security/disclosure-gate/) and [multi-tenant scoping](/security/multi-tenancy/). This guide makes tenant isolation **enforced by the database**, using two layers the engine already supports: 1. **A dedicated, read-only, least-privilege DB login** for the assistant (`Genie:Assistant:ConnectionString`). Even if the SELECT-only validator is bypassed, this login physically cannot write, DDL, or read objects it wasn’t granted. 2. **Row-Level Security (RLS) driven by a DB session context.** Before running each query the engine pushes the caller’s identity into a session context; RLS policies filter every tenant table by it — so the correct rows come back *regardless of what SQL the model wrote*. Both layers are **opt-in**: with no `Genie:Assistant:ConnectionString` configured the assistant falls back to the app connection and prompt-only scope (unchanged behaviour). Enforcement turns on once you (a) point the assistant at a restricted login and (b) apply the RLS policies below via your host migrations. ## How the engine pushes identity [Section titled “How the engine pushes identity”](#how-the-engine-pushes-identity) For every assistant query, `AssistantQueryExecutor` opens the dedicated connection, starts a transaction, and sets a session context **before** running the model’s SQL: | Key (SQL Server `SESSION_CONTEXT`) | Key (PostgreSQL GUC) | Value | | ---------------------------------- | -------------------- | ----------------------------------- | | `genie.CompanyId` | `genie.company_id` | the caller’s company id | | `genie.UserId` | `genie.user_id` | the caller’s user id | | `genie.IsSystem` | `genie.is_system` | `1` for System-role users, else `0` | The context is always set (never skipped), so your RLS predicate can **fail closed**: any connection that did *not* go through the executor has no context and sees nothing. System-role users are granted full access via the explicit `IsSystem` flag — not by an absent value. Division of labour The engine sets the context; **you** create the read-only login and the RLS policies (your host owns the physical tables). Apply the DDL below in a host migration or DB bootstrap script. ## SQL Server [Section titled “SQL Server”](#sql-server) ### 1. Read-only, least-privilege login [Section titled “1. Read-only, least-privilege login”](#1-read-only-least-privilege-login) ```sql -- A login/user the assistant uses. It can only SELECT, and only on the tables you grant. CREATE LOGIN genie_assistant_ro WITH PASSWORD = ''; CREATE USER genie_assistant_ro FOR LOGIN genie_assistant_ro; -- Least privilege: read only. Grant SELECT on the business schema(s) the assistant may query, -- and explicitly DENY the rest (e.g. the auth/identity + engine internals). GRANT SELECT ON SCHEMA::dbo TO genie_assistant_ro; DENY SELECT ON SCHEMA::Genie TO genie_assistant_ro; -- engine internals (ViewStore, chats, …) DENY SELECT ON SCHEMA::Identity TO genie_assistant_ro; -- users/roles/tokens -- (no INSERT/UPDATE/DELETE/EXECUTE granted → writes are impossible) ``` ### 2. RLS predicate + policy (apply to every tenant table with a `CompanyId`) [Section titled “2. RLS predicate + policy (apply to every tenant table with a CompanyId)”](#2-rls-predicate--policy-apply-to-every-tenant-table-with-a-companyid) ```sql CREATE SCHEMA sec; GO -- Returns 1 (row visible) when the caller is System, or the row's company matches the session context. -- No context set → both SESSION_CONTEXT calls are NULL → returns nothing → fail closed. CREATE FUNCTION sec.fn_assistant_company_scope(@CompanyId bigint) RETURNS TABLE WITH SCHEMABINDING AS RETURN SELECT 1 AS ok WHERE TRY_CONVERT(int, SESSION_CONTEXT(N'genie.IsSystem')) = 1 OR TRY_CONVERT(bigint, SESSION_CONTEXT(N'genie.CompanyId')) = @CompanyId; GO -- Repeat this block per tenant table: CREATE SECURITY POLICY sec.AssistantScope_Product ADD FILTER PREDICATE sec.fn_assistant_company_scope(CompanyId) ON dbo.Product WITH (STATE = ON); GO ``` Filter predicates apply to every login RLS filter predicates apply to **all** logins hitting the table. Scope the policy so it only affects the assistant: either gate the predicate on the login (e.g. `AND ORIGINAL_LOGIN() = 'genie_assistant_ro'`) or, cleaner, keep the assistant login separate and only add policies you’re comfortable applying app-wide. Test against your app’s own queries before enabling `STATE = ON` in production. ## PostgreSQL [Section titled “PostgreSQL”](#postgresql) ### 1. Read-only, least-privilege role [Section titled “1. Read-only, least-privilege role”](#1-read-only-least-privilege-role) ```sql CREATE ROLE genie_assistant_ro LOGIN PASSWORD ''; GRANT USAGE ON SCHEMA public TO genie_assistant_ro; GRANT SELECT ON ALL TABLES IN SCHEMA public TO genie_assistant_ro; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO genie_assistant_ro; REVOKE ALL ON SCHEMA "Genie", "Identity" FROM genie_assistant_ro; -- keep internals off-limits -- no INSERT/UPDATE/DELETE granted → writes are impossible ``` ### 2. RLS policy (apply to every tenant table with a `company_id`) [Section titled “2. RLS policy (apply to every tenant table with a company\_id)”](#2-rls-policy-apply-to-every-tenant-table-with-a-company_id) ```sql ALTER TABLE public."Product" ENABLE ROW LEVEL SECURITY; ALTER TABLE public."Product" FORCE ROW LEVEL SECURITY; -- apply even to the table owner CREATE POLICY assistant_company_scope ON public."Product" FOR SELECT USING ( current_setting('genie.is_system', true) = '1' OR "CompanyId" = NULLIF(current_setting('genie.company_id', true), '')::bigint ); ``` `current_setting(key, true)` returns NULL when the key is unset (`missing_ok`), so with no context the predicate is false → fail closed. ## Wire the assistant to the restricted login [Section titled “Wire the assistant to the restricted login”](#wire-the-assistant-to-the-restricted-login) Point the assistant at the read-only connection (kept out of source, like the API key): appsettings.json ```jsonc "Genie": { "Assistant": { "Provider": "DeepSeek", // … "ConnectionString": "" // leave empty to use the app connection (prompt-only scope) } } ``` Set it via environment variable so the secret stays out of config (nested keys map via `__`): ```powershell setx Genie__Assistant__ConnectionString "Server=…;User Id=genie_assistant_ro;Password=…;TrustServerCertificate=True" ``` With it set, `AssistantQueryExecutor` routes every generated query through that login inside a transaction with the session context applied. With it empty, behaviour is unchanged (prompt-only scope). See [Configuration & providers](/assistant/configuration/) for the full options reference. ## What this does and doesn’t cover [Section titled “What this does and doesn’t cover”](#what-this-does-and-doesnt-cover) * ✅ **Writes/DDL:** impossible on the read-only login (belt-and-suspenders with the SELECT-only validator in `ExecuteQueryTool`). * ✅ **Cross-tenant row reads:** blocked by RLS regardless of the model’s SQL, once policies are applied. * ✅ **System role:** sees all tenants (via the `IsSystem` flag). * ✅ **Object/column *metadata*:** the schema context the model receives is built from Genie’s modeled metadata (`EntityStore`) and **permission-filtered** — a non-System caller only learns about tables reachable through a view they hold View/List on, and engine/identity internals are never modeled there, so they are never described to the model. (Applies whenever `EntityStore` is populated; a host with no modeled entities falls back to a raw catalog read.) * ✅ **Catalog enumeration:** the SELECT-only validator also **rejects catalog/metadata reads** — `INFORMATION_SCHEMA`, SQL Server `sys.*`, and PostgreSQL `pg_catalog` / `pg_*`. The permission-filtered schema context is the only table/column source the model may use, so it can’t enumerate names outside the caller’s view boundary. * ⚠️ You must add an RLS policy for **each** tenant table you want protected; tables without a policy are unfiltered (but still read-only under the restricted login). ## Related [Section titled “Related”](#related) * [AI Assistant overview](/assistant/overview/) — the tool orchestrator and RBAC-scoped execution. * [Multi-tenant company scoping](/security/multi-tenancy/) — how the rest of the engine enforces tenancy in SQL. * [Security model](/security/overview/) — the layered model this hardening plugs into. # Components: Table, Form, View > How GenieTable, GenieForm and GenieView render — fetching structure from metadata and values from the object endpoints, merging them client-side, and generating the DOM ids the server no longer ships. `GenieTable`, `GenieForm`, and `GenieView` are the three render surfaces for a Genie object. They share one contract: fetch the object’s **structure** once, fetch a page of **rows** or a record’s **values** as needed, merge the two **client-side**, and render. The server ships **no markup** — the React components reproduce the `genie-*` DOM themselves, and even the container/view ids are generated in the browser. Which of the three renders is decided by the [router](/frontend/routing/) from the route (`/table|form|view/{name}`); a single object can appear as all three (a grid, an editable form, a read-only view). ## The metadata + values merge [Section titled “The metadata + values merge”](#the-metadata--values-merge) Every surface follows the same two-request pattern, wired through a hook: ```text GET /object/{name}/metadata ┐ POST /object/{name}/table ├──▶ builder ──▶ GenieTable / GenieForm / GenieView POST /object/{name}/form|view ┘ ``` * **Structure** is fetched once and **held** (`schemaRef` in the hook; also cached in the API client). Schemas only change on a backend redeploy, so re-navigating re-fetches only data, not structure. * **Data** re-fetches whenever inputs change — a new page, sort, filter, or record. * The hook then calls a **builder** (`buildTableModel` / `buildFormModel`) that merges schema + data into the render model the component consumes. | Surface | Hook | Structure request | Data request | Builder | | ------------ | --------------------------- | ------------------- | ------------------------- | ----------------- | | `GenieTable` | `useGenieTable` | `getObjectMetadata` | `getTableData` (`/table`) | `buildTableModel` | | `GenieForm` | `useGenieForm` | `getObjectMetadata` | `getFormData` (`/form`) | `buildFormModel` | | `GenieView` | `useGenieForm` (`readOnly`) | `getObjectMetadata` | `getViewData` (`/view`) | `buildFormModel` | `GenieView` reuses `useGenieForm` with `readOnly: true` — the same schema + values, forced into view mode server-side. See [Object endpoints](/api-reference/object-endpoints/) for the wire contract and [the structure-vs-data split](/security/disclosure-gate/) for why the boundary exists. ### Client-generated ids [Section titled “Client-generated ids”](#client-generated-ids) Each hook generates a stable per-instance **`ViewId`** (`crypto.randomUUID()`, falling back to a timestamp) and sends it with every request; the form/table container id is composed from it client-side (e.g. `` `${model.Name}_${model.ViewId}_form` ``). The comment in the source is explicit — “the server no longer ships one.” The id also correlates uploads and scopes the rendered DOM. ## GenieTable — the grid [Section titled “GenieTable — the grid”](#genietable--the-grid) `GenieTable` renders a toolbar, an optional filter row, the sortable data grid, row actions, expandable sub-views, and pagination. It’s a controlled component: `useGenieTable` owns page/sort/ filter/search state and passes callbacks in. **Toolbar** (suppressed entirely when the view sets `DisableControls`): * **Add New** — the only labelled button, shown only with the `Create` permission. * **Refresh**, **Export**, **Import**, **Columns** (the column manager: show/hide + reorder). Export/Import are split buttons — a default handler plus any custom [export/import handlers](/model-authoring/import/) as dropdown items, each permission-gated. * **Filters** toggle → reveals the per-column filter row; **Apply** (enabled only when the draft changed) and **Clear**. * **Select mode** → row checkboxes for bulk delete (only with the `Delete` permission). * A table-level **Actions** dropdown for view-level actions. * Right side: a **quick-search** box (only when the view is `Searchable` — an OR-gated `LIKE` across the view’s searchable columns), the **page-size** select, and a “Showing X to Y of Z” range. **Grid body:** * **Sorting** — sortable headers carry independent asc/desc controls; the active column/direction is reflected back from the model. * **Per-column filters** — booleans render a tri-state select, dates a date input, and text/number a operator select (contains / equal / not-equal / `>` / `<` / `>=` / `<=`) plus a value; edited as a draft and applied on demand. * **Cells** — text (with an optional client-side display `Expression`), booleans, dates (with optional self-updating relative time), attachment tags (thumbnails + file pills), and **lookup links** that open a linked record read-only in a dialog. Cell classes combine the column’s `StyleClasses`, its evaluated `StyleClassesExpression`, and the host’s optional `cellClass` resolver. * **Row actions** — a per-row menu. A `Link` action navigates (SPA or new tab) after `{Column}` token substitution; a `Confirm` action prompts first; a `Prompt` action collects inputs in a modal; others POST to `/object/execute-row-action`. Buttons are coloured by a `Color` value mapped to a [`gx-{key}`](/frontend/theming/#the-named-gx--colour-vocabulary) class (row actions fall back to `gx-info`; the standard Edit is `gx-primary`, Delete `gx-danger`). * **Sub-views** — a row expands to a full-width panel hosting a nested table or form (`SubViewPanel`), scoped to the parent row via `Parent__{Column}` parameters. See [Sub-views](/model-authoring/sub-views/). **Pagination** is windowed (first/last always shown, ±1 around the current page, ellipses between), and hidden when there’s a single page. ## GenieForm — the editable form [Section titled “GenieForm — the editable form”](#genieform--the-editable-form) `GenieForm` renders the record’s **layout tree** (`GenieFormLayout`) and Submit/Cancel actions. * **Layout** — the builder produces a tree of nodes the layout renderer walks: **section** (`
` with a legend, optionally an accordion), **row** (a 12-column track), **column** (`col-{Width}`, default 12), **field**, **line** (`
`), **grid**, **tab**, and **sub** (a nested sub-view). A `wizard` layout type turns each top-level tab into a step with Back/Next/Submit. When a view declares no layout, fields render as one flat row. See [Form layout](/model-authoring/layout/). * **Editor fields** — `GenieField` dispatches on `EditorType` to the right editor: Boolean (checkbox / switch), Select (static, dataset-backed searchable, or a modal selector), Text (single-line / multiline / lazy-loaded rich text), Number, Email, Password, Date/Time/DateTime (with UTC↔local conversion), Attachment, CartTable, and Sequence. See [Editor fields](/model-authoring/editor-fields/). * **Dynamic state** — each field’s `Required` / `Disabled` / `Hidden` and computed `Value` are resolved live by the [expression engine](/frontend/expression-engine/) as values change; a role/expression **lock** renders the field read-only. * **Validation** — before submit, a client-side check flags any field that is required, visible, and enabled but empty: those groups highlight, a warning toast appears, and the form scrolls to the first. This is a UX convenience — required-ness is **re-validated on the server**. * **Submit** — the Submit button is gated on `CanSubmit` (a UI hint; the server re-checks Create/Update). `GenieForm` itself only emits the collected `values` via `onSubmit`; the actual `POST /object/submit` and any follow-on **dispatch** (navigate, refresh, or a workflow start/transition) are handled by the caller (the router). Server errors surface as an error toast. ## GenieView — the read-only view [Section titled “GenieView — the read-only view”](#genieview--the-read-only-view) `GenieView` renders the same layout tree in **read-only** form (the React equivalent of the legacy view renderer). It walks the layout, skips hidden fields, and formats each value for display: booleans as a disabled checkbox, `Select` values resolved to their option label (dataset-backed selects resolve the label via `/object/field-dataset`), rich text sanitized with DOMPurify before insertion, attachments as tags, a `CartTable` as a read-only nested sub-view (never raw JSON), and empty values as an em-dash. Its action bar (`GenieViewActions`) offers a single “More actions” menu: **Edit** (with the `Update` permission, navigating to the form route), **Delete** (with `Delete`), and any visible custom row actions — the same action semantics as the grid. The client is a hint, never an authority `Permissions`, `CanSubmit`, and field `hidden`/`disabled` flags in a response exist so the UI renders sensibly. The server re-checks the real permission on every read and write. See the [Security model](/security/overview/). # The expression engine > The eval-free, CSP-safe expression engine that drives field required/disabled/hidden/value rules and grid column styling — mirroring the @Field / If(...) syntax authored in XML, with a matching C# port on the server. Genie fields and grid columns carry small **expressions** — a field is required only when another field has a certain value, a cell is styled by its status, a total is computed from line items. These run on the client through an **eval-free expression engine** (in `expression/`), and the backend ships a **matching C# port** so the same rules are *enforced* server-side, not merely hinted. The syntax mirrors the SQL-flavoured predicates authors already write in XML (`@Field`, `LIKE`, `IN`, `AND`/`OR`/`NOT`, `If(...)`) — see [Column & field expressions](/model-authoring/expressions/) for the authoring reference. This page describes the client runtime. ## CSP-safe by construction [Section titled “CSP-safe by construction”](#csp-safe-by-construction) There is **no `eval` and no `Function`** anywhere in the engine. `expression/interpreter.ts` is a hand-rolled tokenizer, a recursive-descent parser, and a tree-walker over a **closed grammar** — so a strict Content-Security-Policy (no `unsafe-eval`) never blocks it, and a malicious expression can only reach the values and functions explicitly placed in its scope. Two safety properties are baked in: * **Forbidden member keys** — accessing `__proto__`, `constructor`, or `prototype` throws, so an expression can’t walk the prototype chain to reach a global. * **Undefined identifiers throw**, but callers (below) **fail closed** by catching the error and treating the result as `false` / no-change — a typo disables a rule rather than crashing the form. Parsed ASTs are cached (LRU, 256 entries) so re-evaluating the same expression as values change is cheap. ## From SQL syntax to the interpreter [Section titled “From SQL syntax to the interpreter”](#from-sql-syntax-to-the-interpreter) `expression/evaluate.ts` first rewrites the Genie SQL-flavoured source into the interpreter’s JS-like grammar (`transformSqlToJs`), then runs it: | Authored (SQL-flavoured) | Becomes | | ------------------------ | ----------------------------------------------------- | | `@Field` / `Field` | `Field` (bare identifier, resolved from scope) | | `A = B` | `A == B` (loose equality, matching the legacy engine) | | `A <> B` | `A != B` | | `AND` / `OR` / `NOT` | `&&` / `\|\|` / `!` | | `Field LIKE 'ab%'` | `__likeMatch(Field, 'ab%')` | | `Field NOT LIKE 'ab%'` | `!__likeMatch(Field, 'ab%')` | | `Field IN (A, B)` | `(Field == 'A' \|\| Field == 'B')` | | `Field NOT IN (A, B)` | `(Field != 'A' && Field != 'B')` | `LIKE` uses SQL wildcard semantics — `%` = any run, `_` = one char — and is **case-insensitive**. ### Supported operators [Section titled “Supported operators”](#supported-operators) The interpreter grammar (in precedence order, low to high) supports: * Logical `||`, `&&` (short-circuiting) * Equality `==`, `!=` * Comparison `<`, `<=`, `>`, `>=` * Arithmetic `+`, `-`, `*`, `/`, `%` * Unary `!`, `-`, `+` * Grouping `( … )`, member access `a.b` / `a["b"]`, and function calls `f(a, b)` * Literals: numbers, single/double-quoted strings (with `\n \r \t \\ \" \'` escapes), `true`, `false`, `null`, `undefined` Equality is intentionally **loose** (`==` / `!=`) so `@Qty = '0'` matches a numeric `0`, mirroring the server port exactly. ### Built-in functions [Section titled “Built-in functions”](#built-in-functions) Available to every expression scope (`evaluate.ts`): | Function | Purpose | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `If(cond, a, b)` (aliases `IIF`, `iif`) | Conditional — mirrors the DataColumn `IIF` authors know. Both branches are eagerly evaluated (safe for the literal/field-reference branches these expressions use). | | `sumLines(cart, 'QtyCol', 'PriceCol')` | Sums `Qty × Price` across a [``](/model-authoring/cart-table/) value (a JSON array of row objects). Tolerates a missing/blank/invalid cart (→ 0). | A host may register **extra functions** via `handlers.expressionFunctions` on `createGenieApp`; they are merged over the built-ins (so a host entry can override one of the same name) and reach both grid `StyleClassesExpression` and field rules. See [Render extension handlers](/integration/frontend/#render-extension-handlers). ## What the engine drives [Section titled “What the engine drives”](#what-the-engine-drives) ### Field required / disabled / hidden [Section titled “Field required / disabled / hidden”](#field-required--disabled--hidden) `resolveFieldState` evaluates a field’s `Required` / `Disabled` / `Hidden` expressions against the form’s **current values**, falling back to the field’s static base flag when an expression is absent. Each rebuild re-runs as the user types, so a field can appear, become required, or lock live. `evaluateBoolean` **fails closed**: any parse/eval error returns `false`, so a broken rule never throws inside a render. ### Field-level security (Allow / Deny) [Section titled “Field-level security (Allow / Deny)”](#field-level-security-allow--deny) The `Locked` state folds in **field-level security**. A field is disabled when its own `Disabled` rule resolves true **OR** its Allow/Deny access rule (`buildLockedExpression`, in `expression/fieldRule.ts`) resolves true. `fieldRule.ts` also classifies whether an `Allow`/`Deny` value is a **role list** (`Admin,Manager`) or an **expression** (`@Type == 'B'`) — the one place that decision is made, so the client agrees with the server. Role-list locks are checked against the current user’s roles (`isRoleLocked`, mirroring the server’s `FieldStateResolver.IsRoleLocked`). This is a **UX hint only** — the server re-enforces the same Allow/Deny gate on submit and discards a denied field’s caller-supplied value. See [Field & column security](/security/field-security/). ### Computed values [Section titled “Computed values”](#computed-values) `evaluateValue` runs a field’s `ValueExpression` and returns the result as the field’s string value (numbers/booleans coerced to strings), or `null` on error / when absent (the caller then keeps the field’s current value). This is how a `TotalAmount` field auto-calculates from cart line items via `sumLines`. ### Grid column styling [Section titled “Grid column styling”](#grid-column-styling) A column’s `StyleClassesExpression` is evaluated **per row** against the row’s cell values; its string result (typically a `gx-{key}` class) is appended to the cell’s CSS classes — e.g. `If(Status = 'Overdue', 'gx-danger', 'gx-success')`. The named `gx-` colour keys are documented under [Theming](/frontend/theming/#the-named-gx--colour-vocabulary). ## One grammar, two runtimes [Section titled “One grammar, two runtimes”](#one-grammar-two-runtimes) The invariant The TypeScript interpreter and the C# port are kept behaviourally identical — loose `==` equality, short-circuit evaluation, forbidden member keys, the same built-ins. The UI evaluates rules live for responsiveness; the server re-evaluates them (against the **stored** row for edits) as the real gate. A crafted request that flips a client-side flag gets nothing past the server. # Shell & routing > How GenieRouter maps /object/{name} (grid/view/edit/create) and /form/{name} (wizards) to the right hook + component, useGenieRoute + navigate, the GenieLayout shell and GenieNavbar, the auth screens, and the GenieApiClient. The shell is what wraps the render surfaces: a router that turns a URL into the right component, a layout with a module rail + sidebar + header, the navbar fed from the engine, the auth screens, and the API client every part shares. `createGenieApp` wires these together — see [Frontend configuration](/integration/frontend/). ## GenieRouter [Section titled “GenieRouter”](#genierouter) `GenieRouter` reads the current route (via `useGenieRoute`) and renders the matching component. An object (a table/view definition) lives in **one `/object/{name}` space**, with the record **mode** encoded in the path; wizards live at `/form/{name}`: | Route | Mode | Renders | Hook | | ----------------------------- | -------------- | ------------------- | -------------------------- | | `/object/{name}?params` | grid (listing) | `GenieTable` | `useGenieTable` | | `/object/{name}?id={pk}` | view a record | `GenieView` | `useGenieForm` (read-only) | | `/object/{name}/edit?id={pk}` | edit | `GenieForm` | `useGenieForm` | | `/object/{name}/create` | create | `GenieForm` | `useGenieForm` | | `/form/{key}?id=&flow=` | wizard | `GenieWizardRunner` | — | The record’s primary key travels as the reserved lowercase **`id`** query param (a bare `/object/{name}` with `id` is a single-record view; without it, the grid). Internally the client canonicalizes `id` → `Id` for the engine’s form/view contract — so a model field literally named lowercase `id` collides with the pk (the pk wins); avoid it. Beyond these, the router handles the report page (`/reports/{slug}`, plural — see [Report pages](/reports/overview/)), the visual designers (`/workflow-designer`, `/wizard-designer`, `/navbar-designer` — System-role only — and [`/report-designer`](/reports/designer/), which takes the definition to edit as `?Id={slug}` and opens a blank new document without one) and the admin tools (`/object-explorer`, `/jobs`, `/hosted-services`) — all **lazy-loaded**, so their (heavy) chunks download only when the route is opened, and only when the owning module is enabled (a disabled module shows a “not enabled” pane and never triggers the download). Any other non-root path becomes a `path` route — the extension point for host-registered custom pages — and `/` is home. The view/edit/create branches are **keyed by route** so navigating between records remounts the component with fresh state rather than leaking the previous record’s values (the grid is keyed by object name). The router also owns the create/edit flow: a top-level grid navigates to an `/object/{name}/create|edit` page by default, while an object whose `FormStyle` is `Modal` opens the form in a dialog and refreshes in place on save. After a directed form submits, it returns to the grid it was opened from (carried in the URL), which keeps that object’s **navbar item highlighted** throughout the view/edit/create flow. **Legacy routes.** The former `/table/{name}`, `/view/{name}?Id=`, and `/wizard/{name}` URLs still resolve — they are normalized to the new scheme (and rewritten in the address bar). The one exception is the old `/form/{name}` **entity-form** URL: `/form/` now means *wizard*, so those old links no longer resolve. ## useGenieRoute, navigate & hrefs [Section titled “useGenieRoute, navigate & hrefs”](#usegenieroute-navigate--hrefs) `useGenieRoute` (in `shell/useGenieRoute.ts`) parses `window.location` into a `GenieRoute` (`{ kind, name, params, mode?, raw }` — `mode` is set for `kind: "object"`) and re-renders on `popstate` and on programmatic navigation. * **`navigate(href, { replace? })`** updates history (`pushState` / `replaceState`) and dispatches an internal event so the hook re-reads the route — no full-page load. * **`buildObjectHref(name, { mode, id?, params? })`** builds a canonical object href, e.g. `buildObjectHref("Orders", { mode: "grid", params: { Status: "Active" } })` → `/object/Orders?Status=Active`, `buildObjectHref("Orders", { mode: "edit", id: "5" })` → `/object/Orders/edit?id=5`. * **`buildGenieHref("wizard", name, params)`** builds a wizard href → `/form/{name}?…`. * **`normalizeGenieHref`** rewrites legacy routes from the navbar/DB (hash `#/table/Name/c/k=v`, or browser `/table`,`/view`,`/wizard`) into the canonical `/object` / `/form` scheme, so older stored routes keep working with no data migration. Routing mode is set by `createGenieApp({ routing })`: **`"browser"`** (default) uses History-API paths and requires the host to serve `index.html` for unknown paths (SPA fallback); **`"hash"`** avoids that server requirement. Auth/account pages always use the hash, independent of this setting. ## GenieLayout — the app shell [Section titled “GenieLayout — the app shell”](#genielayout--the-app-shell) `GenieLayout` is the three-column shell: a **module rail**, a contextual **sidebar**, and a **header**, on a `has-rail` grid. Its behaviour: * **Modules** are derived from the navbar’s top-level items. Clicking a rail module swaps the sidebar (or navigates, for a direct-link module); the active module is inferred from the current route, with a manual selection winning until navigation moves elsewhere. * **Collapse** — the sidebar collapses to a rail-only view (persisted to `localStorage`); on mobile the rail + sidebar become an off-canvas drawer that auto-closes on navigation. The full-canvas designers auto-collapse the sidebar for room and restore the preference on exit. * **Header** — the hamburger, an optional **global search** (nav + configured datasets, focused with Ctrl/Cmd-Shift-F when `headerSearch` is enabled), the **accent picker** and **light/dark toggle** (both from `useGenieTheme`), and a host-supplied right slot (notifications, user menu). * **Breadcrumb** — rendered from the current route + nav items; a per-route error boundary isolates a render failure to its page. Theming (`data-theme` / `data-accent`, persistence, the picker) is covered in [Theming](/frontend/theming/). ## GenieNavbar [Section titled “GenieNavbar”](#genienavbar) `GenieNavbar` renders the contextual sidebar: a module-context header, a menu search, and the active module’s navigation tree (up to three levels, collapsible groups). Typing in the menu search switches to a flat **cross-module** result list so nothing hides behind module selection. It consumes the navbar **JSON** from the engine — nodes of kind `Section` (heading), `Divider`, or a regular item/group — and renders right-aligned **badges**: a numeric count pill or a themed status dot. Where the tree comes from (the permission-filtered `/navbar` endpoint or a host-supplied static tree) is set by `navbar.source`. See [Navigation](/platform/navigation/) for the navbar model and [Frontend configuration](/integration/frontend/#navbar-source) for the config. ## Auth screens [Section titled “Auth screens”](#auth-screens) The auth experience — login, MFA/TOTP, forgot/reset password, change password, profile — lives under `auth/`. `GenieAuthProvider` holds the session; `useAuth` exposes the current user, roles, and sign-in/out. The auth screens run on **hash routes** (independent of the data-page routing mode) and their affordances adapt to the server’s enabled features, read from `GET /auth/config` (which login methods, MFA channels, reCAPTCHA, password rules are on) — so nothing is hardcoded on the client. The login/forgot/reset screens render beside a gradient **brand panel** customizable via the `auth` block (see [Theming](/frontend/theming/#logo-monogram--brand-panel)). While the session resolves and the authenticated app-shell chunk downloads, a branded loading splash shows (replaceable via `preloader`). ## GenieApiClient [Section titled “GenieApiClient”](#genieapiclient) `GenieApiClient` (`api/client.ts`) is the single fetch layer every part shares. It: * **Carries the JWT** — login returns an access token (not a cookie); the client stores it in `localStorage`, sends it as `Authorization: Bearer …`, and (for the WebSocket transport, which can’t set a header) exposes it for the SignalR `access_token` query. * **Unwraps the envelope** — the engine wraps every response in `{ Success, Error, Data }`; the client returns `Data` (or throws a `GenieApiError` carrying the server’s message) so callers work with plain payloads. Error bodies are reduced to a human-readable message; raw JSON is never shown to the user. * **Sends context headers** — the antiforgery token (`X-XSRF-TOKEN`) on unsafe requests and the browser’s IANA time zone (`X-TimeZone`) on every request, plus `credentials: "include"` for the cookie/company/timezone session. * **Handles 401** — a 401 on any non-auth endpoint fires the registered unauthorized handler (the auth provider signs out and bounces to login); auth-endpoint 401s are left to their callers so a bad login doesn’t loop. * **Caches structure & de-dupes requests** — object metadata is cached in-memory (`schemaCacheTtlSeconds`), and concurrent identical reads (every `GET` plus the read-style POSTs) share one in-flight fetch. Mutations are **never** coalesced. See [Request de-duplication](/integration/frontend/#request-de-duplication). Because the auth, notification, wizard, workflow, and Hangfire controllers sit as **siblings** of the `/genie` base, the client derives those bases from `apiBase` (e.g. `.../auth`, `.../notification`) — so you only ever configure the one `apiBase`. # Theming > The token-driven theme system — dark/light via [data-theme], swappable accent palettes via [data-accent], the named gx- color vocabulary, surface tokens, and logo/monogram/custom colours configured through createGenieApp. Genie’s UI is **entirely token-driven**: no component hardcodes a colour. Every surface, border, and text shade reads a CSS custom property, so a component looks right in **light**, **dark**, and under **every accent** because it only references `--bg-*`, `--text-*`, `--border`, `--accent*`, and the semantic `--success/--warning/--danger/--info` tokens. Theming is therefore a matter of swapping the variables, which the runtime does by flipping two attributes on ``. The tokens live in `theme/foundation/tokens.css`; they are the port of the design-system mockup. All host-facing configuration flows through the `theme` block on `createGenieApp` — see [Frontend configuration](/integration/frontend/) for where that config lives. ## Two axes: theme and accent [Section titled “Two axes: theme and accent”](#two-axes-theme-and-accent) The whole look is controlled by two independent attributes on the document element: | Attribute | Values | Controls | | ------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `data-theme` | `light` \| `dark` | Surfaces & text (the neutral canvas) | | `data-accent` | `indigo` \| `violet` \| `emerald` \| `amber` \| `rose` \| `sky` \| `orbyn` \| *custom* | The brand accent — links, focus rings, primary buttons, gradients | `useGenieTheme` (in `shell/useGenieTheme.ts`) owns both. It sets `data-theme` / `data-accent` on ``, persists each to `localStorage` (`genie-theme` / `genie-accent`), and exposes the header’s light/dark toggle and accent picker. ```html ``` ### Dark / light [Section titled “Dark / light”](#dark--light) `theme.mode` picks the starting mode: ```tsx createGenieApp({ apiBase: "/api/v1/genie", theme: { mode: "system" } }); ``` * `"system"` follows the OS preference (`prefers-color-scheme`) on first load. * `"light"` / `"dark"` force a starting mode. Either way, a user’s explicit toggle is remembered (`localStorage`) and **wins over** the configured default on the next visit. Switching theme adds a one-frame `genie-theme-switching` class that suppresses per-element colour transitions, so flipping `data-theme` repaints atomically instead of animating every element at once. Native controls follow the theme Each theme block also sets `color-scheme`, so the browser renders native widgets (date/time pickers, scrollbars) in the matching light or dark chrome. ### Accent palettes [Section titled “Accent palettes”](#accent-palettes) An accent palette redefines just **five** variables — the rest of the theme reads them: ```css [data-accent="orbyn"] { --accent: #2563eb; --accent-strong: #1747c8; --accent-soft: rgba(37, 99, 235, 0.12); --grad-from: #2563eb; --grad-to: #1747c8; } ``` `--gradient` is derived once as `linear-gradient(135deg, var(--grad-from) 0%, var(--grad-to) 100%)`. The accent shows up in links, focus rings (`0 0 0 3px var(--accent-soft)`), primary/active buttons and the form-card header underline (the **gradient**), and selected table rows (`--accent-soft`). Seven palettes ship built-in: `indigo` (default), `violet`, `emerald`, `amber`, `rose`, `sky`, and `orbyn` (the Orbyn brand blue). Configure which are offered and which is the default: ```tsx createGenieApp({ apiBase: "/api/v1/genie", theme: { accent: "orbyn", // default accent when the user hasn't picked one accents: ["orbyn", "sky"], // the picker's allowed set (see below) }, }); ``` The `accents` array is the **single- vs multi-colour switch**: * **Multiple entries** → the header shows an accent picker and users may switch (persisted). * **A single entry** (e.g. `["orbyn"]`) → the picker is hidden and the app is **locked** to that one colour theme. Omit `accents` to offer all built-ins. ## The named `gx-` colour vocabulary [Section titled “The named gx- colour vocabulary”](#the-named-gx--colour-vocabulary) Separate from the brand accent, Genie exposes a **fixed vocabulary of named colour keys** for places where a colour is chosen *by name* in XML — row-action buttons, sub-view toggles, badges, and chips (authored via `Color="…"` / class expressions). Each key defines a base (`--c-{key}`, for text/border/fill) and a soft tint (`--c-{key}-soft`, for subtle backgrounds): ```plaintext primary · info · success · warning · danger · secondary · purple · pink indigo · sky · teal · orange · lime · rose · slate ``` A `.gx-{key}` utility class (in `theme/global/colors.css`) pipes the key’s tokens into generic `--gx` / `--gx-soft` properties, and any consuming element paints itself from `var(--gx)`. So a single colour key on an element drives its whole look — no per-colour rule per component: ```css .genie-shell .gx-danger { --gx: var(--c-danger); --gx-soft: var(--c-danger-soft); } ``` This is what makes `StyleClassesExpression="If(Status = 'Overdue', 'gx-danger', 'gx-success')"` on a grid column, or `Color="teal"` on a row action, render consistently in both light and dark. `primary` follows the active accent (so it swaps with `[data-accent]`); the other keys are brand-independent and stable. See [Column & field expressions](/model-authoring/expressions/) for the authoring side. ## Surface & semantic tokens [Section titled “Surface & semantic tokens”](#surface--semantic-tokens) `data-theme` swaps a matched set of surface and text tokens (values below are the light theme; the dark block re-defines them): | Group | Tokens | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Backgrounds | `--bg-app` (page canvas), `--bg-surface` (cards/header/sidebar), `--bg-sunken` (inputs, table stripes), `--bg-hover`, `--bg-active`, `--bg-overlay` | | Text | `--text-strong`, `--text`, `--text-muted`, `--text-faint`, `--text-on-accent` | | Borders | `--border`, `--border-strong` | | Shadows | `--shadow-xs` … `--shadow-lg` | | Semantic | `--success`, `--warning`, `--danger`, `--info` (+ `-soft` tints) | The semantic colours mean **meaning, not brand** — use them for success/warning/danger/info, and keep the accent for brand emphasis. Soft variants are backgrounds; base variants are foreground. Neutral foundations (type, spacing, radius, motion) are theme-independent: `--font-sans`, `--font-mono`, the `--sp-1…--sp-12` spacing scale, `--r-sm…--r-pill` radii, `--sidebar-w` / `--header-h` chrome dimensions, and the `--ease` / `--dur` motion tokens. ## Logo, monogram & brand panel [Section titled “Logo, monogram & brand panel”](#logo-monogram--brand-panel) The brand mark is set once via `theme.logo` and reused in the shell rail chip, the auth brand panel, and the loading splash: ```tsx createGenieApp({ apiBase: "/api/v1/genie", theme: { logo: , // a ReactNode (inline SVG) renders as-is… // logo: "/brand/logo.svg" // …or a URL string renders as an }, }); ``` The auth screens render a split layout with a gradient **brand panel** beside the form. Customize it through the `auth` block on `createGenieApp` (not the theme block) — either replace the whole panel (`auth.brandPanel`) or keep the default chrome and swap only its copy (`auth.brandContent`): ```tsx createGenieApp({ apiBase: "/api/v1/genie", auth: { brandPanel: }, }); ``` The panel’s logo follows `theme.logo` and its colours follow the theme tokens. See [Frontend configuration](/integration/frontend/) for the full `auth` and `preloader` options. A replacement panel is rendered as the **first child of the auth grid**, in place of `.auth-brand` — so it should fill its column (no `max-width`, no radius) rather than sit inside it as a card, or the screen reads as a slab floating beside a gutter. Below 860px the grid collapses to one column and Genie hides every child that is not the form panel, custom ones included, so a panel needs no narrow-screen rule of its own. `theme.logo` also appears on the loading splash, where Genie sizes it to 52px tall. An SVG **with a `viewBox` and no `width`/`height` attributes has no intrinsic size** — before that rule it painted at the replaced-element default of 300×150 and swallowed the card. ## Custom colours [Section titled “Custom colours”](#custom-colours) Two escape hatches on `theme`, layered *after* the base theme so they win: ```tsx createGenieApp({ apiBase: "/api/v1/genie", theme: { // (a) single-property overrides applied to :root variables: { "--r-lg": "12px" }, // (b) raw CSS injected once into — register your own accent palette, then select it css: ` [data-accent="acme"] { --accent: #e2571e; --accent-strong: #c9410f; --accent-soft: rgba(226,87,30,.12); --grad-from: #e2571e; --grad-to: #f59e0b; } `, accent: "acme", accents: ["acme"], }, }); ``` `variables` is for single-property tweaks (a brand colour, a radius); `css` is for a full custom palette or token restyle. A custom accent name registered via `css` may be passed to `accent` / `accents` exactly like a built-in — `useGenieTheme` keeps the caller’s list verbatim rather than filtering to the built-ins. ## The theming invariants [Section titled “The theming invariants”](#the-theming-invariants) To keep a component correct across light, dark, and all accents: 1. **Never hardcode a colour** — always reference a token. 2. **Accent = brand, semantic = meaning.** Don’t paint success/error with the accent. 3. **The gradient is for primary emphasis only** (primary buttons, active page button, form-card header, logo/stat chips) — never for body surfaces. 4. **Soft variants are backgrounds; base variants are foreground.** # Backend integration > Wire Genie.Engine into your ASP.NET Core host — the fluent GenieBuilder, DbContext, source generator, and startup SQL. This page shows how to add the engine to your own **ASP.NET Core host**. For a complete, runnable example see the [Inventory sample](/getting-started/quickstart/); for installing the packages from GitHub Packages (auth, `nuget.config`) see [Consuming the packages](/packages/consuming/). ## 1. Reference the engine and wire the host [Section titled “1. Reference the engine and wire the host”](#1-reference-the-engine-and-wire-the-host) Reference `Genie.Engine`, register it through the fluent builder, and add the exception handler and hubs to the pipeline: ```csharp using Genie.Engine; builder.Services.AddGenie(genie => genie .LoadFromConfiguration(builder.Configuration)); // config-first: datasource + connection strings + options builder.Services.AddGenieAuth(builder.Configuration); // minimal auth; features are opt-in var app = builder.Build(); app.UseGenieExceptionHandler(); // first in the pipeline — consistent { success, error, traceId } JSON app.UseGenieCors(); // Genie:Cors policy; before auth (preflights are anonymous) app.UseGenieSpa(); // opt-in same-origin UI hosting (Genie:Spa); no-op while disabled // ... your middleware ... app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapGenieHubs(); // SignalR notification hub at /hubs/genie-hub app.Run(); ``` `AddGenie(genie => …)` registers the entire engine: view resolution, the unified Object services, identity/RBAC, workflow, wizard, sequences, notifications, **and the model-migration hosted service**. The `` type parameter registers the engine against a host-derived context so its EF migrations live in the host assembly (see [step 2](#2-derive-your-dbcontext-from-geniecontext)); the non-generic `AddGenie(genie => …)` overload stays on the base `GenieContext`. Controllers live in the engine assembly The engine ships the real controllers (`Object`, `Navbar`, `Auth`, …). Surface them with `services.AddControllers().AddApplicationPart(typeof(Genie.Engine.AssemblyMarker).Assembly)` — the Inventory sample does exactly this in [`Configuration/ServiceRegistration.cs`](/getting-started/quickstart/). ### The fluent `GenieBuilder` [Section titled “The fluent GenieBuilder”](#the-fluent-geniebuilder) All engine configuration flows through the fluent **`GenieBuilder`** (the same config-first + in-code-override pattern as `GenieAuthBuilder`). `LoadFromConfiguration(configuration)` binds the datasource (`Genie:Datasource`), the connection strings, and every options section from appsettings as the **base**, then any `Use*` / `Configure*` call **overrides it in code** (later wins). It also rejects pre-consolidation section names up front, so a stale appsettings fails loudly instead of binding to defaults: ```csharp builder.Services.AddGenie(genie => genie .LoadFromConfiguration(builder.Configuration) .UseDatasource(Datasource.PostgreSql) // or set Genie:Datasource in config .UseConnectionString("PostgreSql", secretFromVault) .ConfigureErrors(e => e.ExposeErrorDetails = false) .ConfigureStorage(s => { s.Type = StorageType.Local; s.Path = "App_Data/storage"; }) .ConfigureEmail(m => { m.Enabled = true; m.SmtpHost = "smtp.example.com"; })); ``` The builder also registers `IAppIdentity` (the datasource) and the `IGenieConnectionStrings` seam, so the host no longer wires those by hand — the data path resolves connection strings through the seam, not `IConfiguration`. Available fluent calls include `UseDatasource`, `UseConnectionString`, `UseAppIdentityType`, `AddWorkers`, and `ConfigureErrors` / `ConfigureCors` / `ConfigureSpa` / `ConfigureStorage` / `ConfigureEmail` / `ConfigureSms` / `ConfigureWarehousing` / `ConfigureMigrations` / `ConfigureSchemaCache` / `ConfigureUploads` (plus `ConfigureAuth` / `ConfigureHangfire`, honoured by the umbrella `AddGenieApp`). ### The umbrella `AddGenieApp` / `MapGenieApp` [Section titled “The umbrella AddGenieApp / MapGenieApp”](#the-umbrella-addgenieapp--mapgenieapp) `AddGenieApp(genie => …)` composes the whole platform from the **same** builder — the engine + Identity (`ConfigureAuth`) + Hangfire (`ConfigureHangfire`) + Assistant + OpenAPI. Because those modules read `IConfiguration` directly, you **must** call `LoadFromConfiguration(configuration)` inside the callback or `AddGenieApp` throws: ```csharp builder.Services.AddGenieApp(genie => genie .LoadFromConfiguration(builder.Configuration) .ConfigureAuth(auth => auth.AddImpersonation()) .ConfigureHangfire(h => h.RedisPrefix = "myapp-jobs")); var app = builder.Build(); app.UseGenieExceptionHandler(); app.UseGenieCors(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapGenieApp(builder.Configuration); // Razor Pages + both hubs + Hangfire dashboard + OpenAPI + SPA app.Run(); ``` `MapGenieApp` maps the Genie notification hub, the Assistant chat hub, the IP-gated Hangfire dashboard, OpenAPI/Swagger, and — when `Genie:Spa:Enabled` is set — same-origin hosting of the built React UI (see [Deployment](/integration/deployment/)). If you compose the engine manually instead (as the Inventory sample does), map the pieces yourself: `MapGenieHubs()` for the notification hub, and `MapAssistantHubs()` if you enable the [Assistant](/assistant/overview/). Register the engine’s config once `AddGenieApp` shares one assembled `GenieOptions` graph between the engine and the umbrella modules, so the configure lambda runs **once**. Don’t also call `AddGenie` — pick one entry point. ### Background workers [Section titled “Background workers”](#background-workers) The engine’s `MonitoredBackgroundService` workers (app-notification delivery, email/SMS drain loops, warehousing sync) are **config-driven by default** — app-notifications always run, the email/SMS workers run when their channel is `Enabled` + valid, and the warehousing sync worker runs when at least one [warehousing target](/platform/warehousing/) is valid. Call **`AddWorkers(...)`** to switch to **explicit opt-in** — only the workers you list run, so an API host can skip the drain/sync loops while a dedicated Worker host runs them (a Meilisearch target’s *read* provider stays active either way; only the *sync* worker is gated): ```csharp builder.Services.AddGenie(genie => genie .LoadFromConfiguration(builder.Configuration) .AddWorkers(w => w .AddAppNotificationWorker() .AddEmailWorker() // still needs a valid email channel .AddSmsWorker() // still needs a valid SMS channel .AddWarehousingSyncWorker())); ``` See [Notifications](/platform/notifications/) for the delivery channels, [Warehousing](/platform/warehousing/) for the replication targets, and [Global search](/platform/search/) for the search providers. ## 2. Derive your DbContext from `GenieContext` [Section titled “2. Derive your DbContext from GenieContext”](#2-derive-your-dbcontext-from-geniecontext) The host owns the EF context so its migrations live in the host assembly: ```csharp namespace YourApp.Persistence; public partial class YourContext(IConfiguration configuration, IAppIdentity appIdentity) : GenieContext(configuration, appIdentity) { protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // full Genie schema + seeds modelBuilder.ApplyConfigurationsFromAssembly(typeof(YourContext).Assembly); // your configs + generated ones } } ``` Mark it **`partial`** so the source generator can merge the low-code entities’ `DbSet`s into it (see [step 3](#3-wire-the-source-generator-entityxml--ef-tables)). `ApplyConfigurationsFromAssembly` is what registers the generated `IEntityTypeConfiguration` classes — no per-entity wiring needed. Because you passed `` to `AddGenie`, every engine service that depends on `GenieContext` resolves your derived context. ## 3. Wire the source generator (entity.xml → EF tables) [Section titled “3. Wire the source generator (entity.xml → EF tables)”](#3-wire-the-source-generator-entityxml--ef-tables) `Genie.Source` is a Roslyn generator. Reference it as an **analyzer** and feed it your entity files: ```xml YourContext ``` For each `*.entity.xml` the generator emits, **into your project’s namespace**: * a `partial class {EntityName} : EntityTraits` (audit/tenant/soft-delete + `Id` come from the base); * an enum per `Select` field and an FK navigation per `Lookup` field; * an `{EntityName}Configurations : EntityBaseConfiguration<{EntityName}, long>` (table, schema, indexes, relationships); * a `partial class {GenieDbContextName}` with a `DbSet<>` per entity (merges into your context). Then create the physical tables with a normal migration: ```bash dotnet ef migrations add --project YourHost --startup-project YourHost \ --context YourContext --output-dir Persistence/Migrations ``` Physical tables vs runtime metadata Physical tables come from the **EF migration** here — not from the runtime model migration in the next section. See [Model migration](/integration/model-migration/) for how the two relate. ## 4. Engine SQL objects (deployed at startup) [Section titled “4. Engine SQL objects (deployed at startup)”](#4-engine-sql-objects-deployed-at-startup) The engine’s own database objects — the **password-encryption** key/procs (`sp_encrypt_password` / `sp_decrypt_password`, or the PostgreSQL `pgcrypto` functions), the **sequence-number** functions (`[Genie].[GetNextSequenceNumber]` / `[Genie].[GetSequenceNumberPreview]`), and the company-scope helpers — are deployed **at startup** by `EngineSqlBootstrapper` (an `IHostedService` registered by `AddGenie`, ahead of the model-migration service). There is **nothing to add to a migration** — it’s automatic. Each unit of engine SQL is owned by a per-feature `IEngineSqlContributor` (`PasswordEncryptionSqlContributor`, `SequenceSqlContributor`, `CompanyScopeSqlContributor`); the bootstrapper collects them, each picks the dialect from the active datasource, substitutes the configured secrets in memory, executes via raw ADO.NET, and records each unit in the `Genie.ExecutedScripts` table keyed by a **content hash**: * On first boot, each unit runs and is recorded. * On later boots, an unchanged unit (same hash) is **skipped** — the heavy DDL runs only when the SQL (or the unit’s own `Version`) actually changes. This is the same hash-tracking the model migration uses for `*.sql` / `*.entity.xml` files. Secrets are read from the host’s `IConfiguration` (`Genie:Auth:PasswordEncryption:MasterKeyPassword`, `…:PasswordEncryptionKey`, `…:SeedAdminPassword`) and are **never persisted** — the stored `ExecutedScripts.Script` and its hash use the tokenised template; only the executed batches carry the substituted values. Per-entity sequence triggers are created at model-migration time Only the sequence *functions* are deployed by the startup bootstrapper; the INSERT trigger that calls them is created per-entity as each `*.entity.xml` is migrated. By then the table (EF migration, step 3) and the `GetNextSequenceNumber` function (startup bootstrap) both already exist. ## 5. Background jobs (Hangfire) [Section titled “5. Background jobs (Hangfire)”](#5-background-jobs-hangfire) `AddGenieHangfire(configuration)` wires [Hangfire](https://www.hangfire.io/) on a **Redis** storage backend plus an embedded processing server, and registers the Jobs dashboard service that powers `GET/POST /api/v1/hangfire/*` (consumed by the React **Jobs** dashboard). It is included automatically by `AddGenieApp`; a host that composes the engine manually calls it itself: ```csharp services.AddGenieHangfire(configuration); // after AddGenie(...) ``` The Redis connection is read from `ConnectionStrings:Redis` (the same instance backing the cache). **If no Redis connection is configured, only the dashboard service registers and the Jobs API returns 503** — the rest of the engine still boots. Settings bind from the `Genie:Hangfire` section (code overrides via `ConfigureHangfire` win on top): ```json "Genie": { "Hangfire": { "RedisPrefix": "hangfire", "KnownQueues": [ "default", "pipelines", "transfers", "notifications", "logs" ], "DefaultPageSize": 25, "MaxJobsPerStateQuery": 1000 } } ``` All `/api/v1/hangfire/*` endpoints require the **System** role. The legacy built-in Hangfire dashboard (`MapGenieHangfireDashboard` → `/hangfire`) remains available behind the IP-allowlist for hosts that want it. ## 6. Data Protection (key ring) [Section titled “6. Data Protection (key ring)”](#6-data-protection-key-ring) `AddGenieAuth(configuration)` also calls `AddGenieDataProtection(configuration)`, which persists the ASP.NET Core **Data-Protection key ring** per the `DataProtection` config section. The key ring protects auth cookies, antiforgery tokens, and — critically — the **encrypted JWT signing key** (see [Identity → signing keys](/security/identity/#signing-keys-rsa-keypair-dataprotection-encrypted)). It is a **no-op when `Enabled` is `false`** (the default), in which case the framework default applies: a per-user local key ring, or an **ephemeral** one under service identities without a writable profile — keys that don’t survive a restart. ```json "Genie": { "DataProtection": { "Enabled": true, "Provider": "Redis", // "Redis" or "FileSystem" (default) "ApplicationName": "YourApp", // pins the DP discriminator — default "Zed" "RedisKey": "DataProtection:Keys", // Redis provider: the ring's Redis key (default shown) "KeyPath": "C:\\ProgramData\\YourApp\\dp-keys", // FileSystem provider (default C:\ProgramData\Zed\dp-keys) "UseDpapi": true // FileSystem on Windows: DPAPI-NG at-rest encryption (default true) } } ``` * **`Provider: "Redis"`** stores the ring in Redis (connection from `ConnectionStrings:Redis`) — one ring shared by every node, surviving restarts and redeploys. Required posture for multi-node hosts, and for **any** host using `JwtKeyStorage.CacheStore`. * **`Provider: "FileSystem"`** persists to `KeyPath` (the app-pool identity needs read/write; the ring is DPAPI-NG-encrypted at rest on Windows unless `UseDpapi` is `false`). Per-node — fine for single-node hosts with `FileSystem` JWT keys. * **`ApplicationName`** sets the DP application discriminator. Without it the framework default is the **content-root path**, so moving the deployment folder silently makes old ciphertext undecryptable. Always set it. Pair the JWT key store with a persisted key ring If the host selects `JwtKeyStorage.CacheStore` (JWT keypair in Redis, durable, no TTL) while `Genie:DataProtection:Enabled` is `false`, the app boots fine the first time and then fails at the next restart/redeploy with *“failed to decrypt the persisted private key from the cache store”* — the durable ciphertext outlived the ephemeral key ring. Enable Data Protection with the **Redis** provider so both live in the same store. If several Genie apps share one Redis instance, give each its **own database** (`…,defaultDatabase=1`): the JWT cache keys (`auth:jwt:*`) are not namespaced per app, so co-tenant apps would otherwise overwrite each other’s keypair. See [Identity](/security/identity/#the-data-protection-pairing-rule) for the full failure-mode list and recovery steps. Exact option names; no environment-variable expansion The section binds to `GenieDataProtectionOptions` — the path property is **`KeyPath`** (a `"Path"` key is silently ignored), and values are used verbatim: `%ProgramData%`-style tokens are **not** expanded, so use full literal paths. ## What next [Section titled “What next”](#what-next) * [Frontend configuration](/integration/frontend/) — wire the React app with `createGenieApp`. * [Configuration reference](/integration/configuration/) — every appsettings section, key, default, and valid value a host can set. * [Deployment](/integration/deployment/) — same-origin UI hosting (`UseGenieSpa`), reverse proxies, and the `Genie:Cors` policy (avoid the per-request CORS preflight tax). * [Model migration](/integration/model-migration/) — how the engine applies your model files at startup. * [Errors & exception handling](/integration/errors/) — the JSON error envelope and `Genie:Errors`. * [Object endpoints](/api-reference/object-endpoints/) — the JSON API surface the UI consumes. # Configuration reference (appsettings) > Every appsettings key a Genie host can set — all under the single Genie section — covering the datasource, model migration, errors, CORS, SPA hosting, storage, notifications, auth, MFA, Hangfire, Data Protection and the Assistant, with defaults and valid values. Everything a caller project can configure, section by section, with defaults and valid values. Genie is **config-first**: `LoadFromConfiguration(configuration)` on the `GenieBuilder` binds every engine section from the host’s `IConfiguration` as the base, and any fluent `Use*` / `Configure*` call afterwards **overrides it in code** (later wins). The identity, Hangfire, Data Protection, Assistant, and OpenAPI modules read `IConfiguration` directly (composed by `AddGenieApp`, or by your own `AddGenieAuth` / `AddGenieHangfire` / `AddGenieAssistant` calls). One root: `Genie:` **Every engine-owned setting lives under the single `Genie:` section.** The only keys Genie reads outside it are the platform conventions a host owns anyway: `ConnectionStrings` (read through `GetConnectionString`), plus `Serilog` / `Logging` / `AllowedHosts`, which belong to ASP.NET and Serilog rather than to Genie. That boundary is the point: **any other root section is yours**. A host can define `Security:PublicForms` or `RateLimiting:PublicForms` without colliding with the engine. If you are upgrading from the older layout, see [Renamed sections](#renamed-sections) — the old paths are no longer read, and the engine **throws at startup** naming each one. Every key is also settable through standard ASP.NET Core **environment variables** — replace `:` with `__` and index arrays: `Genie__Cors__AllowedOrigins__0`, `Genie__Spa__Enabled=true`, `Genie__Assistant__ApiKey=sk-…` (the right place for secrets). ## A complete production-shaped appsettings.json [Section titled “A complete production-shaped appsettings.json”](#a-complete-production-shaped-appsettingsjson) Only set what you need — every value below that equals the default can be omitted. Secrets (passwords, API keys) belong in environment variables or a secret store, not in the file. ```jsonc { // --- Not Genie's: platform + library conventions, always at the root --- "ConnectionStrings": { "SqlServer": "Server=…;Database=…;…", // used when Genie:Datasource = SqlServer "PostgreSql": "Host=…;Database=…;…", // used when Genie:Datasource = PostgreSql "Redis": "localhost:6379,defaultDatabase=1" // cache, Hangfire, DataProtection, JWT keys }, "Serilog": { /* … */ }, "AllowedHosts": "*", // --- Everything Genie reads --- "Genie": { "Datasource": "SqlServer", // "SqlServer" | "PostgreSql" "Errors": { "ExposeErrorDetails": false, "IncludeStackTrace": false }, "Spa": { "Enabled": true, "RootPath": "ui" }, "SchemaCache": { "Enabled": true, "TtlMinutes": 30 }, "Uploads": { "MaxImportBytes": 1073741824 }, "Warehousing": { "Enabled": false, "Targets": {} }, // replication to ClickHouse / Meilisearch "Performance": { "Enabled": false }, "Warmup": { "Enabled": false }, // true = warm EF + view cache at startup "Migration": { "MigrationExecution": "Yes", // "No" | "Yes" (hash-checked) | "Forced" "ModelsPath": "models" // absolute, or relative to the app / content root }, "Cors": { "AllowedOrigins": [] }, // empty = no cross-origin callers (safe default) "Storage": { "Type": "Local", "Path": "App_Data/storage" }, "Notifications": { "Email": { "Enabled": true, "SmtpHost": "smtp.example.com", "SmtpPort": 587, "FromEmail": "noreply@example.com", "Username": "noreply@example.com" }, "Sms": { "Enabled": false } }, "Auth": { "Jwt": { "Issuer": "MyApp", "Audience": "MyAppClient", "AccessTokenExpiryMinutes": 60 }, "Security": { "MaxInvalidLoginAttempts": 5, "SessionTimeoutMinutes": 480 }, "Mfa": { "Email": { "Enabled": true, "UseProductionCodes": true } }, "Features": { "RefreshTokens": true, "PasswordReset": true, "Impersonation": false }, "PasswordEncryption": { "SeedAdminPassword": "…", // first-boot system password — CHANGE IT "MasterKeyPassword": "…" // required for DB password encryption } }, "Hangfire": { "RedisPrefix": "myapp-jobs" }, "DataProtection": { "Enabled": true, "Provider": "Redis", "ApplicationName": "MyApp" }, "Diagnostics": { "AllowedIPs": [ "10.0.0.5" ] } } } ``` ## Renamed sections [Section titled “Renamed sections”](#renamed-sections) Every engine-owned section was consolidated under `Genie:`. The legacy paths are **no longer read** — on startup `LoadFromConfiguration` inspects your configuration and **throws**, listing each stale path next to its replacement, so a half-configured app can never boot silently. | Legacy path | Now | | ---------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `AppSettings:Datasource` | `Genie:Datasource` | | `Startup` | `Genie:Migration` — and `ExecuteMigration` → `MigrationExecution` | | `Storage` | `Genie:Storage` | | `Notifications:Email` / `:Sms` / `:Vapid` | `Genie:Notifications:Email` / `:Sms` / `:Vapid` | | `Assistant` | `Genie:Assistant` | | `Security:Cors` | `Genie:Cors` | | `Security:SeedAdminPassword` / `MasterKeyPassword` / `PasswordEncryptionKey` | `Genie:Auth:PasswordEncryption:*` | | `Security:Login` / `Security:Recaptcha` / `Security:PasswordReset` | `Genie:Auth:Login` / `:Recaptcha` / `:PasswordReset` | | `Security` (lockout, password policy, sessions) | `Genie:Auth:Security` | | `Authentication:MFA` | `Genie:Auth:Mfa` | | `Authentication:JwtSettings` | `Genie:Auth:Jwt` (incl. `KeyPath`) | | `Authentication:CookieName` | `Genie:Auth:Cookies:CookieName` | | `Authorization` | `Genie:Auth:Authorization` | | `PasswordEncryption` | `Genie:Auth:PasswordEncryption` | | `DataProtection` | `Genie:DataProtection` | | `ForwardedHeaders` | `Genie:ForwardedHeaders` | | `RateLimiting:Auth` / `:Otp` | `Genie:RateLimiting:Auth` / `:Otp` | | `DiagnosticsAccess:AllowedIPs` | `Genie:Diagnostics:AllowedIPs` | | `OpenApi:*` | `Genie:OpenApi:*` | Matching is on the **exact** section that moved, never a parent — so a host’s own `Security:PublicForms` or `RateLimiting:PublicForms` is left alone while `Security:Cors` and `RateLimiting:Otp` are still caught. A legacy section is only flagged when it carries a **real value**: empty leftovers (a `"Section": {}` stub, or an environment variable blanked rather than unset) express no intent and are ignored. Don’t forget environment variables The check covers your whole `IConfiguration`, environment variables included. A legacy path set as `Assistant__ApiKey`, `Security__MasterKeyPassword`, `AppSettings__Datasource`, … will be reported — and it is worth reporting, because that value used to be honoured and now silently would not be. Rename it (`Genie__Assistant__ApiKey`) wherever it is defined: your shell profile, a `setx`-persisted user variable, `launchSettings.json`, Dockerfiles/compose, Kubernetes manifests, systemd units, or CI secrets. Migrate environment-specific files (`appsettings.Production.json`) at the same time. ## Core [Section titled “Core”](#core) ### `Genie:Datasource` [Section titled “Genie:Datasource”](#geniedatasource) | Value | Meaning | | ----------------------- | --------------------------------------------------------------------- | | `SqlServer` *(default)* | SQL Server dialect — EF provider, hardcoded system views, engine SQL. | | `PostgreSql` | PostgreSQL dialect throughout. | Parsed case-insensitively; an unknown value **throws at startup**. Everything that touches SQL is dual-dialect — the datasource selects which variant runs. PostgreSQL hosts: timestamps must be UTC Npgsql rejects any `DateTimeOffset` with a non-zero offset against `timestamp with time zone`, so custom host code that persists `DateTime.Now` throws rather than storing a slightly-wrong value — and on the startup path that means the app never boots. Use `DateTimeOffset.UtcNow`; see [Timezone handling → never `DateTime.Now`](/platform/timezones/#server-side-rule-never-datetimenow). ### `ConnectionStrings` [Section titled “ConnectionStrings”](#connectionstrings) | Name | Used for | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SqlServer` | The application database when `Genie:Datasource=SqlServer`. | | `PostgreSql` | The application database when `Genie:Datasource=PostgreSql`. | | `Redis` | The distributed `ICacheStore` (refresh tokens, MFA gates, JWT keypair with `JwtKeyStorage.CacheStore`), Hangfire storage, and the Data-Protection key ring (`Provider: "Redis"`). Optional for single-node hosts that register an in-memory `ICacheStore`. | Co-tenant apps sharing Redis The JWT cache keys (`auth:jwt:*`) are not namespaced per app — if several Genie apps share one Redis instance, give each its **own database** (`…,defaultDatabase=1`). See [Backend → Data Protection](/integration/backend/#6-data-protection-key-ring). ## Engine sections (bound by `LoadFromConfiguration`) [Section titled “Engine sections (bound by LoadFromConfiguration)”](#engine-sections-bound-by-loadfromconfiguration) ### `Genie:ReportingSources` — named read-only datasources [Section titled “Genie:ReportingSources — named read-only datasources”](#geniereportingsources--named-read-only-datasources) Named, host-owned databases that a [report page](/reports/overview/) dataset points at with `Source="Name"`, that an [object](/model-authoring/views/#datasource--reading-from-a-warehouse) points at with `
`, and that the assistant’s source picker lists. The host owns which engine and which credentials each name resolves to, so a `*.report.xml` or `*.view.xml` carries no environment detail and the same definition imports into staging and production unchanged. ```json "Genie": { "ReportingSources": [ { "Source": "Default", "Dialect": "SqlServer", "ConnectionString": "SqlServerReadOnly", "IsDefault": true }, { "Source": "Analytics", "Dialect": "ClickHouse", "ConnectionString": "Analytics", "TenantColumn": "CompanyId" }, { "Source": "Warehouse", "Dialect": "PostgreSql", "ConnectionString": "Host=wh;Database=ops;Username=ro;Password=…" } ] } ``` An **array**, not a map: the order here is the order the assistant’s source picker lists, so the host decides what a user reaches first, and each entry names itself. | Key | Meaning | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Source` | The name a dataset, an object or the picker resolves against. Required, and unique. | | `Dialect` | `SqlServer`, `PostgreSql` or `ClickHouse`. All three execute; the ClickHouse driver ships with the engine. | | `ConnectionString` | **Either** a `ConnectionStrings` key (first two entries above) **or** a literal connection string (third). Looked up as a name first; a miss means the value *is* the connection string. | | `TenantColumn` | Column carrying the tenant id. The assistant filters non-System callers on it. | | `SingleTenant` | `true` declares the source holds one company’s data, so no tenant filter is needed. | | `Enabled` | Default `true`. `false` takes the source out of **every** path at once — absent from the picker and refused by resolution — so a retired database fails loudly instead of being quietly read. | | `IsDefault` | Marks the source a new assistant chat starts on. | **`Default` is reserved** for the application’s own database. Declaring it points reports and the assistant at a read-only login on that same server — which is where a report’s read-only guarantee actually comes from; the SELECT-only parse check is defence in depth. A report dataset with **no** `Source` falls through to it, and a startup warning names the reports that moved. Objects deliberately do **not** fall through: they write, and this connection is read-only. Names are matched case-insensitively. An unknown source, a disabled one, or one whose `ConnectionString` resolves to nothing all **throw** — naming the source and listing what is configured. Resolution deliberately does not fall back to the app’s own database, which would read the wrong data and look like it worked. A connection string can be supplied out-of-band by the entry’s **index**, which is how a deployment keeps credentials out of the file — `Genie__ReportingSources__1__ConnectionString` replaces that one field on the second entry and leaves the rest of it alone. Positional, so reordering the array moves which source an override applies to. In code: `genie.UseSource("Analytics", source => { … })` declares or adjusts an entry, and **configures rather than replaces** — so setting one property in code does not drop a `TenantColumn` bound from configuration, which would silently refuse every non-System caller. Describing a source to the assistant There is no `SchemaPath` here. A source is described to the assistant by an `ISourceSchemaProvider` in code — see [Assistant → Describing a source](/assistant/configuration/#describing-a-source). An object that names a source is **read-only** and builds its grid SQL — paging, sorting, filters, quick-search — in that source’s dialect. Its editor-field and column lookups still resolve against the application database. See [Views → `DataSource`](/model-authoring/views/#datasource--reading-from-a-warehouse). ClickHouse binds parameters differently ClickHouse uses `{name:Type}` placeholders, not `@name`. A dataset on a ClickHouse source writes its filters that way — `WHERE SaleDate >= {FltFrom:Date}` rather than `>= @FltFrom`. Genie binds the value either way; it is the SQL text that differs. `Dialect` is not `Genie:Datasource` `Genie:Datasource` selects the app’s own EF provider and has no ClickHouse member; a source is an external read-only database the app never migrates. Point one at a **read-only least-privilege login** — that, more than the SELECT-only guard, is where a report’s read-only guarantee comes from. All three dialects execute. A ClickHouse source is the natural read side of [warehousing](/platform/warehousing/): give the read source and the warehousing target the **same name**, and “analytics” means the same server whether something is writing to it or reading from it. See [Reports → runtime](/reports/runtime/#sources). ### `Genie:Errors` — error-detail exposure [Section titled “Genie:Errors — error-detail exposure”](#genieerrors--error-detail-exposure) | Key | Default | Meaning | | -------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `ExposeErrorDetails` | `true` | Whether unexpected **5xx** responses carry the real exception message. Expected 4xx errors always carry theirs. **Set `false` in production.** | | `IncludeStackTrace` | `false` | Also include the exception type + stack trace in the envelope. Development only. | Fluent override: `ConfigureErrors(e => …)`. Details: [Errors](/integration/errors/). ### `Genie:Spa` — same-origin UI hosting [Section titled “Genie:Spa — same-origin UI hosting”](#geniespa--same-origin-ui-hosting) | Key | Default | Meaning | | ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `Enabled` | `false` | Serve the built React UI from the API origin (zero CORS preflights). | | `RootPath` | *(wwwroot)* | UI build output, relative to the content root — `"../ui/dist"` in dev, `"ui"` in a published app. | | `IndexFile` | `"index.html"` | SPA entry document for fallback routes. | | `ApiPathPrefixes` | `/api, /hubs, /files, /hangfire, /swagger, /openapi` | Never fall back to the SPA — JSON 404 instead. | | `ImmutablePathPrefixes` | `/assets` | Content-hashed bundles ⇒ `Cache-Control: … immutable`. | | `ImmutableMaxAgeSeconds` | `31536000` | Cache lifetime for immutable assets (1 year). | Fluent override: `ConfigureSpa(s => …)`. Full walkthrough: [Deployment](/integration/deployment/). ### `Genie:Cors` — cross-origin policy [Section titled “Genie:Cors — cross-origin policy”](#geniecors--cross-origin-policy) | Key | Default | Meaning | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | `AllowedOrigins` | `[]` | Origins allowed cross-origin. **Empty = none** (safe default; same-origin unaffected). | | `AllowedMethods` | `[]` → `GET, POST, PUT, PATCH, DELETE, OPTIONS` | Empty inherits the default set. | | `AllowedHeaders` | `[]` → `Authorization, Content-Type, X-Requested-With, X-Correlation-Id, X-TimeZone, X-XSRF-TOKEN, X-SignalR-User-Agent` | Empty inherits the default set (everything the Genie client sends, including the SignalR negotiate header). | | `ExposedHeaders` | `[]` | Response headers readable by cross-origin script. | | `AllowCredentials` | `false` | Required for the Genie client cross-origin; only honoured with explicit origins. | | `PreflightMaxAgeSeconds` | `7200` | Preflight cache (`Access-Control-Max-Age`); 7200 is Chromium’s cap. | Fluent override: `ConfigureCors(c => …)`; applied with `app.UseGenieCors()`. Details: [Deployment → Option 3](/integration/deployment/#option-3--genuinely-cross-origin-securitycors). ### `Genie:SchemaCache` — view-schema process cache [Section titled “Genie:SchemaCache — view-schema process cache”](#genieschemacache--view-schema-process-cache) | Key | Default | Meaning | | ------------ | ------- | --------------------------------------------------------------------------------------------------------- | | `Enabled` | `true` | Cache resolved view schemas (cache-aside over `ICacheStore`). `false` = every resolve hits the ViewStore. | | `TtlMinutes` | `30` | Absolute entry TTL; schema writes (model migration, execute-script) invalidate sooner. | ### `Genie:Uploads` — upload limits [Section titled “Genie:Uploads — upload limits”](#genieuploads--upload-limits) | Key | Default | Meaning | | ---------------- | -------------------- | -------------------------------------------------------------------------- | | `MaxImportBytes` | `1073741824` (1 GiB) | Caps the request body and multipart form length for `import-data` uploads. | ### `Genie:Idempotency` — safe-retry for object mutations [Section titled “Genie:Idempotency — safe-retry for object mutations”](#genieidempotency--safe-retry-for-object-mutations) | Key | Default | Meaning | | ------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Enabled` | `true` | When `true`, object mutations honour an `Idempotency-Key` request header (a repeat replays the first response instead of re-running the write). Set `false` to ignore the header. | | `Ttl` | `24:00:00` (24 h) | How long a key’s response is remembered (and how long an in-flight lock survives a crash). | | `HeaderName` | `Idempotency-Key` | The request header carrying the client-generated key. | Keys are cached via `ICacheStore` (Redis when configured, else in-memory) and scoped per user + company. See [Idempotency](/api-reference/object-endpoints/#idempotency-safe-retry). ### `Genie:Performance` — performance logging [Section titled “Genie:Performance — performance logging”](#genieperformance--performance-logging) | Key | Default | Meaning | | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Enabled` | `false` | When `true`, each request’s step timings are collected and written as one consolidated log event per request. When `false`, nothing is collected or logged (a true no-op). | When enabled, the `UseGeniePerformanceLogging()` middleware times the whole request and emits a single log event per request. The **message** is a compact one-liner — `[Perf] {Method} {Route} {Action}` (HTTP method, request path, and the `Controller.Action` name) — and **everything else is attached as structured `ForContext` properties** so it doesn’t clutter the message template: * `CorrelationId` — the same `Activity.Current?.Id ?? TraceIdentifier` the [error envelope](/integration/errors/) uses. * `StatusCode`, `TotalMs`. * `Steps` — the full timing **tree**, rendered as an indented outline (under a synthetic root of the action + total time). Nesting reflects how steps were opened inside one another (a phase and the SQL it triggered): ```text ObjectController.FieldDataSet (took 51.4 ms) ⌊ FieldDataSet:ResolveView (took 2 ms) ⌊ FieldDataSet:Query (took 48 ms) ⌊ SQL: SELECT ... (took 47 ms) ⌊ Unattributed (binding/serialization/pipeline) (took 1.4 ms) ``` Two layers of steps are captured **automatically**, no code changes needed: * **Every SQL statement** through the engine’s data-access seam (shown as `SQL: …`). * **Semantic phases** across the object pipeline — grid (`Grid:ResolveView`, `Grid:Permissions`, `Grid:BuildSql`, `Grid:Project`, `Grid:LinqQuery`), forms (`Form:ResolveView`, `Form:MapFields`, `Form:LoadExisting`, `Form:Validate`, `Form:PreProcess`, `Form:Save`, `Form:Workflow`, …), metadata (`Metadata:ResolveView`, `Metadata:Schema`), field datasets (`FieldDataSet:*`), import (`Import:Parse`/`Cook`/`Attachments`/`Save`/`Posting`/`Commit`), export (`Export:Query`/`Write`), and uploads (`Upload:Store`). The tree always ends with an **`Unattributed`** line whenever the tracked steps don’t add up to the total (above a \~1 ms threshold): it’s the request time spent outside any tracked step — model binding, response serialization, and the rest of the MVC/middleware pipeline — so you never have to subtract by hand to find the gap. A large `Unattributed` band on a data-heavy endpoint usually means response serialization; a large one only on the *first* request of a fresh process is cold-start (see `Genie:Warmup` below). The event is written under the **`Genie.Performance`** Serilog source context, so the host can route it to its own sink (e.g. a dedicated file) with a Serilog filter. The correlation id is also pushed into the Serilog `LogContext`, so any ordinary log written during the request carries the same `CorrelationId` (requires `.Enrich.FromLogContext()`, which the sample host enables). Add the middleware to your pipeline once, right after `UseGenieExceptionHandler()` — it’s safe to leave in unconditionally since it no-ops while disabled: ```csharp app.UseGenieExceptionHandler(); app.UseGeniePerformanceLogging(); // Genie.Engine.Hosting.Diagnostics ``` To time your own steps on top of the built-in ones, either inject `IPerformanceTracker` or use the ambient `Performance.Current` (both resolve to the same per-request tree, and steps opened inside another step nest under it; both are a no-op when the flag is off): ```csharp // Injected — for your own services: public MyService(IPerformanceTracker perf) { … } using (perf.Track("Conv:Query")) { /* … the step to measure */ } // Ambient — for static / DI-less code (Genie.Engine.Core.Abstractions): using (Performance.Current?.Track("Conv:Query")) { /* … */ } // Already measured a phase with your own Stopwatch? Surface it as a leaf step: perf.Record("Conv:Query", elapsedMs); ``` ### `Genie:Warmup` — startup warm-up [Section titled “Genie:Warmup — startup warm-up”](#geniewarmup--startup-warm-up) | Key | Default | Meaning | | -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Enabled` | `false` | When `true`, a one-shot background task runs once at startup (after model migrations settle) to remove the first-request cold-start cost. When `false`, nothing runs (a true no-op). | | `PreloadViews` | `true` | Also pre-resolve every `ViewStore` view so the schema cache is warm for the first request. `false` still forces the first EF query (model/plan compile + connection-pool prime) but leaves the view cache cold. | The first request against a freshly started process pays a one-time cost the steady state doesn’t: JIT compilation, the EF Core model + query-plan build, and opening the first database connection. In a performance trace this shows up as the initial `metadata`/`table` hit being an order of magnitude slower than an identical request a second later (visible as inflated `*:ResolveView`/`SQL`/`Unattributed` timings that collapse on the next call). Enabling warm-up runs that cost **once at startup, off the request path**, so the first real user request is already warm: ```jsonc "Genie": { "Warmup": { "Enabled": true } } ``` It waits for model migrations to be ready (or disabled) before touching the ViewStore, runs in the background so it never delays host startup, and swallows failures (a failed warm-up only means the first requests are as slow as they would have been without it). Leave it **off in development** (fast restarts matter more than the first request) and consider turning it **on in production**. ### `Genie:Migration` — runtime model migration [Section titled “Genie:Migration — runtime model migration”](#geniemigration--runtime-model-migration) | Key | Default | Meaning | | ------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `MigrationExecution` | `Forced` | `No` (skip), `Yes` (apply only files whose content hash changed), `Forced` (re-apply everything). Use `Yes` in production. | | `ModelsPath` | `null` | Where the model files live. Absolute path, or relative — see below. `null` resolves by convention. | | `FilePatterns` | `*.entity.xml`, `*.view.xml`, `*.sql`, `*.navbar.xml` | Which kinds of model file the sweep picks up. **`*.rbac.xml` is not swept unless you add it** — see below. | | `MaxRetryAttempts` | `3` | Retries per file for transient failures. | | `InitialRetryDelayMs` | `500` | First retry delay (doubles per attempt). | | `MaxDegreeOfParallelism` | `4` | Parallel file processing per category. | | `ContinueOnError` | `true` | Keep migrating other files when one fails. | | `ValidateBeforeExecution` | `true` | Validate model files before executing them. | **`ModelsPath` resolution.** An absolute path is used as-is. A relative path is resolved against the application base directory first (`bin/…`, matching the copy-to-output shape), then the host content root — so `"models"` works both for a published app and when running from the source tree. A configured path that does not exist is logged as an **error** and migration is skipped: it deliberately does **not** fall back to the conventional locations, because a typo silently migrating some other folder is worse than a visible stop. Leave `ModelsPath` unset to use the convention — `models/` next to the app binaries, then `../models` — which logs a warning and skips if neither exists. See [Model migration](/integration/model-migration/). **`FilePatterns` and RBAC.** The default omits `*.rbac.xml`. A baseline RBAC import makes the file the **complete** grant set for every role it names, so a file that mentions a framework-seeded role (`Admin`, `AccessManager`) revokes whatever the engine seeded for it — including the grants that let that role administer access at all. That is a reasonable thing to do deliberately and a bad thing to do by accident, so sweeping it is opt-in: ```json "Genie": { "Migration": { "FilePatterns": [ "*.entity.xml", "*.view.xml", "*.sql", "*.navbar.xml", "*.rbac.xml" ] } } ``` Configuring the list **replaces** the default rather than adding to it, and a pattern the engine has no strategy for is rejected at startup instead of silently collecting files nothing can execute. The effective list is logged with the models directory, so “why were my permissions not applied” is answered by the startup log. Hosts that would rather deploy RBAC explicitly can leave it out and use `POST /api/v1/genie/auth/import-rbac` (preview first) or the GenieClient import command. Also remember the **build** side: the model files have to reach the output directory. A host that copies models with an explicit glob (as `Inventory.Sample.Api.csproj` does) must list `*.rbac.xml` there too — a kind the glob omits is simply absent at runtime, with nothing to say so. ### `Genie:Storage` — file/object storage [Section titled “Genie:Storage — file/object storage”](#geniestorage--fileobject-storage) | Key | Default | Meaning | | ------------------------- | ------------- | ----------------------------------------------------------------------------- | | `Type` | `Local` | `Local` or `S3` (covers AWS S3, MinIO, and other S3-compatible stores). | | `Path` | `""` | **Local**: base directory (absolute, or relative to wwwroot). | | `AccessUrl` | `""` | **S3**: endpoint URL, e.g. `https://s3.amazonaws.com` or your MinIO host. | | `BucketName` | `""` | **S3**: default bucket (also allowlisted for the Object Explorer). | | `ExplorerBucketNames` | `[]` | **S3**: extra buckets the Object Explorer may operate on. | | `AccessKey` / `SecretKey` | `""` | **S3** credentials — prefer env vars (`Genie__Storage__SecretKey`). | | `Region` | `"us-east-1"` | **S3** region. | | `ForcePathStyle` | `true` | Path-style addressing (required for MinIO). | | `PublicBaseUrl` | `""` | Public base for download links; empty ⇒ `AccessUrl` (S3) or relative (Local). | Fluent override: `ConfigureStorage(s => …)`. ### `Genie:Notifications:Email` — outbound email channel [Section titled “Genie:Notifications:Email — outbound email channel”](#genienotificationsemail--outbound-email-channel) | Key | Default | Meaning | | ------------------------- | ------------ | --------------------------------------------------------- | | `Enabled` | `false` | `false` ⇒ the email worker isn’t registered. | | `SmtpHost` / `SmtpPort` | `""` / `587` | SMTP server. | | `EnableSsl` | `true` | STARTTLS/SSL. | | `FromEmail` / `FromName` | `""` | Sender identity. | | `Username` / `Password` | `""` | SMTP credentials. | | `BatchSize` | `50` | Emails per drain batch. | | `ProcessingInterval` | `30` | Seconds between drain cycles. | | `MaxRetryAttempts` | `3` | Per-message delivery retries. | | `TimeoutSeconds` | `30` | SMTP timeout. | | `IgnoreSslErrors` | `false` | Only for connecting by IP to hosts with mismatched certs. | | `Imap:Host` / `Imap:Port` | `""` / `993` | Optional: save sent copies via IMAP. | | `Imap:SentFolderName` | `"Sent"` | Target folder for sent copies. | The channel counts as **valid** (worker eligible) when `SmtpHost`, `FromEmail`, and `Username` are set and `SmtpPort > 0`. Fluent override: `ConfigureEmail(m => …)`. ### `Genie:Notifications:Sms` — outbound SMS channel [Section titled “Genie:Notifications:Sms — outbound SMS channel”](#genienotificationssms--outbound-sms-channel) | Key | Default | Meaning | | ------------------------------------------------------- | ----------------- | ------------------------------------------ | | `Enabled` | `false` | `false` ⇒ the SMS worker isn’t registered. | | `Provider` | `""` | Provider name, e.g. `"Twilio"`. | | `AccountSid` / `AuthToken` | `""` | Provider credentials. | | `FromNumber` | `""` | E.164 sender number. | | `BatchSize` / `ProcessingInterval` / `MaxRetryAttempts` | `50` / `30` / `3` | Drain loop tuning. | Valid when `Provider` and `FromNumber` are set. Fluent override: `ConfigureSms(s => …)`. ### `Genie:Notifications:Vapid` — browser push keypair [Section titled “Genie:Notifications:Vapid — browser push keypair”](#genienotificationsvapid--browser-push-keypair) | Key | Default | Meaning | | -------------------------- | -------------------------- | -------------------------------------------------------------------------------------------- | | `PublicKey` / `PrivateKey` | `""` | VAPID keypair. Without both, web-push delivery is skipped (in-app notifications still work). | | `Subject` | `mailto:admin@example.com` | Contact URI sent to the push service. | The public key is served to the browser by `GET /api/v1/push-subscription/vapid-public-key`; the private key is a secret — supply it as `Genie__Notifications__Vapid__PrivateKey`. ### `Genie:Warehousing` — replication to external stores [Section titled “Genie:Warehousing — replication to external stores”](#geniewarehousing--replication-to-external-stores) Replicates entity rows out to an analytical database (ClickHouse) and/or a search index (Meilisearch). The **write-side counterpart to [`Genie:ReportingSources`](#geniereportingsources--named-read-only-datasources)**: that section names databases the engine only ever *reads*, this one names stores it *writes*. A deployment that warehouses into ClickHouse and then reports off it configures both, pointed at the same server. ```json "Genie": { "Warehousing": { "Enabled": true, "Strategy": "Polling", "PollIntervalSeconds": 30, "BatchSize": 5000, "Targets": { "analytics": { "Provider": "ClickHouse", "ConnectionString": "Host=localhost;Port=8123;Database=genie;Username=default;Password=", "Database": "genie", "Entities": [ "*" ], "ExcludeEntities": [ "AuditLog" ] }, "search": { "Provider": "Meilisearch", "Url": "http://localhost:7700", "Key": "masterKey", "Entities": [ "*" ] } } } } ``` | Key | Default | Meaning | | --------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Enabled` | `false` | Master switch. No target is registered and no worker runs while this is false. | | `Strategy` | `Polling` | App-level delivery strategy, inherited by every entity that does not override it with `WarehousingStrategy`. `Polling` is the only one implemented. | | `PollIntervalSeconds` | `30` | Idle wait between sync cycles. A cycle that fills a batch loops again after 1s instead, so a backlog drains fast. | | `BatchSize` | `5000` | Rows read per cycle per entity. A batch is held as positional arrays, not a dictionary per row, so this stays cheap; lower it only for unusually wide rows. | | `WatermarkLagSeconds` | `60` | How long a gap in a `Watermark` entity’s key sequence may persist before it is stepped over. **Must exceed the longest transaction writing to that table** — see [Warehousing](/platform/warehousing/#how-watermark-works). | | `Targets` | `{}` | The destinations, keyed by a host-chosen name that identifies them in logs and on the [hosted-services dashboard](/platform/background-jobs/). Case-insensitive. | Per target: | Key | Default | Meaning | | ------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Provider` | — | `ClickHouse` or `Meilisearch`. | | `Enabled` | `true` | Park a target without deleting its configuration (and its credentials). | | `ConnectionString` | — | **ClickHouse.** A literal connection string, never a `ConnectionStrings` key — a warehousing target is written *to*, and a name that silently resolved elsewhere would replicate production rows into the wrong store. | | `Database` | from the connection string | **ClickHouse.** The database holding the replicated tables. | | `Url` | — | **Meilisearch.** e.g. `http://localhost:7700`. | | `Key` | — | **Meilisearch.** API key. Needs **write** access — this target both writes documents and backs the search query path. | | `Entities` | `["*"]` | Which entities this target receives. `"*"` (or omitting the key) means every warehoused entity; a name list means only those. Matched case-insensitively against the entity name or its `ObjectType`. | | `ExcludeEntities` | `[]` | Subtracted after `Entities` is applied, and wins on a tie. | An entity opts into replication in its model — a `Warehousing` strategy or a `` block; see [Entities → Warehousing](/model-authoring/entities/#warehousing). A target’s `Entities` selects from that set and can never widen beyond it. A **Meilisearch** target additionally skips entities with no `` block, since the document shape comes from that config. Fluent overrides: `ConfigureWarehousing(w => …)` for the cadence, and `UseWarehousingTarget(name, target)` to add or replace one target the way `UseSource` does. See [Warehousing](/platform/warehousing/) and [Global search](/platform/search/). `Genie:Meilisearch` was removed Meilisearch is now one warehousing target rather than a section of its own, so the whole integration is configured in one place. A leftover `Genie:Meilisearch` block **throws at startup** rather than being ignored — silently ignoring it would leave search working on the SQL provider against an index nothing writes to any more, a symptom that points nowhere near the cause. Map it across: `Enabled` → the target’s presence, `Host` → `Url`, `ApiKey` → `Key`, `SyncBatchSize` → `Genie:Warehousing:BatchSize`, `SyncIntervalSeconds` → `Genie:Warehousing:PollIntervalSeconds`. ## Identity — `Genie:Auth` (read by `AddGenieAuth` / `AddGenieApp`) [Section titled “Identity — Genie:Auth (read by AddGenieAuth / AddGenieApp)”](#identity--genieauth-read-by-addgenieauth--addgenieapp) One unified section binds the whole auth graph, and it is the **only** path — the pre-consolidation scattered sections (`Security`, `Authentication:MFA`, `Authentication:JwtSettings`, `Authorization`, `PasswordEncryption`) are rejected at startup, not read. Fluent `ConfigureAuth(auth => …)` calls win over configuration. The **minimal default** is username/password + JWT + cookie session; every heavier feature is opt-in. ### `Genie:Auth:Jwt` [Section titled “Genie:Auth:Jwt”](#genieauthjwt) | Key | Default | Meaning | | -------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `Issuer` / `Audience` | `"Zed"` / `"ZedClient"` | Token claims — set to your app’s values. | | `AccessTokenExpiryMinutes` | `60` | Access-token lifetime. | | `RefreshTokenExpiryDays` | `7` | Refresh-token lifetime (needs `Features:RefreshTokens`). | | `KeyPath` | `%ProgramData%\Genie\jwt-keys` | Directory the signing keypair is persisted to under `JwtKeyStorage.FileSystem`. Environment variables are expanded. Ignored for `CacheStore`. | ### `Genie:Auth:Cookies` [Section titled “Genie:Auth:Cookies”](#genieauthcookies) | Key | Default | Meaning | | ------------------- | --------------------- | ----------------------------- | | `CookieName` | `"Genie.Engine.Auth"` | Session cookie name. | | `SlidingExpiration` | `true` | Renew the cookie on activity. | The cookie is always `HttpOnly`, `SameSite=Lax`, `Secure` — not configurable. ### `Genie:Auth:Security` [Section titled “Genie:Auth:Security”](#genieauthsecurity) | Key | Default | Meaning | | ---------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | | `MaxInvalidLoginAttempts` | `5` | Failed logins before lockout. | | `UserLockedTimeoutMinutes` | `5` | Lockout duration; `0` = locked until an admin unlocks. | | `PasswordExpiryDays` | `180` | `0` disables expiry. Counted from the seeded `system` user’s **first boot**, not from a fixed date. | | `PasswordHistoryCount` | `3` | Recent passwords that can’t be reused; `0` disables. | | `SessionTimeoutMinutes` | `480` | Cookie session lifetime (8 h). | | `AllowConcurrentSessions` | `false` | `false` = a new login revokes the previous session’s tokens. | | `WebLoginBlockedRoles` | `[]` | Roles blocked from the web UI (API/JWT still works). | | `DetailedAuthErrors` | `false` | Surface precise failure reasons — never in production. | | `PasswordRequirements:MinPasswordLength` | `8` | Plus `RequireDigit`/`RequireLowercase`/`RequireUppercase` (`true`) and `RequireNonAlphanumeric` (`false`). | ### `Genie:Auth:Mfa` [Section titled “Genie:Auth:Mfa”](#genieauthmfa) | Key | Default | Meaning | | --------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------- | | `Email:Enabled` | `true` | Email OTP channel. | | `Email:UseProductionCodes` | `false` | **`false` means the dev code `000000` is accepted — set `true` in production.** | | `Email:OtpExpiryMinutes` | `10` | OTP lifetime. | | `Sms:Enabled` | `false` | SMS OTP (needs a valid `Genie:Notifications:Sms` channel). | | `GoogleAuth:Enabled` | `false` | TOTP authenticator apps. | | `GoogleAuth:Issuer` | `"Enfra"` | Name shown in the authenticator app — set to your app. | | `GoogleAuth:WindowSize` | `1` | Clock-drift tolerance in 30 s steps. | | `MaxValidationAttempts` / `ChallengeTtlMinutes` | `5` / `10` | Challenge limits. | | `MaxResends` / `ResendCooldownSeconds` / `VerificationLockoutSeconds` | `3` / `120` / `120` | Resend + lockout tuning (`0` disables). | ### `Genie:Auth:Features` — opt-in flows (all default `false`) [Section titled “Genie:Auth:Features — opt-in flows (all default false)”](#genieauthfeatures--opt-in-flows-all-default-false) | Key | Enables | | --------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `RefreshTokens` | Rotating refresh tokens with reuse detection (Redis-backed). | | `PasswordReset` | Forgot-password / magic-link flow (see `Genie:Auth:PasswordReset`: `CooldownSeconds` = `120`, `MagicLinkTtlMinutes` = `30`). | | `Impersonation` | System-role impersonation (`POST /api/v1/impersonation/start\|stop`). | Equivalent fluent calls: `auth.AddRefreshTokens()`, `auth.AddPasswordReset()`, `auth.AddImpersonation()`. ### Other `Genie:Auth` subsections [Section titled “Other Genie:Auth subsections”](#other-genieauth-subsections) * **`Recaptcha`** — `Enabled` (`false`), `SiteKey`, `SecretKey`. Off = validation short-circuits to success. * **`Login`** — `ShowSignUpLink` (`false`). * **`Authorization`** — `AllowUndefinedResources` (`false`): whether resources without permission definitions are open to all authenticated users (`true`) or denied (`false`). * **`PasswordEncryption`** — the database password-encryption secrets, detailed [below](#secrets--genieauthpasswordencryption). ### Code-only auth knobs (fluent builder, not appsettings) [Section titled “Code-only auth knobs (fluent builder, not appsettings)”](#code-only-auth-knobs-fluent-builder-not-appsettings) * `auth.UseJwtKeyStorage(JwtKeyStorage.FileSystem | JwtKeyStorage.CacheStore)` — where the RS256 signing keypair lives. `CacheStore` (Redis) is required for multi-node hosts and **must** be paired with `Genie:DataProtection` `Enabled: true` + `Provider: "Redis"` (see the [pairing rule](/integration/backend/#6-data-protection-key-ring)). * `auth.EnableAntiforgery` — global CSRF filter for cookie-based requests, on by default. ## Secrets — `Genie:Auth:PasswordEncryption` [Section titled “Secrets — Genie:Auth:PasswordEncryption”](#secrets--genieauthpasswordencryption) | Key | Required | Meaning | | ----------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SeedAdminPassword` | recommended | First-boot password for the seeded `system` user. Defaults to `Admin@123` **with a logged warning** — always set it. | | `MasterKeyPassword` | **yes** | Database-side password encryption: the SQL Server master-key password, or the source PostgreSQL derives its AES-256 key from. Missing ⇒ startup exception. | | `PasswordEncryptionKey` | legacy | PostgreSQL-only fallback (base64 of 32 bytes) used when `MasterKeyPassword` is absent. | These are secrets — supply them via environment variables (`Genie__Auth__PasswordEncryption__MasterKeyPassword=…`) or a secret store in production. The seeded `system` user’s password clock starts on **first boot**: the same run-once engine-SQL step that encrypts `SeedAdminPassword` also stamps `PasswordLastChangedAt`, so the account is never expired on arrival regardless of when you deploy. A database seeded by an **older engine version** kept a fixed seed date and may already be past `PasswordExpiryDays` — that shows up as a login returning `RequiresPasswordChange: true` with no tokens, which also blocks non-interactive clients. Reset it once: ```sql -- PostgreSQL UPDATE "Identity"."Users" SET "PasswordLastChangedAt" = now(), "PasswordExpiryTime" = NULL WHERE "Id" = 1; -- SQL Server UPDATE [Identity].[Users] SET [PasswordLastChangedAt] = CAST(SYSUTCDATETIME() AS DATETIMEOFFSET), [PasswordExpiryTime] = NULL WHERE [Id] = 1; ``` ## Platform modules [Section titled “Platform modules”](#platform-modules) ### `Genie:DataProtection` (read by `AddGenieAuth`) [Section titled “Genie:DataProtection (read by AddGenieAuth)”](#geniedataprotection-read-by-addgenieauth) | Key | Default | Meaning | | ----------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `Enabled` | `false` | `false` = framework-default (possibly ephemeral) key ring. | | `Provider` | `"FileSystem"` | `"Redis"` (shared, multi-node — needs `ConnectionStrings:Redis`) or `"FileSystem"`. | | `ApplicationName` | `"Zed"` | Key-isolation scope — **always set it** (the framework default is the content-root path, so moving the folder breaks decryption). | | `KeyPath` | `C:\ProgramData\Zed\dp-keys` | FileSystem provider directory (note: the property is `KeyPath`, not `Path`). | | `RedisKey` | `"DataProtection:Keys"` | The **Redis** key the ring is stored under (a cache key, not a config path). | | `UseDpapi` | `true` | DPAPI-NG at-rest encryption (Windows + FileSystem only). | Full failure-mode discussion: [Backend → Data Protection](/integration/backend/#6-data-protection-key-ring). ### `Genie:Hangfire` (read by `AddGenieHangfire` / `AddGenieApp`) [Section titled “Genie:Hangfire (read by AddGenieHangfire / AddGenieApp)”](#geniehangfire-read-by-addgeniehangfire--addgenieapp) | Key | Default | Meaning | | ------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------- | | `RedisPrefix` | `"hangfire"` | Key prefix (`:` auto-appended) — set per app when sharing Redis. | | `RedisDatabase` | *(connection default)* | Redis DB index override. | | `WorkerCount` | *(Hangfire default)* | Processing workers (`ProcessorCount × 5`). | | `KnownQueues` | `default, pipelines, transfers, notifications, logs` | Queues the server processes / the dashboard offers. | | `DefaultPageSize` / `MaxJobsPerStateQuery` | `25` / `1000` | Jobs-dashboard paging limits. | No `ConnectionStrings:Redis` ⇒ only the dashboard service registers and the Jobs API returns **503**; the rest of the engine boots normally. ### `Genie:Assistant` (read by `AddGenieAssistant` / `AddGenieApp`) [Section titled “Genie:Assistant (read by AddGenieAssistant / AddGenieApp)”](#genieassistant-read-by-addgenieassistant--addgenieapp) | Key | Default | Meaning | | ------------------------------------------------ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Providers[].Provider` | `"Ollama"` | Per model entry: `OpenAI` \| `DeepSeek` \| `Gemini` \| `Ollama` \| `OpenAICompatible` \| `ChatClient` (case-insensitive; unknown throws at startup). See [Configuration & providers](/assistant/configuration/#models). | | `Providers[].Endpoint` | kind default | The API root. Optional for the hosted kinds (each defaults to its own service), required for `OpenAICompatible`. A trailing `/chat/completions` is accepted and stripped. | | `Providers[].Model` | `"codellama:7b"` | Model name as the provider knows it. Any name the provider serves is accepted. | | `Providers[].ApiKey` | `null` | **Required for OpenAI/Gemini/DeepSeek.** Supply out-of-band as `Genie__Assistant__Providers__{n}__ApiKey`. | | `Providers[].MaxTokens` / `Temperature` / `TopP` | `10000` / `0.1` / `0.9` | Generation tuning, per model. | | `Providers[].TimeoutSeconds` | `60` | Timeout for a single provider HTTP request, per attempt (failed transport / `408` / `429` / `5xx` attempts are retried twice). | | `RequestTimeoutSeconds` | `120` | Overall deadline for one assistant turn (MCP loop + streamed reasoning read); on elapse the caller is told the assistant timed out. `0` disables. | | `ConversationMode` | `true` | Multi-turn chat vs single-query. | | `ConversationHistoryLimit` | `3` | Prior turns sent per request (`0` = full history). | | `ChatWidgetEnabled` | `false` | The in-app chat launcher. | | `ChatWidgetEnabledForRoles` | `[]` | Role allowlist; `"*"` or empty = all authenticated users. | | `ConnectionString` | `null` | Optional read-only DB for assistant query execution. | | `DocsPath` | `null` | Directory of `*.md` app-help docs injected as context. | More keys (memory, titles, welcome message) in [Assistant](/assistant/overview/). ### `Genie:OpenApi` + `Genie:Diagnostics` [Section titled “Genie:OpenApi + Genie:Diagnostics”](#genieopenapi--geniediagnostics) | Key | Default | Meaning | | ------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Genie:OpenApi:Title` / `Version` / `Description` | `"Genie API"` / `"v1"` / `"API documentation"` | Swagger document metadata. | | `Genie:Diagnostics:AllowedIPs` | `[]` | IPs allowed to reach Swagger / OpenAPI / the Hangfire dashboard (loopback and the server’s own addresses always pass). Everyone else gets 404 via `UseGenieDiagnosticsAccess()`. | ### `Genie:RateLimiting` [Section titled “Genie:RateLimiting”](#genieratelimiting) | Section | Keys | Defaults | Applied to | | ------------------------- | ------------------------------ | ---------- | ----------------------------------------- | | `Genie:RateLimiting:Auth` | `PermitLimit`, `WindowMinutes` | `20` / `1` | Login and auth endpoints, per source IP. | | `Genie:RateLimiting:Otp` | `PermitLimit`, `WindowMinutes` | `5` / `1` | OTP send/verify endpoints, per source IP. | Both policies are opt-in at the host: they only take effect once the host registers them (`limiter.AddGenieAuthPolicy(configuration)` / `AddGenieOtpPolicy(configuration)` inside `AddRateLimiter`) and calls `app.UseRateLimiter()`. ### `Genie:ForwardedHeaders` (behind a reverse proxy) [Section titled “Genie:ForwardedHeaders (behind a reverse proxy)”](#genieforwardedheaders-behind-a-reverse-proxy) | Key | Default | Meaning | | -------------------------------- | ------- | -------------------------------------------------------------- | | `Enabled` | `false` | Honour `X-Forwarded-For` / `X-Forwarded-Proto` from the proxy. | | `KnownProxies` / `KnownNetworks` | `[]` | Trusted proxy IPs / CIDR ranges (empty = loopback only). | | `ForwardLimit` | `1` | Max forwarded entries processed. | Enable this when running behind the [reverse proxy](/integration/deployment/#option-2--one-reverse-proxy-in-front-of-both) so rate limiting and audit see real client IPs. This section needs host wiring `AddGenie` does **not** read it for you. The host must call `services.AddGenieForwardedHeaders(configuration)` and `app.UseForwardedHeaders()` early in the pipeline (before `UseRateLimiter()`). Without those two calls the section is inert, every request appears to come from the proxy, and per-IP rate limits share one partition. ## Background workers — config-driven, or explicit in code [Section titled “Background workers — config-driven, or explicit in code”](#background-workers--config-driven-or-explicit-in-code) There is **no worker config section**. By default, worker registration follows the channel config: app-notifications always run; the email/SMS workers run when their channel is `Enabled` **and** valid; the warehousing sync worker runs when at least one warehousing target is valid. To split an API host from a dedicated worker host, switch to explicit opt-in in code: ```csharp genie.AddWorkers(w => w.AddAppNotificationWorker().AddEmailWorker()); ``` See [Backend → Background workers](/integration/backend/#background-workers). ## Production checklist [Section titled “Production checklist”](#production-checklist) The defaults are development-friendly; flip these before going live: * [ ] `Genie:Errors:ExposeErrorDetails` → `false` (5xx messages hidden). * [ ] `Genie:Auth:PasswordEncryption:SeedAdminPassword` → a real secret (and rotate the seeded account’s password). * [ ] `Genie:Auth:PasswordEncryption:MasterKeyPassword` → set (required) via env var / secret store. * [ ] `Genie:Migration:MigrationExecution` → `Yes` (hash-checked) instead of `Forced`. * [ ] `Genie:Auth:Mfa:Email:UseProductionCodes` → `true` if email MFA is on (kills the `000000` dev code). * [ ] `Genie:Auth:Jwt:Issuer`/`Audience` → your app’s values. * [ ] `Genie:DataProtection` → `Enabled: true`, `ApplicationName` set; `Provider: "Redis"` for multi-node or `CacheStore` JWT keys. * [ ] `Genie:Spa:Enabled` → `true` with `RootPath` at the published UI ([one deployable](/integration/deployment/#integrating-it-into-your-own-host-end-to-end)), **or** front both with a reverse proxy — either way, avoid cross-origin. * [ ] `Genie:Cors:AllowedOrigins` → keep empty unless a UI genuinely lives on another domain. * [ ] `Genie:Diagnostics:AllowedIPs` → set, and add `app.UseGenieDiagnosticsAccess()` so Swagger/Hangfire are hidden from the public. * [ ] `Genie:ForwardedHeaders:Enabled` → `true` with your proxy in `KnownProxies` when behind one — plus the two host calls it needs (see above). * [ ] Environment-specific files (`appsettings.Production.json`) and `__`-separated env vars use the `Genie:` paths too — a stale legacy path now **fails startup**, so verify the app boots. # Deployment — same-origin hosting & CORS > Serve the React UI and the Genie API from one origin (zero CORS preflights) with UseGenieSpa or a reverse proxy, or configure the cached-preflight CORS policy for cross-origin hosts. How you pair the built React UI with the Genie API in production decides whether every API call pays a **CORS preflight** — an extra `OPTIONS` round-trip (often 100–300 ms) before the real request. This page covers the three deployment shapes, from best to fallback. ## Why every cross-origin request preflights [Section titled “Why every cross-origin request preflights”](#why-every-cross-origin-request-preflights) The Genie client sends `Authorization`, `X-TimeZone`, and `X-XSRF-TOKEN` headers plus `Content-Type: application/json`, with credentials included. Any one of those makes a request “non-simple”, so when the UI and the API are on **different origins** the browser sends a preflight `OPTIONS` first — for every endpoint, on every page. In dev you never see this: the Vite proxy makes the API same-origin. In production there is no Vite server, so an ad-hoc split-origin deployment suddenly pays the tax. The fix, in order of preference: 1. **Same origin** — serve the UI from the API host (`UseGenieSpa`) or behind one reverse proxy. CORS never applies. **Zero preflights.** 2. **Cross-origin with preflight caching** — configure `Genie:Cors`; the browser caches each endpoint’s preflight for up to two hours instead of re-asking per request. The UI needs no change either way: `apiBase` is a relative path (`/api/v1/genie`), so it calls whatever origin served it. ## Option 1 — the API host serves the UI (`UseGenieSpa`) [Section titled “Option 1 — the API host serves the UI (UseGenieSpa)”](#option-1--the-api-host-serves-the-ui-usegeniespa) Build the UI, point the engine at the output, flip one flag: ```json "Genie": { "Spa": { "Enabled": true, "RootPath": "../ui/dist" // UI build output, relative to the API content root; omit ⇒ wwwroot } } ``` ```csharp app.UseGenieExceptionHandler(); app.UseGenieCors(); // still safe to keep — no-op cross-origin unless origins are configured app.UseGenieSpa(); // static assets + SPA fallback; no-op while Genie:Spa:Enabled is false app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapGenieHubs(); ``` `UseGenieSpa` (also called by `MapGenieApp` for umbrella hosts, so config alone is enough there): * serves the build output as **static files** — content-hashed bundles (Vite’s `/assets`) get `Cache-Control: public, max-age=31536000, immutable`; everything else (notably `index.html`) gets `no-cache` so a new deployment is picked up on the next navigation; * adds the **SPA fallback**: unknown, extension-less paths (e.g. `/object/Orders` on a hard refresh) serve `index.html` — this is the fallback `routing: "browser"` requires; * keeps API URL space clean: unmatched paths under the reserved prefixes (`/api`, `/hubs`, `/files`, `/hangfire`, `/swagger`, `/openapi` by default) return the standard Genie **JSON 404 envelope** (`{ success: false, error, traceId }`), never HTML; * **fails fast at startup** when enabled but the index document is missing, with the fix in the message. All knobs bind from `Genie:Spa` (code overrides via `ConfigureSpa(...)` on the `GenieBuilder` win): | Key | Default | Purpose | | ------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------- | | `Enabled` | `false` | Master switch — opt-in; reverse-proxy hosts leave it off. | | `RootPath` | *(wwwroot)* | UI build output directory, relative to the host content root, e.g. `"../ui/dist"`. | | `IndexFile` | `"index.html"` | The SPA entry document served for fallback routes. | | `ApiPathPrefixes` | `/api, /hubs, /files, /hangfire, /swagger, /openapi` | Prefixes that never fall back to the SPA (JSON 404 instead). | | `ImmutablePathPrefixes` | `/assets` | Prefixes holding content-hashed bundles ⇒ cached immutable. | | `ImmutableMaxAgeSeconds` | `31536000` | Cache lifetime for immutable assets (1 year). | The [Inventory sample](/getting-started/quickstart/) ships this pre-wired: `cd ui && npm run build`, set `Genie:Spa:Enabled` to `true`, `dotnet run` the API, and browse the API origin directly. ## Integrating it into your own host, end-to-end [Section titled “Integrating it into your own host, end-to-end”](#integrating-it-into-your-own-host-end-to-end) A complete walkthrough for a typical caller project — the same shape as the Inventory sample: ```plaintext your-app/ api/ ASP.NET Core host (references Genie.Engine) Program.cs appsettings.json appsettings.Production.json YourApp.Api.csproj ui/ React host (installs @orbyn-technologies/genie-engine-ui) src/main.tsx createGenieApp({ apiBase: "/api/v1/genie", routing: "browser", … }) vite.config.ts dev proxy for /api, /files, /hubs → the API port models/ *.entity.xml / *.view.xml / *.navbar.xml / *.sql ``` ### 1. The host pipeline [Section titled “1. The host pipeline”](#1-the-host-pipeline) Nothing about the UI is referenced in code — `UseGenieSpa()` reads everything from config and is a no-op until enabled, so the same `Program.cs` serves dev (API only, Vite serves the UI) and production (API serves the UI): ```csharp using Genie.Engine; using Genie.Engine.Features.Identity.Extensions; // UseGenieCors using Genie.Engine.Hosting; // UseGenieSpa var builder = WebApplication.CreateBuilder(args); builder.Services.AddGenie(genie => genie .LoadFromConfiguration(builder.Configuration)); // binds Genie:Cors and Genie:Spa too builder.Services.AddGenieAuth(builder.Configuration); builder.Services.AddControllers() .AddApplicationPart(typeof(Genie.Engine.AssemblyMarker).Assembly); var app = builder.Build(); app.UseGenieExceptionHandler(); // 1. errors → JSON envelope, wraps everything app.UseGenieCors(); // 2. Genie:Cors, before auth (preflights are anonymous) app.UseGenieSpa(); // 3. UI assets short-circuit here, before auth cost app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapGenieHubs(); app.Run(); ``` (An `AddGenieApp`/`MapGenieApp` umbrella host needs no `UseGenieSpa()` call at all — `MapGenieApp` applies it from config.) ### 2. Per-environment config [Section titled “2. Per-environment config”](#2-per-environment-config) `appsettings.json` — SPA hosting off; dev runs the Vite server with its proxy, so dev is already same-origin and CORS stays silent: ```json "Genie": { "Spa": { "Enabled": false, "RootPath": "../ui/dist" // used when you flip Enabled locally to try production mode } } ``` `appsettings.Production.json` — SPA hosting on, pointing at the folder the publish step creates (next section): ```json "Genie": { "Spa": { "Enabled": true, "RootPath": "ui" } } ``` No `Genie:Cors` section is needed in either file: with SPA hosting there is no cross-origin caller, and the empty-origins default already rejects any stray one. ### 3. One deployable: fold the UI build into `dotnet publish` [Section titled “3. One deployable: fold the UI build into dotnet publish”](#3-one-deployable-fold-the-ui-build-into-dotnet-publish) Add a publish target to the API’s `.csproj` so `dotnet publish` builds the UI and ships it under `ui/` in the publish output — one artifact to deploy, no separate UI pipeline: ```xml ui\%(RecursiveDir)%(Filename)%(Extension) PreserveNewest ``` ```bash dotnet publish api -c Release -o out # out/ now contains the API + out/ui/index.html + out/ui/assets/* — deploy it as one unit ASPNETCORE_ENVIRONMENT=Production dotnet out/YourApp.Api.dll ``` The published content root is the app folder, so `RootPath: "ui"` resolves to `out/ui`. If the folder is missing (UI build skipped), the host **fails at startup** with a message naming the path — it won’t silently serve 404s. ### 4. The day-to-day loop [Section titled “4. The day-to-day loop”](#4-the-day-to-day-loop) * **Dev**: `dotnet run` the API + `npm run dev` the UI. Vite’s proxy keeps the browser same-origin; no CORS, hot reload as usual. * **Try production mode locally**: `npm run build` in `ui/`, run the API with `Genie__Spa__Enabled=true` (env var — no config edit), browse the API port directly. The network tab should show **zero `OPTIONS` requests**. * **Deploy**: `dotnet publish` (the target above) → run with `ASPNETCORE_ENVIRONMENT=Production`. The runnable reference for all of this is the [Inventory sample](/getting-started/quickstart/) (`sample/Inventory` in the repo): its `RequestPipeline.cs` shows the pipeline order, `appsettings.json` carries the `Genie:Spa` block, and `appsettings.Development.json` demonstrates the cross-origin fallback config. ## Option 2 — one reverse proxy in front of both [Section titled “Option 2 — one reverse proxy in front of both”](#option-2--one-reverse-proxy-in-front-of-both) Identical result (one origin, zero preflights) when you’d rather keep the API process serving only JSON. Route the reserved prefixes to the API and serve static files for everything else — this is exactly what the Vite dev proxy does, productionized. nginx: ```nginx server { listen 443 ssl; server_name app.example.com; root /var/www/genie-ui; # the UI build output index index.html; location ~ ^/(api|files)/ { proxy_pass http://127.0.0.1:5184; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /hubs/ { # SignalR needs the WebSocket upgrade proxy_pass http://127.0.0.1:5184; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } location /assets/ { # content-hashed bundles add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } location / { # SPA fallback for client routes add_header Cache-Control "no-cache"; try_files $uri /index.html; } } ``` With YARP, the equivalent is a catch-all static-files app plus routes for `/api/{**rest}`, `/files/{**rest}`, and `/hubs/{**rest}` (WebSockets proxy transparently) to the API cluster. ## Option 3 — genuinely cross-origin (`Genie:Cors`) [Section titled “Option 3 — genuinely cross-origin (Genie:Cors)”](#option-3--genuinely-cross-origin-geniecors) When the UI must live on a different domain (e.g. a CDN), configure the engine’s CORS policy — registered automatically by `AddGenie`/`AddGenieApp` — and apply it with `app.UseGenieCors()` (after `UseGenieExceptionHandler`, **before** `UseAuthentication`: preflights are anonymous): ```json "Security": { "Cors": { "AllowedOrigins": [ "https://ui.example.com" ], "AllowCredentials": true } } ``` | Key | Default | Purpose | | ------------------------ | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AllowedOrigins` | `[]` | Origins allowed to call the API. **Empty = no cross-origin callers** (the safe default); same-origin traffic is unaffected. | | `AllowedMethods` | `GET, POST, PUT, PATCH, DELETE, OPTIONS` | Methods allowed on CORS calls. | | `AllowedHeaders` | `Authorization, Content-Type, X-Requested-With, X-Correlation-Id, X-TimeZone, X-XSRF-TOKEN, X-SignalR-User-Agent` | Request headers allowed — the default covers everything the Genie client sends, including the header the SignalR browser client adds to hub negotiate requests. | | `ExposedHeaders` | `[]` | Response headers readable by cross-origin script. | | `AllowCredentials` | `false` | Required for the Genie client (it sends credentials). Only honoured with explicit origins — never paired with `*`. | | `PreflightMaxAgeSeconds` | `7200` | `Access-Control-Max-Age` — how long the browser caches a preflight verdict. Chromium caps at 7200, Firefox at 86400. | Code overrides win via the builder: `genie.ConfigureCors(c => c.AllowedOrigins = [...])`. With `PreflightMaxAgeSeconds` the browser re-preflights each endpoint **URL** at most once per cache window instead of before every request — a large improvement, but not zero: the preflight cache is per-URL, so the first hit on each endpoint still pays one `OPTIONS`. Cross-origin cookies are the real cost The refresh-token and `XSRF-TOKEN` cookies only flow cross-site when issued `SameSite=None; Secure`, and the SignalR negotiate `POST` on `/hubs/*` is itself a credentialed CORS request. `Genie:Cors` configures the headers, not the cookie policy — which is precisely why the same-origin options above are recommended: they make the whole class of problems disappear. One policy, also the default `AddGenieCors` registers the policy both under its name (`"Cors"`) and as the **default policy**, so a bare `app.UseCors()` works too. A host that later registers its own default policy overwrites the Genie one — prefer configuring `Genie:Cors` instead of hand-rolling a second policy. ## What next [Section titled “What next”](#what-next) * [Backend integration](/integration/backend/) — the host pipeline these calls slot into. * [Configuration reference](/integration/configuration/) — every appsettings section a host can set, including the full `Genie:Spa` and `Genie:Cors` tables above in context. * [Frontend configuration](/integration/frontend/) — `apiBase` and `routing: "browser"`. * [Identity](/security/identity/) — cookies, JWT, and the Data-Protection pairing rule. # Errors & exception handling > The global exception handler, the JSON error envelope, the status-code mapping, and the Genie:Errors config. A global `GenieExceptionHandler` (an `IExceptionHandler`, registered by `AddGenie` and activated by `app.UseGenieExceptionHandler()` **first** in the pipeline) turns any unhandled exception into a consistent JSON envelope with a status code mapped from the exception type — instead of the framework’s default HTML error page or an empty 500. ## The error envelope [Section titled “The error envelope”](#the-error-envelope) ```json { "success": false, "error": "The requested object was not found.", "traceId": "00-4bf9…-01" } ``` * `success` is always `false` for an error response. * `error` is the message (see [detail exposure](#config-genieerrors) for when 5xx messages are masked). * `traceId` is the current activity id (or the request trace identifier) — quote it in bug reports; the full failure is always logged server-side against the same id. A **field validation** failure (`FieldValidationException` → 400) additionally carries `fieldErrors`, mapping field name → message, so a form can highlight and focus the offending field instead of only showing the joined text (see [Validation](/model-authoring/editor-fields/#validation)): ```json { "success": false, "error": "Another product already uses this name.", "traceId": "00-4bf9…-01", "fieldErrors": { "Name": "Another product already uses this name." } } ``` `fieldErrors` is absent for every other exception type. The React form consumes it automatically (`GenieApiError.fieldErrors`). When `IncludeStackTrace` is on, the body also carries `exceptionType` and `stackTrace` for local debugging. ## Status-code mapping [Section titled “Status-code mapping”](#status-code-mapping) Throw the right exception type from your services and let the handler shape the response — don’t hand-roll `BadRequest(new { message })` in new code. | Exception type | Status | | -------------------------------------------------------------------------------------------------------------------- | ------- | | `UnauthorizedException`, `UnauthorizedAccessException` | **401** | | `NotFoundException`, `KeyNotFoundException` | **404** | | `ConflictException` (optimistic concurrency) | **409** | | `ArgumentException` | **400** | | `ValidationException`, `FieldValidationException`, `RuntimeException`, `GenieException`, `InvalidOperationException` | **400** | | anything else | **500** | Expected (4xx) errors are deliberate, safe feedback, so they **always** carry their real message. Only unexpected (5xx) failures are subject to detail masking. ## Config: `Genie:Errors` [Section titled “Config: Genie:Errors”](#config-genieerrors) Whether 5xx bodies expose the real exception message is config-driven via the `Genie:Errors` section (`GenieErrorOptions`) — on in dev, off in prod: ```json "Genie": { "Errors": { "ExposeErrorDetails": true, "IncludeStackTrace": false } } ``` | Key | Default | Effect | | -------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ExposeErrorDetails` | `true` | When `true`, a 5xx returns the real exception message. When `false`, it returns a generic *“An unexpected error occurred…”* message and logs the detail only. Turn **off** in production. | | `IncludeStackTrace` | `false` | When `true`, the response also includes the exception type and full stack trace. Local debugging only — keep `false` outside Development. | Override in code through the builder: ```csharp builder.Services.AddGenie(genie => genie .LoadFromConfiguration(builder.Configuration) .ConfigureErrors(e => { e.ExposeErrorDetails = false; // production e.IncludeStackTrace = false; })); ``` Register the handler first `app.UseGenieExceptionHandler()` must be **first** in the pipeline, before routing/auth/endpoints, so it wraps every downstream response. The Inventory sample calls it at the top of its `UseSampleApi(...)` pipeline. # Frontend configuration > Configure the React app in code with createGenieApp — apiBase, routing, navbar, modules, auth panel, preloader and handlers. The frontend is the `@orbyn-technologies/genie-engine-ui` npm package. Install it (plus the React peer deps) and configure the whole app **in code** — there are no XML/config files on the client: ```bash npm install @orbyn-technologies/genie-engine-ui react react-dom ``` Font Awesome glyphs used by Genie navigation, views, actions and reports are bundled with the UI package. Hosts do not need a separate Font Awesome dependency or CDN stylesheet. ```tsx import { createGenieApp } from "@orbyn-technologies/genie-engine-ui"; import "@orbyn-technologies/genie-engine-ui/styles.css"; const GenieApp = createGenieApp({ apiBase: "/api/v1/genie", // where the Genie.Engine API is mounted routing: "browser", navbar: { source: "endpoint" }, modules: { auth: true, notifications: true }, theme: { mode: "system" }, }); ``` `createGenieApp` mounts `GenieRouter`, which maps `/table|form|view/{name}` to `useGenieTable` / `useGenieForm` and the matching component. The UI fetches **structure** (SQL-free schema) once and **values** per record, then merges them client-side — so a view defined in `*.view.xml` renders with no frontend code. Auth affordances adapt automatically to the server’s enabled features (read from `GET /api/v1/auth/config`). See [Object endpoints](/api-reference/object-endpoints/) for the exact contract, and [Components](/frontend/components/) for the rendered surface. ## Core config [Section titled “Core config”](#core-config) | Key | Purpose | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `apiBase` | Base path where the Genie.Engine JSON API is mounted, e.g. `/api/v1/genie`. | | `routing` | `"browser"` (History API paths like `/object/Orders?Status=Active`) or `"hash"`. Browser routing requires the host to serve `index.html` for unknown paths (SPA fallback) — [`UseGenieSpa`](/integration/deployment/) provides that out of the box. Auth/account pages always use the hash. | | `basePath` | Base path the app is mounted under (e.g. `/genie`). Defaults to `/`. | | `modules` | Which feature modules to include (see below). Omitted flags default per module. | | `navbar` | Where the sidebar navbar comes from (see below). | | `theme` | Theme overrides — see [Theming](/frontend/theming/). | | `schemaCacheTtlSeconds` | Lifetime of the in-memory schema (metadata) cache. `null` (default) holds it until reload; `0` disables it. | | `debugRequests` | Logs every API request to the console (delta, caller hint, `⇊dedup` marker). Off by default. | | `pdfWorkerSrc` | URL of the pdf.js worker used by the [document viewer](/platform/reports/#viewing-a-report-in-the-ui) when rendering a PDF. Omit to use the worker bundled inside the package (correct for almost every host); set it to a self-served copy (e.g. `"/pdf.worker.min.mjs"`) when a strict CSP forbids the bundled worker’s `blob:` URL. | | `handlers` | Render-time extension points (see below). | | `auth` | Customizes the auth screens’ brand panel (see below). | | `assistant` | Content overrides for the AI Assistant panel — `{ title, subtitle, welcome, suggestions, placeholder }`. `welcome`/`suggestions` fall back to the server’s `Genie:Assistant:WelcomeMessage`/`WelcomeChips` (`GET api/assistant/config`), then defaults — with **no built-in chips**. See [Assistant configuration](/assistant/configuration/). | | `preloader` | Replaces the built-in loading splash (see below). | ## Modules [Section titled “Modules”](#modules) `modules` toggles which feature modules are compiled in (unused ones tree-shake out). Defaults: `auth`, `navbar`, `tables`, `forms`, `wizard`, `reports`, `notifications` and `multiTenant` are **on**; `assistant` and `headerSearch` are **off**. ```tsx createGenieApp({ apiBase: "/api/v1/genie", modules: { auth: true, navbar: true, tables: true, forms: true, wizard: true, reports: true, // /reports/{slug} pages — see /reports/overview/ notifications: true, // header bell — see /platform/notifications/ assistant: true, // AI assistant panel — see /assistant/overview/ headerSearch: true, // global search — see /platform/search/ multiTenant: false, }, }); ``` Some modules are documented in their own sections: [report pages](/reports/overview/), the [AI assistant](/assistant/overview/), the [global header search](/platform/search/), and the notification bell under [Notifications](/platform/notifications/). ## Navbar source [Section titled “Navbar source”](#navbar-source) `navbar` selects where the sidebar tree comes from. **There is no auto-fallback** between modes — an `endpoint` error stays an error, and `static` always uses `items`. Omit the whole block to use the package-provided default nav items (the built-in System module). ```tsx // (a) Server-driven: fetch the permission-filtered tree from GET /navbar createGenieApp({ apiBase: "/api/v1/genie", navbar: { source: "endpoint" } }); // (b) Host-supplied static tree (no API call) createGenieApp({ apiBase: "/api/v1/genie", navbar: { source: "static", items: myNavItems } }); ``` `modules.navbar` toggles whether the rail/sidebar renders at all; `navbar.source` selects where its contents come from — they’re independent. ## Theme [Section titled “Theme”](#theme) All theming — mode, accent/accents, logo/monogram, custom colours — flows through the `theme` block on `createGenieApp`. The palette and token detail live in [Theming](/frontend/theming/). ```tsx createGenieApp({ apiBase: "/api/v1/genie", theme: { mode: "system", accent: "orbyn" } }); ``` ## Auth brand panel [Section titled “Auth brand panel”](#auth-brand-panel) The login / forgot / reset screens render in a split layout: a gradient **brand panel** beside the form. Customize it via the `auth` block — no fork needed (`brandPanel` wins over `brandContent` when both are set): ```tsx createGenieApp({ apiBase: "/api/v1/genie", auth: { // (a) Replace the whole panel with your own markup. The host owns everything in the // `.auth-brand` slot; style it with host CSS. brandPanel: , // (b) …or keep the default panel chrome and only swap its copy: brandContent: { heading: "Inventory operations, unified.", tagline: "Stock, suppliers, and purchase orders — in one workspace.", features: ["Real-time stock levels", "Supplier management", "Purchase-order delivery"], }, }, }); ``` The panel’s logo/wordmark follows `theme.logo` and its colours follow the theme tokens. For a fully-custom panel see the Inventory sample’s `InventoryBrandPanel.tsx`. ## Loading splash (preloader) [Section titled “Loading splash (preloader)”](#loading-splash-preloader) While the session resolves and the authenticated app-shell chunk downloads, Genie shows a built-in loading splash (branded via `theme.logo`). Replace it wholesale with the top-level `preloader` slot — the same visual then covers the session-resolve, the lazy-shell Suspense fallback, and the post-login hand-off: ```tsx createGenieApp({ apiBase: "/api/v1/genie", preloader: , // any ReactNode; own markup + CSS. Omit to keep the built-in splash. }); ``` ## Request de-duplication [Section titled “Request de-duplication”](#request-de-duplication) `GenieApiClient` collapses redundant calls so a single view/form load makes one network round-trip per logical request — important under React StrictMode (which double-invokes effects in dev) and at production scale: * **Metadata** is cached in-memory (`schemaCacheTtlSeconds`; held until reload by default). * **In-flight coalescing** dedupes concurrent identical requests: every `GET` plus the read-style POSTs (`/table`, `/form`, `/view`, `/field-dataset`, `/sequence-number`) share one fetch while it is pending. There is **no caching beyond the in-flight window** — once a request settles, a later navigation re-fetches fresh data. Mutations (`submit` / `delete-row` / `upload`) are **never** coalesced. * Set `debugRequests: true` to log each request (with a `+Nms` delta, a caller-stack hint, and a `⇊dedup` marker for coalesced calls) while diagnosing — leave it off in production. ## Render extension handlers [Section titled “Render extension handlers”](#render-extension-handlers) Pass a `handlers` block to customize table/form rendering without forking the components. Every table/form render invokes these (all fields optional): ```tsx createGenieApp({ apiBase: "/api/v1/genie", handlers: { // Extra functions available to EVERY client-side expression — table `StyleClassesExpression` // and form field Required/Disabled/Hidden/Value rules. Merged over the built-ins (If/IIF, // sumLines, …), so a host entry can override a built-in of the same name. expressionFunctions: { Money: (n) => new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" }).format(Number(n) || 0), }, // Per-cell CSS class resolver, evaluated per row. Appended after the column's StyleClasses and // its evaluated StyleClassesExpression. cellClass: (column, row) => (column.Name === "Status" && row.Cells.Status === "Overdue" ? "gx-danger" : null), // Override class names for known render slots whose styling varies by project. classNames: { attachmentCell: "my-attachment-row" }, // Rich cell/value renderers, keyed by a column/field's Render="key" (see below). renderers: { code: ({ value }) => {value}, }, }, }); ``` The built-in `If(cond, a, b)` (alias `IIF`) mirrors the DataColumn `IIF` syntax authors already know and uses `=` for comparison — e.g. `StyleClassesExpression="If(Status = 'Overdue', 'gx-danger', 'gx-success')"`. See the `` attributes in [Model Authoring](/model-authoring/actions/) and the field rules in [Components](/frontend/components/). ### Rich cell renderers [Section titled “Rich cell renderers”](#rich-cell-renderers) A grid column (or a view field) can render its value as **rich content** instead of plain text via the `Render="key"` attribute. The engine ships four built-ins that need no registration: * `badge` — the value as one coloured chip * `tags` — a pipe/comma-separated value as a row of chips * `bullets` — a pipe/comma-separated value as a bulleted list * `progress` — a numeric `0–100` value as a progress bar with a percentage label Colour comes from the column’s `StyleClasses` / `StyleClassesExpression` (the `gx-*` palette), e.g. `Render="badge"` with `StyleClassesExpression="If(OnHand <= ReorderLevel, 'gx-danger', 'gx-success')"`. For anything else, register a renderer under `handlers.renderers`; the `Render` key resolves to your renderer first (a host entry **wins** over a built-in of the same name), then the built-in set, then falls back to plain text if unknown. Each renderer receives `{ value, classes, row?, column?, field? }` and returns a **React node**: ```tsx renderers: { // value is passed as a text CHILD → React escapes it. Never return an HTML string or build // markup from value; there is no dangerouslySetInnerHTML anywhere in the renderer path. code: ({ value }) => {value}, rating: ({ value }) => {"★".repeat(Number(value) || 0)}, }, ``` Renderers return nodes, not HTML A renderer must return a React node and pass `value` as a child so React escapes it. Returning an HTML string (or interpolating `value` into markup) would reintroduce an XSS sink — the whole point of this API is that untrusted data values are rendered as text, never as HTML. Rendering the app `createGenieApp` returns a component you render into your root. Passing children makes the host own the content area (register custom pages, delegate the rest to the built-in router). See the Inventory sample’s `main.tsx` for the full wiring. # Model migration > How the engine scans the models directory at startup and applies each file via a per-type strategy. At startup the engine’s `ModelMigrationService` (an `IHostedService` registered by `AddGenie`) scans a **models directory** and applies each file **in dependency order** — entities → views → SQL → navbar — via a per-type **strategy**. This is what populates the runtime metadata stores the API reads from; it is separate from the EF migration that creates physical tables. ## What each file type does [Section titled “What each file type does”](#what-each-file-type-does) ```text ┌─▶ *.entity.xml ──[EntityMigrationStrategy]─────────▶ Entity store + triggers ModelMigrationService ─────┼─▶ *.view.xml ────[ObjectViewMigrationStrategy]─────▶ ViewStore (ObjectView JSON) scans models directory ├─▶ *.sql ─────────[StoredProcedureMigrationStrategy]─▶ Recognized DDL executed └─▶ *.navbar.xml ──[NavbarMigrationStrategy]──────────▶ Navigation store ``` | File | Strategy | Result | | -------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `*.entity.xml` | `EntityMigrationStrategy` | Upserted into the entity store (entity metadata); the per-entity sequence/search/audit triggers are (re)created here. | | `*.view.xml` | `ObjectViewMigrationStrategy` | Upserted into `ViewStore` — the polymorphic `ObjectView` JSON the UI renders as grid/form/view. | | `*.sql` | `StoredProcedureMigrationStrategy` | Recognized DDL (`CREATE [OR ALTER] PROCEDURE/FUNCTION/VIEW/TRIGGER`) is **executed**; plain SQL is only recorded. | | `*.navbar.xml` | `NavbarMigrationStrategy` | Synced into the navigation store. | Each applied file is recorded by a **content hash**, so an unchanged file is skipped on the next boot — only changed files re-run. Physical tables come from the EF migration The runtime migration populates metadata stores; it does **not** create your domain tables. Those come from `dotnet ef migrations` in the host assembly (see [Backend integration → source generator](/integration/backend/)). A plain `CREATE TABLE` in a `*.sql` file is *recorded but not executed* — use it for procedures, functions, and SQL views. ## Controlling it — the `Genie:Migration` config section [Section titled “Controlling it — the Genie:Migration config section”](#controlling-it--the-geniemigration-config-section) Model-migration behaviour binds from the **`Genie:Migration`** section (`ModelMigrationOptions`), overridable in code via `ConfigureMigrations`: ```json "Genie": { "Migration": { "MigrationExecution": "Forced", "ModelsPath": "models", "FilePatterns": [ "*.entity.xml", "*.view.xml", "*.sql", "*.navbar.xml" ] } } ``` `FilePatterns` chooses which kinds of file the sweep picks up; the stage order is fixed regardless (entities → views → SQL → navbar → RBAC), because a view may reference an entity and a grant may reference a view. Configuring it **replaces** the default, and an unsupported pattern is rejected at startup rather than collected and never run. `*.rbac.xml` is opt-in It is **not** in the default list. A baseline RBAC import makes the file the *complete* grant set for every role it names, so a file mentioning `Admin` or `AccessManager` revokes the framework’s own seeded grants for that role — including the ones that let it administer access. Add `"*.rbac.xml"` when you want deploy-time RBAC, and keep the file to **application** roles. See [RBAC](/security/rbac/). `MigrationExecution`: * `No` — don’t run on startup. * `Yes` — run, skipping files whose content hash is unchanged. * `Forced` — re-apply every file regardless of hash. ```csharp builder.Services.AddGenie(genie => genie .LoadFromConfiguration(builder.Configuration) .ConfigureMigrations(m => m.MigrationExecution = MigrationExecutionMode.Yes)); ``` ## Locating the models directory [Section titled “Locating the models directory”](#locating-the-models-directory) Point `Genie:Migration:ModelsPath` at your model files: * An **absolute** path is used as-is. * A **relative** path is resolved against the application base directory (`bin/…`) first, then the host content root — so `"models"` covers both a published app and running from the source tree. * A configured path that **does not exist** is logged as an error and migration is skipped. It deliberately does not fall back to the conventional locations: a typo that silently migrates some other folder — or nothing at all — is worse than a visible stop. Leave `ModelsPath` unset to use the convention: `/models`, then `/../models`. If neither exists the service logs a warning naming both probed paths and skips. The simplest portable approach is to **copy your model files into the build output**. The Inventory sample copies `../models/**` into `bin/.../models` with a `` item in its csproj, and sets `"ModelsPath": "models"`: ```xml ``` Match the directory layout to your deployment. ## How this relates to `dotnet ef migrations` [Section titled “How this relates to dotnet ef migrations”](#how-this-relates-to-dotnet-ef-migrations) Two distinct steps run against the same database, in a fixed order at boot: 1. **`dotnet ef migrations`** (design-time, in the host assembly) — creates the **physical tables** from the source-generated EF configurations. Applied by the host, typically via `context.Database.Migrate()` on startup. When you upgrade the engine and its model gains tables (e.g. `Identity.ResourceCapabilities` + `Identity.PermissionCapabilities` for [capabilities and grants](/security/rbac/#capabilities)), scaffold a new host migration (`dotnet ef migrations add `) so those tables exist — this works for SQL Server and PostgreSQL from the same model. 2. **Engine SQL bootstrap** (`EngineSqlBootstrapper`) — deploys the engine’s shared SQL objects (sequence functions, company-scope helpers, password-encryption procs). Runs before the model migration. See [Backend integration](/integration/backend/#4-engine-sql-objects-deployed-at-startup). 3. **Model migration** (`ModelMigrationService`) — populates the metadata stores and creates the per-entity triggers described above. ```text ┌──────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ │ dotnet ef migrations │ ─▶ │ EngineSqlBootstrapper │ ─▶ │ ModelMigrationService │ │ physical tables │ │ shared SQL objects │ │ metadata + triggers │ └──────────────────────┘ └───────────────────────┘ └───────────────────────┘ ``` So authoring a new entity is: write the `*.entity.xml`, `dotnet ef migrations add` (tables), then let the runtime migration register its metadata + triggers on the next boot. # Actions & row actions > Toolbar and per-row , server-driven dispatch envelopes, toolbar quick-search, and pre-execute . A view can carry **toolbar actions** (buttons above the grid), **row actions** (per-row buttons or menu items), a **quick-search** box, and **pre-execute filters**. This page covers all four, plus the CSP-safe dispatch envelope that lets server SQL drive the client. ## Toolbar actions & row actions [Section titled “Toolbar actions & row actions”](#toolbar-actions--row-actions) ```xml exportTable('Customer'); UPDATE Sales.Customers SET IsActive = 0 WHERE Id = @Id; UPDATE Sales.Customers SET CreditLimit = @NewLimit WHERE Id = @Id; ``` A **toolbar ``** carries an `OnClick` script (element body), optional `Icon`, `Confirm`, `ExportHandler`. A **row ``** resolves its body by precedence **`` > `Url` (link) > `OnClick`/script**, plus `Name`, `Icon`, `Color`, `Confirm`, `Target`, `Permission`, `DisplayExpression`/`DisplayColumn` (conditional visibility — see [expressions](/model-authoring/expressions/#row-action-visibility--displayexpression)), and an optional **``** (`Message`, `ConfirmLabel` + editor fields collected before the action runs). ### Link actions [Section titled “Link actions”](#link-actions) A `Url` makes the action a link. `{Column}` tokens are substituted from the row (`{Id}` included), and tokens landing in the **query string are percent-encoded**, so a value containing `&`, `#` or `=` can’t corrupt the parameters after it. `Target` decides where it opens: | `Target` | Behaviour | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | omitted / `_self` | An internal path navigates in-app (SPA route); an external URL opens in the same tab. | | `_blank` (or any anchor target) | Opens a window. Note a new tab carries **no** bearer token, so don’t point it at an `[Authorize]` endpoint. | | `Modal` | Opens the [document viewer](/platform/reports/#viewing-a-report-in-the-ui) in a dialog, without leaving the grid. Only applies to a `/report-view` or `/file-view` URL; anything else falls back to navigation. Superseded by `Type="Report"` below, which does the same by default. | ### Document actions — `Type="Report"` [Section titled “Document actions — Type="Report"”](#document-actions--typereport) `Type="Report"` is the preferred way to open a report or stored file, and **it opens in a dialog by default**. It is the one `Type` an author may declare: `Sql`, `Link` and `Script` are inferred from the action’s body, so naming them is rejected rather than silently ignored. A `Report` still needs a `Url` — the viewer URL it opens. `Mode` then decides *where*, and is only valid alongside `Type="Report"`: | `Mode` | Behaviour | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | omitted / `Dialog` | **The default.** Opens the [document viewer](/platform/reports/#viewing-a-report-in-the-ui) in a dialog over the grid, keeping the list, its scroll position and its pre-execute filters underneath. Right for a document that is glanced at and dismissed. | | `Redirected` | Opens the viewer in a **new tab** with the sidebar minified, for a document somebody sits and reads. Safe despite the `_blank` warning above, because the tab loads the SPA viewer *page* — not the `[Authorize]` endpoint — and same-origin tabs share the bearer token. | A `Report` whose `Url` is not a viewer path has no dialog to open, so it **falls back to navigation** rather than producing a dead button. ```xml ``` `Target="Modal"` predates `Type="Report"` and still works, so existing views need no edit — but prefer `Type="Report"` in new ones: it says what the action *is* rather than overloading the anchor target, and it is what `Mode` attaches to. ## Dispatch callbacks [Section titled “Dispatch callbacks”](#dispatch-callbacks) A `` block (in a row action, submit, delete, import `AfterSql`, or an RPC) can drive an **action** instead of just returning a value. Return a result set whose **first column is named `FunctionName`**; each row becomes one callback, with the remaining columns as positional params (`Param1`, `Param2`, … in column order, SQL `NULL` → `null`). Names must match `^[A-Za-z_][A-Za-z0-9_]*$`, and rows run **in sequence**, each awaited, so a callback completes before the next row runs. Callbacks come in two kinds, and the split matters: * **Server callbacks** — [starting or advancing a workflow](/workflows/callbacks/). The engine runs these itself, inside the request that produced them, and **strips them from the response**. They never reach the browser, so backend operations aren’t exposed as public endpoints. * **UI callbacks** — everything in the table below. These are returned to the React client, which runs each row through a **closed registry** — no `eval`, no inline JS. A single result set can mix them; the server rows execute, the rest are handed on: ```sql SELECT 'startWorkflow' FunctionName, 'OrderApproval', 'Orders', @Id -- runs on the server UNION ALL SELECT 'showSuccess', 'Submitted', 'Sent for approval.', NULL; -- runs in the browser ``` ```sql -- Validation message that keeps the form/grid open: SELECT 'showAlert' FunctionName, 'error', 'Invalid Work Package Dates', 'Each work package must stay within project start/end dates.'; -- Refresh the grid after a successful update: UPDATE … ; SELECT 'refreshTable' FunctionName; ``` Supported **UI** `FunctionName` values and their positional params (the server callbacks are documented in [Workflow callbacks](/workflows/callbacks/)): | `FunctionName` | Params | Effect | | -------------- | ------------------------ | ------------------------------------------------------------------------------- | | `refreshTable` | — | Reload the surrounding grid/record | | `showAlert` | severity, title, message | Blocking dialog; severity = `error`/`success`/`info`/`warning` | | `showSuccess` | title, message | Blocking success dialog | | `showError` | title, message | Blocking error dialog | | `swal` | title, message, severity | Legacy alias for `showAlert` (severity defaults to `error`) | | `closeModal` | — | Dismiss the surrounding modal | | `navigate` | url | Same-origin redirect (internal routes navigate in-app; `javascript:` refused) | | `historyBack` | — | Browser back | | `openWindow` | url, target, features | `window.open` (defaults `_blank`, `noopener,noreferrer`; `javascript:` refused) | The client registry lives in `src/ui/genie-engine-ui/src/components/genieActions.ts`; it is wired into every mutation handler so any `` that returns this shape is dispatched automatically. An unknown `FunctionName` reaching the client surfaces an “Unknown action” dialog rather than failing silently. Migrating from `SELECT 'javascript:…'` The render-era Genie let a write block return a **JavaScript string** that the old server-rendered client `eval`’d: ```sql -- OLD (server-rendered Genie) — no longer executed: SELECT 'javascript:swal("Error","Invalid dates","error");'; SELECT 'javascript:window.location.reload();'; ``` The React UI **never executes these** — there is no `eval` in the client (it would also violate any CSP). A legacy `javascript:` result still passes through the API’s `Result` field for backward compatibility, but the client treats it as an inert string: the action silently does nothing. Rewrite each one as a dispatch row — same effects, positional params instead of code: ```sql -- NEW — dispatch rows through the closed registry: SELECT 'showAlert' FunctionName, 'error', 'Invalid dates', 'Each work package must stay in range.'; SELECT 'refreshTable' FunctionName; ``` Chains work too: the old `swal(...); setTimeout(reload)` idiom becomes two rows in one result set (`UNION ALL` or two selects) — rows run in order, each awaited. `redirect:`/URL string returns should become a `navigate` row for the same reason. ## Toolbar quick-search [Section titled “Toolbar quick-search”](#toolbar-quick-search) Marking one or more [columns](/model-authoring/expressions/#grid-columns--columns) `Search="true"` enables a **search box in the table toolbar**. On submit (Enter or the Search button) the typed term is applied server-side as a single `LIKE '%term%'` predicate **OR-gated across all searchable columns**, then **AND-combined** with any active column filters — e.g. `(Name LIKE @t OR Email LIKE @t) AND Status = @s`. The box appears only when the view declares at least one searchable column; the term itself is always parameterized and the searchable set is derived from the view config server-side (the client never picks the columns). SQL-backed views only. ## Filters (pre-execute) [Section titled “Filters (pre-execute)”](#filters-pre-execute) ```xml ``` Filter fields become `@parameters` available to `` (guard with `(@Param IS NULL OR …)`). Children are editor fields (or `` shorthand); an optional `` lays them out. Filter `SELECT Id AS Value, Name AS Label FROM Sales.Products ``` `RowStyle` is `Grid` or `Stacked`. Each `` child is itself a full editor field — including a `… IF (@Rows IS NOT NULL AND ISJSON(@Rows) = 1) INSERT INTO Sales.OrderLines (OrderId, ProductId, Quantity, UnitPrice, LineTotal, …) SELECT TRY_CAST(@Parent__Id AS BIGINT), TRY_CAST(j.ProductId AS BIGINT), TRY_CAST(j.Quantity AS INT), TRY_CAST(j.UnitPrice AS DECIMAL(18,2)), TRY_CAST(j.Quantity AS INT) * TRY_CAST(j.UnitPrice AS DECIMAL(18,2)), … FROM OPENJSON(@Rows) WITH (ProductId NVARCHAR(50) '$.ProductId', Quantity NVARCHAR(50) '$.Quantity', UnitPrice NVARCHAR(50) '$.UnitPrice') AS j WHERE TRY_CAST(j.ProductId AS BIGINT) IS NOT NULL; UPDATE Sales.OrderLines SET IsDeleted = 1, … WHERE OrderId = TRY_CAST(@Parent__Id AS BIGINT) AND IsDeleted = 0; IF (@Rows IS NOT NULL AND ISJSON(@Rows) = 1) INSERT INTO Sales.OrderLines (…) SELECT … FROM OPENJSON(@Rows) WITH (…) AS j …; ``` The `OPENJSON … WITH (…)` column list is the mass-assignment allowlist — only its declared paths are read. **RBAC is re-validated on the child view** (parent-create needs the child’s Create verb, parent-edit needs Update). The **parent** `` must `SELECT` the new primary key as its first result cell (e.g. `SELECT CAST(SCOPE_IDENTITY() AS BIGINT);`) so the engine can attach the lines. **Header total.** Because line persistence lives in the child view, recompute a parent total (e.g. `TotalAmount`) with an `AFTER INSERT, UPDATE, DELETE` **trigger** on the child table — the single source of truth no matter who writes the lines. Keep a `sumLines(...)` `ValueExpression` on the header field for the live in-form estimate while editing. View mode is automatic: `SubView` defaults to the child view, so View renders that grid read-only in the cart’s place (see [Per-mode rendering](#per-mode-rendering-subview)). ## Computed columns [Section titled “Computed columns”](#computed-columns) A cart column can carry a [`ValueExpression`](/model-authoring/expressions/#computed-values--valueexpression) that derives its value from the **other cells in the same row**. Give the column a `Disabled="true"` so it reads as a computed field — the cart makes any column with a `ValueExpression` read-only and recalculates it live as the row’s inputs change (including a value autofilled via ``): ```xml ``` The expression scope is the row’s own cells (referenced by column name); a numeric result is formatted to the column’s `DecimalPlaces`. An existing record’s computed cells are filled on load, so they never open blank. To total the lines in a **header** field, use the `sumLines(...)` helper in a `ValueExpression` on a top-level field. ## Dependent option filtering (`FilterBy`) [Section titled “Dependent option filtering (FilterBy)”](#dependent-option-filtering-filterby) A dataset-backed cart Select can filter its options by a **sibling cell in the same row**: set `FilterBy="{SiblingColumn}"` and expose an extra column with the same name on the Select’s dataset. Each row keeps only the options whose extra column equals that row’s sibling value (an empty sibling shows all options; the row’s current selection is never filtered away, so stored labels survive). Filtering is client-side over the already-loaded dataset — no extra queries per row. ```xml ``` ## Per-mode rendering (`SubView`) [Section titled “Per-mode rendering (SubView)”](#per-mode-rendering-subview) `SubView` links the cart to the [``](/model-authoring/sub-views/) sub-view that holds the same rows. It drives **per-mode rendering**: Create/Edit show the editable cart; **View** renders that sub-view read-only (permission-gated) in the cart’s place — so you never need a separate `` for the line items, and the view never shows raw JSON. When `SubView` is omitted the form falls back to a sub-view named after the cart field (and, for a `TableName` cart, to that table); if none resolves, View renders a plain read-only cart table. A referenced (`TableName`) cart always renders as the sub-view table — editable in Create/Edit, read-only in View. ## Selected-value labels [Section titled “Selected-value labels”](#selected-value-labels) A dataset-backed column shows the **label** of each stored selection in edit (and the read-only fallback), even when the option list isn’t fully loaded. The cart sends the stored keys to the field-dataset endpoint as `CurrentValues` and the backend returns just those rows (works for SQL / Options / CSV / Model datasets). For very large or search-only SQL datasets you can narrow this server-side with `WHERE Value IN (SELECT value FROM STRING_SPLIT(@CurrentValues, ','))`; otherwise no SQL change is needed. ## Large pickers — server-side search & scroll pagination [Section titled “Large pickers — server-side search & scroll pagination”](#large-pickers--server-side-search--scroll-pagination) A `ControlType="ModalSelector"` column doesn’t preload its dataset. Its picker searches **server-side** (the typed term goes to the field-dataset endpoint as `@SearchText`, debounced) and loads **one page at a time**, appending the next page as you scroll — so a catalog of thousands never loads (or renders) at once. The easiest way to page a large dataset is the [`Type="search"`](/model-authoring/editor-fields/#search-datasets-shorthand) shorthand — write just the core query and the engine adds the search/resolve/pagination for you: ```xml ``` (For full control you can still hand-write the plumbing with `Type="sql"` using `@SearchText` / `@CurrentValues` / `@PageSize`/`@PageOffset` — see the editor-fields reference.) A plain (non-modal) dropdown column still preloads its options, so keep those datasets small (or use a ModalSelector). ## Persistence [Section titled “Persistence”](#persistence) Note This section is the **local cart** recipe (you hand-write the JSON expansion in the parent). A [view-backed cart](#view-backed-cart-view--parentkey) persists itself through its child view — you don’t write any of the SQL below on the parent. A local cart is an in-memory field whose value submits as a **JSON array** under its own name (`@{Name}`, e.g. `@Lines`). Persist it in `` by expanding that JSON into child rows (SQL Server `OPENJSON`); capture the new parent id first and end by returning it: ```xml INSERT INTO Sales.Sales (InvoiceNumber, CustomerId, TotalAmount, …) VALUES (@InvoiceNumber, @CustomerId, @TotalAmount, …); DECLARE @NewId INT = CAST(SCOPE_IDENTITY() AS INT); IF (@Lines IS NOT NULL AND ISJSON(@Lines) = 1) INSERT INTO Sales.SaleItems (SaleId, ProductId, Quantity, UnitPrice, LineTotal, …) SELECT @NewId, TRY_CAST(j.ProductId AS INT), TRY_CAST(j.Quantity AS INT), TRY_CAST(j.UnitPrice AS DECIMAL(18,2)), TRY_CAST(j.Quantity AS INT) * TRY_CAST(j.UnitPrice AS DECIMAL(18,2)), … FROM OPENJSON(@Lines) WITH (ProductId NVARCHAR(50) '$.ProductId', Quantity NVARCHAR(50) '$.Quantity', UnitPrice NVARCHAR(50) '$.UnitPrice') AS j WHERE TRY_CAST(j.ProductId AS INT) IS NOT NULL; SELECT @NewId; ``` ### Edit mode (pre-fill + persist) [Section titled “Edit mode (pre-fill + persist)”](#edit-mode-pre-fill--persist) A cart maps to no real column, so to make it work in edit the same way as create: 1. **Pre-fill** — add a JSON-array column to the main ``, aliased to the cart field name, with keys matching the cart column names. The form-values load wraps `` (`… WHERE Id=@Id`), so the cart parses this JSON to seed its rows. Hide it in the grid via `Columns`: ```xml SELECT s.Id, …, (SELECT si.ProductId, si.Quantity, si.UnitPrice FROM Sales.SaleItems si WHERE si.SaleId = s.Id AND si.IsDeleted = 0 FOR JSON PATH) AS Lines FROM Sales.Sales s … ``` 2. **Persist** — mirror the `OPENJSON` expansion in ``: soft-delete the current child rows for `@Id`, then re-insert from `@Lines` (same block as `InsertSql`, keyed on `@Id`). 3. **View mode** — set `SubView="…"` on the `` (pointing at the `` sub-view for the same rows). The cart then drives all three modes itself: editable cart in Create/Edit, the read-only sub-view in View. Place a single `` in the layout — **no** separate `` is needed (a standalone `` here would duplicate the cart in edit). Without `SubView`, View falls back to a read-only cart table. Use a standalone `` only for child grids that aren’t backed by a cart. The local-cart pre-fill/persist steps above are the hand-written pattern; a [view-backed cart](#view-backed-cart-view--parentkey) does all three (load, persist, View mode) for you with no parent SQL. Worked example: `sample/Inventory/models/views/PurchaseOrders.view.xml` — a **view-backed** ``. The `PurchaseOrderLines` child view supplies the columns (a ModalSelector product column that autofills the line price via ``, a per-line `LineTotal` `ValueExpression`) and the set-based `OPENJSON` Insert/Edit SQL; the header `TotalAmount` uses `sumLines(...)` for the live estimate and the `tr_PurchaseOrderLines_Total` trigger for the stored value. # Editor fields & datasets > The block — every editor field type, per-field events, and Select datasets (sql/static/model), ModalSelector, columns, and autofill mappings. `` holds the create/edit form fields for a view. Each child is named after its field type. This page covers the field types and their attributes, plus the ``’s `MaxSelection`. Each bound is checked on its own — a field declaring only `MaxValue` is still range-checked. 4. The field’s `` rules, in authored order. Two rules apply to everything except `Required`: * **A blank value is skipped.** Demanding a value is `Required`’s job, so an optional field with a pattern or a rule is not flagged when left empty. * **Fields the user can’t edit are skipped** — hidden, disabled, `Allow`/`Deny`-locked, and `` (engine-generated). Their values aren’t the caller’s to fix, and pre-existing bad data must not block an unrelated edit. ### `` [Section titled “\”](#validations) Each `` is an **assertion**: it must resolve **truthy for the value to be valid**, and a falsy result reports its `Message`. The rule body is the element’s text (use `CDATA` so `<`/`>` need no escaping), and `Type` selects how it is evaluated — `Expression` (the default) or `Sql`. ```xml +@Discount >= 0 @Id) THEN 0 ELSE 1 END ]]> ``` **`Type="Expression"`** uses the same expression flavour as `Required`/`Hidden` (see [Column & field expressions](/model-authoring/expressions/)) and runs on both layers. An expression that fails to evaluate counts as a **violation** (fail-closed), so an authoring typo surfaces immediately rather than silently passing. Coerce numbers with unary `+` Submitted values are strings, and the expression engine compares two strings lexicographically (as JavaScript does) — `"50" <= "100"` is **false**. Write `+@Discount <= +@Total` when both sides are fields. Comparing against a numeric literal (`@Qty > 0`) already coerces. **`Type="Sql"`** is for checks a single row can’t see — uniqueness, referential state, aggregate limits. It runs **server-side only**, after every free check has passed and before the write: * The query receives the submit parameters (`@Field` for every declared field, the `@Session*` parameters, `@FormLoadTime`, and `@Id` — bound `NULL` on create, so the “unique except myself” shape `@Id IS NULL OR p.Id <> @Id` works in both modes) and must return a **single scalar: truthy (`1`/`true`) is valid**; falsy or no rows is a violation reporting `Message`. * The query is **never sent to the browser** (it’s stripped from `/metadata` like any other SQL body), so the client skips these rules; the submit response carries the message per field, and the form highlights + focuses that field exactly as it does for a client-side failure. * It is a read *before* the write, so a uniqueness rule narrows but does not close a race — keep the unique index as the real guarantee. * **Imports skip SQL rules** (one query per row per rule doesn’t pay); regex, bounds and expression rules still apply to every imported row. ## Per-field events [Section titled “Per-field events”](#per-field-events) ```xml ``` ## Field types [Section titled “Field types”](#field-types) Every type also takes the base attributes above, including `RegexValidation` and a `` block. | Element | Type-specific attributes | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | `TextType` (`SingleLine`\|`MultiLine`\|`RichText`\|`Email`), `MaxLength`, `Mask`, `AutoComplete`, `AutoCompleteSource` | | `` | `MinValue`, `MaxValue`, `DecimalPlaces`, `Step`, `ShowSpinButtons` | | `` | `DisplayType` (`Checkbox`\|`Switch`\|`RadioButtons`), `TrueLabel`, `FalseLabel` | | `