Changelog
A running history of user-facing changes to Genie — the XML contract, endpoints, config
options, and the React UI. Newest first, organized by release tag version (vX.Y.Z). Internal
refactors with no behaviour change don’t belong here.
[Unreleased]
Section titled “[Unreleased]”- A column’s
Lookuplink also works in a record view. A column withLookup/LookupKeydrew its value as a link in the grid only. In the record’s view mode the same value was plain text, so a reader looking at a record could not follow its links. Now, wherever a record view shows that column as a read-only field, the value is a link that opens the linked record read-only in a dialog with a View button, as in the grid. The key column need not be in the layout. A blank key draws plain text. See Expressions.
Changed
Section titled “Changed”- A sub-view embedded in a record’s view mode keeps its
LinkandReportrow actions. A<Sub>in a read-only record view used to lose every row action, not only the ones that change data. A child grid whose rows are opened only through links therefore showed no actions column and no ⋮ menu, so a user could not open a report from the record they were reading. An example is a report list withViewAction="Disabled". Link and Report actions now stay, already permission-filtered: they open a URL or a document and change nothing. Server-run actions (Sql,Script), Add New, Edit, Delete and import are still hidden there. See Sub-views.
[v0.3.6] — 2026-10-08
Section titled “[v0.3.6] — 2026-10-08”<SubView InMenu="true">lists a sub-view in the row’s More (⋮) menu instead of as an icon button. Every sub-view used to get its own icon on every grid row. A view with five or six sub-views became a row of look-alike icons, wider than the data beside it. A sub-view markedInMenuappears as a labelled item at the top of the ⋮ menu and opens exactly as its button did. Unmarked sub-views keep their buttons, so existing views look the same. See Sub-views.
Changed
Section titled “Changed”- The PDF viewer shows every page in one scrolling column.
/report-view(and<GenieDocumentViewer>) used to draw only the current page, with small pager arrows as the only way to the next; a reader who missed them took page 1 for the whole document. The pages now stack and scroll; the pager still works, its arrows jumping to a page and its counter following the page you have scrolled to. Zoom and fit-to-width apply to every page. Opening a PDF no longer blinks: the pane reserves its scrollbar space up front (the pages used to re-draw when the scrollbar appeared), and the pages fade in once page 1 has drawn instead of popping in one by one.
- A grid opens in the direction its view declares.
<Sql SortDirection="ASC">was ignored on the first load: a request that names no sort column carriesSortBy = "", not null, and the grid service only fell back to the view’s direction for null. Every SQL-backed grid therefore opened descending until someone clicked a header, so a list meant to read top to bottom (steps 1, 2, 3) read backwards. An emptySortBynow takes the view’s column and direction, as LINQ-backed views already did. Exports get the same fix. Grids that declareDESC, or a request that names a column, are unchanged. Grids that declareASC(or rely on its default) now open ascending, as written.
[v0.3.5] — 2026-10-07
Section titled “[v0.3.5] — 2026-10-07”genie-engine-uipicks up two security fixes in its dependencies. DOMPurify moves to 3.4.16 (two DOM XSS advisories inIN_PLACEsanitising) andsource-map-js, which Vite pulls in, to 1.2.2 (a denial of service on crafted source maps). The npm audit gate in CI failed on them, which stopped the v0.3.4 npm package from publishing.
[v0.3.4] — 2026-10-07
Section titled “[v0.3.4] — 2026-10-07”- Stopping impersonation works again, so a second impersonation no longer fails.
POST /api/v1/impersonation/stopinherited the controller’s System-role requirement and was refused with 403, because it runs as the impersonated (non-System) user. The client hid the error, so the admin’s cookie was never restored, and the next impersonation was refused as Already impersonating a user. Stop now needs only an authenticated session; start still requires the System role. - The impersonation failure toast now says why. It shows the server’s reason (for example System users cannot be impersonated) instead of only Could not impersonate this user.
[v0.3.3] — 2026-10-04
Section titled “[v0.3.3] — 2026-10-04”Changed
Section titled “Changed”- The workflow SLA monitor and the wizard storage worker are now monitored workers. Both appear on the hosted-services dashboard (Workflow SLA Monitor, Wizard Storage Worker) with run counts, durations and failures, can be paused there, and wait for model migration before their first cycle. A failed SLA sweep is retried after a minute rather than immediately. Wizard provisioning now starts once model migration has finished instead of alongside it.
MonitoredBackgroundService.ErrorDelay— a worker can now set how long to wait after a failed cycle (default two seconds).IWizardProvisioningQueue.ReadAllAsyncis replaced byWaitToReadAsync+TryDequeue. Only a host that implements the queue itself is affected.
[v0.3.2] — 2026-10-02
Section titled “[v0.3.2] — 2026-10-02”- The report designer has a canvas search. The toolbar’s search box rings every matching widget, filter and dataset and highlights the matched text; Enter steps through the hits, selecting each and scrolling to it (Shift+Enter goes back) — the same search the wizard and workflow designers have. History, XML and Open Preview are now icon buttons to make room for it.
- The assistant can now author wizards and workflows, the way it authors reports. On the wizard or
workflow designer, attach Current wizard / Current workflow from the assistant’s + menu and
ask for a change: the assistant reads the saved definition, and proposes a validated rewrite as an
Apply card that loads onto the canvas as one undoable step. Nothing is saved until you press
Save, and each tool re-checks your
Updatepermission on the definitions list. Applying keeps your canvas layout: existing nodes stay where you put them, new nodes are placed beside the node they connect from, and a field added to a wizard step with a form layout is placed in that layout so it actually shows. - Undo, redo and an unsaved-changes guard in the wizard and workflow designers. Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (and toolbar buttons) step through edits, a dot on Save marks unsaved work, and leaving the page with unsaved changes asks first.
- The Warehouse Activity sample report is a fuller, denser dashboard. Six KPI tiles to a row (units in, units out, a net-change trend, last movement and more), three slice charts side by side (adding the In / Out / Adjustment mix), a daily in/out flow beside the busiest products, and a per-warehouse scorecard beside a busiest-weekday chart. A new Movement type filter narrows all of it.
- Bring any
Microsoft.Extensions.AIIChatClientas an assistant model. AProvidersentry withProvider: "ChatClient"is served by the client the host registers under the entry’s name (services.AddKeyedChatClient("{Name}", …)) — Azure OpenAI, Bedrock, Anthropic, Google.GenAI, OllamaSharp or your own — and still gets the picker, token settings, thinking panel and turn trace. OpenAICompatibleprovider kind for any server that speaks OpenAI Chat Completions (vLLM, LM Studio, llama.cpp, a proxy). No API key is required.- Ollama and compatible thinking models show their reasoning in the Thinking panel (the
reasoning/reasoning_contentfield), streamed, and never as the reply. - OpenTelemetry for assistant model calls — an
Activityper call on theGenie.Assistantsource, recorded without message content. ChatEnabledonGET /api/assistant/config, telling the UI whether this caller may chat. When it isfalsethe panel shows “The assistant isn’t enabled for your account” in place of the composer and the conversation list, instead of a composer whose every send is refused. An older server that does not send the flag leaves the panel as it was.
Changed
Section titled “Changed”- Tidier wizard and workflow designer toolbars. History and XML are now icon buttons, side by side at the right of the toolbar next to Undo/Redo — the same controls, in the same place, as the report designer.
- The workflow designer’s property pane now matches the report designer’s. Every property is a
compact two-column row under an uppercase category (Identity, Guard, Status, SLA, Approval, Bound
record, Action, …) in one bordered box, with a chip naming the selection, a footer explaining the
focused row, and a full-width Delete button. Flags such as Require Comment, SLA and Bind Record are
unset/true/falseselects. Viewing an old version or a running instance now visibly disables every editor instead of silently ignoring edits. The saved XML is unchanged. - The wizard designer’s property pane now matches the report designer’s, too. Settings, links and
every node type show their properties as compact two-column rows under uppercase categories in one
bordered box — with the variables, fields, static options, layout tree, branch conditions and
storage/index editors as sections inside the same box — a chip naming the selection (
WIZARD,LINK,INPUT, …), a footer explaining the focused property, and a full-width Delete button. A node’s Label is now an ordinary row (no more click-to-rename title), and field flags (Required, Disabled, Readonly, Indexed) and an index’s Unique areunset/true/falseselects. Viewing an old version visibly disables every editor. The saved XML is unchanged. - The report designer’s toolbox is now a toolbar dropdown, and the left pane is gone. The canvas gets that width back. You can still drag an item from the dropdown onto the canvas, and now you can also click it to pick it up: the zones that can take it light up with Place … here, and clicking one places it (Escape cancels). A DataSet, or a filter when the filter bar has no Column yet, is placed as soon as you click it. A toolbox drag now lights up only the zones that accept what you are dragging.
- The report designer’s Outline moved to the right pane, as a tab between Properties and Issues, with its own filter box as before.
- The report designer’s toolbar is one tidy row. The Name, Slug and Label boxes are gone — the breadcrumb already names the report and the property grid edits it. Building controls sit on the left (Toolbox, Undo/Redo, and the 12-column guides, now an icon toggle), document actions on the right (status, History, XML, Preview, then Draft and Publish last).
- The report designer’s canvas no longer stacks a drop zone at the end of every container. A filled Column, Row, Grid or block used to carry a permanent “Drop … here” box, and nested four deep they took more room than the cards. Zones now appear only while you place something, and only where it can go; an empty container keeps its drop area. Each container gets a + in its header to add the first thing it accepts (a Row into a Grid, a Column into a Row).
- A report’s Name is read-only in the designer. It is the RBAC resource, so a stray edit would silently orphan the report’s grants. Renaming is still possible on purpose, through the XML dialog and Publish; Slug and Label stay editable in the property grid.
- A host that relied on the REST path with
ChatWidgetEnabled: falseloses its assistant. That combination only ever worked by accident. To keep the assistant, setChatWidgetEnabled: trueand choose who may use it withChatWidgetEnabledForRoles("*"for every signed-in user). - The assistant’s provider layer is rebuilt on
Microsoft.Extensions.AI. The four hand-written clients (OpenAI, DeepSeek, Ollama, Gemini) are replaced by oneIChatClient-based provider, and the kinds become presets. ExistingProvidersentries keep working:Endpointis now the API root and optional for the hosted kinds, with a trailing/chat/completionsstill accepted. Ollama is reached through its OpenAI-compatible/v1surface and Gemini through…/v1beta/openai. The per-turn chat trace is unchanged. - DeepSeek accepts any model name it serves. The three-name allowlist is gone — the stream idle deadline already covers the empty, never-ending stream it guarded against — so a new DeepSeek model needs no engine release.
- Narrow widget cards in the report designer show their titles. At six cards to a row the title was squeezed down to one letter beside the kind badge and controls. A narrow card’s header now wraps, giving the title the card’s full width, and the smallest cards drop the kind badge.
- Re-importing or re-creating a deleted wizard no longer fails with a duplicate-key error. Deleting
a wizard keeps its response table, but saving a wizard with the same name (the Wizard XML import, or
the designer’s new-wizard flow) could not see the deleted one. It created a second wizard that claimed
the same table and hit
IX_Forms_ResponseTable. The deleted wizard is now revived with its original response table and earlier submissions, and a different wizard that maps to the same table name gets a numeric suffix instead. - A report widget’s
Heightis honoured again. The card body ignored it and grew to fit its content, so charts sized themselves from a fixed aspect ratio (a donut in a 260px card drew a 317px circle) and a tall table never scrolled inside its card. Cards are now the height the author set, and a chart with noHeightkeeps its usual proportions. - A donut’s centre total no longer grows with the circle. It is held at a readable size instead of reaching ~30px on a large donut.
- The report designer’s small icon buttons are centred. The move handles, the × on cards and containers, and the toolbar’s icon buttons kept the browser’s default button padding, which pushed each icon off to one side.
- Charts no longer vanish when the assistant answers over the REST fallback. The chart tool is an
information tool, so it runs on the REST path too — but
POST /api/assistant/querybuilt its reply without the turn’s blocks and neither returned nor saved them. The reply said “I’ve displayed the donut chart” above nothing. The endpoint now returnsBlockson its result and persists the chart with the message (an Apply card is still dropped from history, as on the hub), and the UI’s fallback renders it. ChatWidgetEnabled/ChatWidgetEnabledForRolesnow actually switch the chat off. They gated only the SignalR hub. A refused hub connection sent the UI to the REST endpoints, which required nothing beyond a signed-in user, so the assistant kept answering for everyone — and the only visible effect of the setting was that charts stopped rendering. Every conversational endpoint (/query,/generate-sql,/chats/*) now carries the sameAssistantChatpolicy as the hub, evaluated by one shared rule.- Every assistant provider now recovers from running out of tokens. An answer cut off at
MaxTokenstriggers the compaction retry on OpenAI (Chat Completions), Ollama and Gemini too — before, only DeepSeek and OpenAI’s reasoning path noticed, and the others returned the half-written answer. - Retries are the same for every provider. A
408/429/5xxor a dropped connection is retried twice with backoff; DeepSeek and Gemini used not to retry at all. - Gemini’s API key is no longer sent in the URL, where proxies and HTTP logs record it, and its system prompt and tool results are now sent with the right roles instead of being folded into user turns.
- An OpenAI (or DeepSeek/Gemini) model entry without an
Endpointnow goes to that provider’s own service. It used to inherit Ollama’shttp://localhost:11434. - API keys are redacted from assistant errors and logs even when a provider or proxy echoes them in its error body.
[v0.3.1] — 2026-09-16
Section titled “[v0.3.1] — 2026-09-16”Changed
Section titled “Changed”- Opening the report designer no longer attaches the report by itself. The composer’s + menu used to auto-attach the page’s first offer on arrival, which made every message in the designer an authoring message: an ordinary question about the data was answered by reading the document, proposing changes, or reporting on what had not been changed. Attaching is what turns the assistant from something you ask questions of into something that rewrites your document — and what puts a multi-kilobyte document in the prompt — so it is now one deliberate click, and the designer is an ordinary conversation until you make it otherwise. Navigating to a page that no longer offers the attached context clears it rather than swapping in whatever that page offers.
-
A live “Thinking” stream for GPT-5 / o-series models, over OpenAI’s Responses API. Chat Completions never exposes a reasoning trace for these models — there is no
reasoning_contentfield to read, at any effort level, so a reasoning-family OpenAI model previously showed nothing while it thought, unlike DeepSeek’s reasoner.OpenAiProvidernow routes a reasoning-family model (gpt-5*,o1,o3,o4) on OpenAI’s own host to/v1/responsesinstead, streaming the model’s reasoning summary through the sameReceiveReasoningChunkpipe and Thinking panel DeepSeek already uses. It is a model-generated summary of the reasoning, not the raw chain-of-thought — OpenAI does not return that from any endpoint — and a turn can legitimately produce an empty one. Gated behind the existingEnableThinking(defaulttrue) and restricted to OpenAI’s own service: a self-hosted or proxied server answering to a reasoning-family name is not guaranteed to implement/v1/responses, so it keeps using Chat Completions exactly as before. Nothing to configure — existingProvidersentries pick this up automatically. -
MaxHeighton a report<Row>. A row with a widget whose content grows without bound — aDataTablewith many rows, or any widget nobody gave an explicitHeight— used to grow to match, and every other card beside it grew with it, because a row’s columns stretch to the same height.<Row MaxHeight="400">caps the row’s rendered height instead; whichever column would otherwise overflow it scrolls internally rather than stretching the row. It is a ceiling on the whole row, not a fixed size for one widget — a widget’s ownHeightstill answers that question, unchanged, and the two are meant to be used together. Additive: existing.report.xmlfiles need no change, and an ordinary form’s<Row>(the layout control is shared) silently ignores the attribute exactly as it ignores everything else authored on it today. -
An interactive hover tooltip for
LineandAreacharts. The only “tooltip” was a native SVG<title>nested in each ~2.6px data-point circle — in practice unreachable, and more so on any host that renders report cards at a denser size than the framework default. Moving the mouse anywhere over the plot now snaps a dashed crosshair to the nearest category by x-position and shows one joined tooltip listing every series’ value at that point together — a colour swatch, label and formatted value per row — instead of one series at a time; the hovered dot on each line grows slightly so it is clear which point is selected, and moving off the plot clears both.Bar,Horizontal="true"bars andPie/Donutare untouched and keep their existing per-shape native tooltip. Additive: no.report.xmlchange is needed, and an existingLine/Areachart gets the new tooltip automatically. -
ReasoningEfforton a provider entry. Sent asreasoning_effort; unset by default, so nothing changes for an existing configuration. It is the knob for the GPT-5 and o-series families, which default tomediumand pay for it on every pass of the tool-calling loop. A server that does not know the parameter has it dropped after one rejection, the same way the token-cap rename and the sampling knobs are learned. -
“Reply in chat” is now a real instruction, not a hope. Ask a question with the report designer open and add “reply in chat” — or tell the assistant not to change the report — and the turn answers in the conversation with figures and, where it suits, a chart: no Apply card, no XML. It is enforced by withholding the change-proposing tool for that turn rather than by asking the model nicely, because the designer’s own instructions push hard the other way and a prompt line loses that argument often enough to matter. Reading tools stay available, so “summarise this report, just reply in chat” still works. Detection is deliberately narrow and requires the phrase to end its clause: “don’t change the report” is chat-only, “don’t change the report title” is an ordinary edit — the asymmetry is on purpose, since answering in chat when you wanted an edit costs one message, and editing when you asked for neither costs an undo. The blunter version of the same control is to leave the report unattached, which is now the default; this is for the case where you are mid-edit, want it attached, and just have a question.
Changed
Section titled “Changed”-
A refused query now says which check refused it. Every refusal reached the model as the same sentence — “rejected for safety reasons. Only read-only SELECT statements without system-access patterns are permitted” — whichever check had actually failed. A model cannot correct what it is not told: asked for five KPIs at once it wrote the large combined query that asks for, was refused on length, read a message about read-only statements, and rewrote a query that had never not been read-only. Each check now names itself and says what to do instead, including the offending verb where there is one.
-
The query length cap is 16,000 characters, up from 4,000. It is a sanity bound, not a security control — the verb and catalog scans are what make a statement safe, and length has never correlated with danger. Five KPIs, a deduplicating subquery and a two-hop topology join is an ordinary question and lands past 4,000 without being remotely suspicious.
-
A
Pie/Donutchart now fills its card instead of sitting fixed at 150×150px. Every other chart kind (Bar,Line,Area) already measured its container and drew at that size; the slice-chart renderer never received the measurement at all, so a donut in a tall or wide card rendered small and centred with dead space around it, which read as broken rather than intentional. It now scales with its container (clamped 140–320px, kept square so it never distorts into an oval), while the internal drawing proportions (radius, stroke width) are unchanged. -
A chart survives leaving the conversation and coming back. Blocks rode the live push only, so reopening a chat — or switching to another and returning — kept the assistant’s sentence and lost the chart it described, which reads as a reply that failed to draw one. Chart blocks are now stored with the message and restored with it. Apply cards still are not, and that asymmetry is deliberate: a chart records what the data was at a moment that has passed, while 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.
Hosts must add a migration —
AssistantChatMessagegains a nullableBlocksJsoncolumn (nvarchar(max)). Existing messages keep working; they simply have no chart to restore. -
A
<in a tool argument no longer truncates it. A model calling a tool in its own XML-shaped native markup had each parameter read up to the first<rather than to the closing tag — and a bare<is how every SQL comparison against an upper bound is written. Observed on DeepSeek R1: the chart query was cut atAND StatTime, exactly where< toStartOfHour(…)began, and the database answered “Unmatched parentheses”. The error described the truncation, not anything the model had written, so it rewrote the query twice and failed identically each time. -
XML entities in a native tool call are resolved. A model escaping
<and>as</>inside an XML-shaped envelope is doing the correct thing, but the text reached the tool still encoded — and the semicolon ending each entity reads as statement chaining, so the read-only guard refused the query. The model was told its query was “rejected for safety reasons” for escaping a greater-than sign. -
Every tool call in one reply now runs. Models batch independent lookups — “fetch the technology dictionary and the KPI catalogue” — and the loop ran the first and dropped the rest without a word. The model then read a reply carrying one result where it had asked for two and spent its next pass re-sending the call it thought had gone missing. Observed on DeepSeek R1 three times in one eight-pass turn: nearly half the budget went on recovering from a silent drop, and the chart the user had asked for was never drawn. Up to three calls per reply run in order, with results returned together; past that the model is told by name which ones did not run, rather than being left to guess.
-
The assistant is told when it is nearly out of passes. It could not see the iteration cap, so it explored at the same rate right up to the point the turn was taken off it mid-thought. With two passes left it is now told so, and told that a chart must be drawn now because after the last pass it can only write text.
-
The assistant cannot ask a second clarifying question about the same request. The rule was “at most one clarifying question per user message”, and an answer to a clarification is a new user message — so the allowance renewed every round. Observed on GPT-5 Mini: “plot the hourly availability trend for Lahore sites over the last 24 hours” was met with which series, then which technology set, then how many sites — six minutes of narrowing without a single row read, and the turn that finally had everything it needed hit the request deadline. The tool is now withheld on the turn that answers a clarification, which is the only version of this rule that cannot renew itself, and the prompt tells the model to assume the reasonable reading and say what it assumed. Scope and row counts are named as things not worth asking about.
-
A charted query and a run query are judged by the same rules.
execute_querynormalized its SQL before the safety check andchart_resultchecked the raw parameter, so identical SQL ran fine as a query and came back “rejected for safety reasons” as a chart. -
SQL comments no longer make a query “unsafe”. The guard rejected any
--or/* */, while the system prompt tells the model to explain a complex query “inside SQL comments (– ) inside the query body” — so a model following instructions was refused, with nothing in the message to say why. Comments are now stripped before the keyword scan, which closes the hide-a-keyword hole just as completely; a write verb that appears only inside a comment is allowed, because the database ignores it too. Statement chaining, dollar-quoted bodies and unterminated block comments are still refused. The stripping is literal-aware, soWHERE Note = 'a -- b'is not a comment and-- it's fineis not an unterminated string. -
A long turn no longer forgets the question it is answering. The message window kept the last
ConversationHistoryLimit * 2messages of a list that grows by two on every tool pass, so from the fourth pass on, the user’s own question had been trimmed out from under the answer. Nothing errors: the model is left holding tool results with nothing to explain them. Observed on DeepSeek R1, which reasoned “the user message appears empty — I should ask for clarification” on pass six of a question it had been answering correctly since pass one, and closed the turn with a clarification prompt instead of the answer — the longer and more useful the work, the more likely it was to happen. History is still trimmed; the current turn never is. -
A model calling a tool in its own native format is no longer handed an empty parameter object. DeepSeek R1 sometimes emits
parameter name="params"carrying the whole argument object as one value, the shape its own function-calling API takes. That reached the tools as{"params": {"sql": "…"}}— a parameter object with none of the parameters in it. The envelope is now unwrapped. -
A missing tool parameter says which one.
execute_query,get_table_schemaandget_app_docread a required field without checking it was there:GetString()on a missing property throwsInvalidOperationException, which is not aJsonExceptionand so escaped their own error handling and reached the model as “Operation is not valid due to the current state of the object”. A model cannot fix what that does not describe, and each occurrence cost a pass. They now return “‘sql’ parameter is required”. -
A new chat no longer opens carrying the last chat’s questions. The user-memory block injected the previous eight questions from other threads into every conversation’s system prompt, and the option documented to switch it off (
UserMemoryMaxEntries: 0) selected the default of 8 instead of disabling anything — so there was no way to stop it. Zero (or negative) now genuinely disables it, and the default is off: a chat is sent its own history and nothing else. Hosts that want the old recall set a positive number. -
An ordinary question no longer gets answered as though it were a report edit. When a turn ran out of tool-calling passes, the forced final answer was always told “no change was applied to the report this turn” — including on turns that had nothing to do with a report. Handed that sentence, the model wrote the answer it describes: “No change was applied to the report this turn — nothing was added and no tiles were resized”, on a question about traffic figures, with the figures never mentioned. The reminder is now scoped to authoring turns; every other turn is told to answer the question asked from what the tools actually returned.
Changed
Section titled “Changed”-
The assistant is told the date in the form it needs to use it. Alongside the prose date it now gets today and yesterday in ISO form, and is told to prefer the database’s own
today()/now()functions over a pasted literal in anything that gets saved and re-run. A model given only “Sunday, September 14, 2026” writes the date it remembers from training into theWHEREclause, which comes back empty rather than failing — and a report carrying a hardcoded date is wrong tomorrow while still running perfectly. -
Suggestions have to be verified before they are offered. “What should I add here?” used to fall through every rule in the prompt — all of which are about answering with data — and the model free-associated plausible metrics and column names that do not exist. The prompt now holds a suggestion to the same standard as an answer: run it first with a
LIMIT, offer at most three, and never name a metric, status or category that has not appeared in a tool result or the schema. -
The report designer must read its own preview, not just run it. The preview ledger could only enforce that a dataset’s SQL had been executed; a query returning a column of zeros, no rows, or a “top 10” whose every label is identical passed that check and published. The prompt now requires probing an unfamiliar table with a small
LIMITquery first, and inspecting the returned rows for exactly those signatures before an Apply card is offered.
-
A provider that accepts a request and then generates nothing no longer hangs the chat. A new per-model
StreamIdleTimeoutSeconds(default 90) bounds how long a streamed answer may go without delivering a token.TimeoutSecondscould never cover this — it ends at the response headers, and a Server-Sent Events body arrives after them — so the turn sat on a typing indicator until the whole budget expired, with nothing in the log after200 OK.Seen against DeepSeek on 2026-09-15 and reproduced directly against the API:
200 OK, a: keep-alivecomment every 12 seconds, and no tokens for minutes, ondeepseek-reasoneranddeepseek-chatalike, with a funded and available account. That keep-alive is why nothing noticed — the socket is provably alive while the model is producing nothing, so a keep-alive deliberately does not reset the clock; only a delivered token does. The user now gets a message naming the model and saying the provider is reachable but not generating. -
A SQL keyword inside a string literal is data, and no longer refused as a verb. Both read-only guards — the report dataset gate and the assistant’s own
IsSafeQuery— scanned the raw statement, so the value a query filtered on was judged as if it were syntax. Found against a telecom KPI warehouse whose headline metrics are namedDrop Call RateandVolte Drop Rate: every report naming one was rejected for “using DROP”, and the assistant could not answer a question about the two KPIs an operator watches most. Genie now blanks the inside of quoted literals before the keyword scan, handling both the doubled quote and the backslash escape.Safe in that order, and only in that order: statement chaining (
;), SQL comments and dollar-quoted bodies are still rejected on the raw text first, and no engine executes DDL inside a scalar expression — so a verb the masking hides has no way to run. An unterminated literal is rejected outright rather than scanned, because the guard can no longer tell the query from its data. -
Font Awesome icons are now supplied by
genie-engine-ui. The package emitsfas fa-*classes throughout navigation, model-driven views, actions and reports, but previously relied on each host to load an undocumented external stylesheet; without one, every corresponding<i>element was blank. The package now bundles the matching CSS and webfonts itself, and the Inventory sample no longer needs its CDN workaround.The bundle imports Font Awesome’s four component stylesheets rather than
all.min.css. Both carry the same 2,668 icon classes, butall.cssalso declares the v4/v5 compatibility font families, which point at the same three webfonts — and a library build inlines every font as base64, so each alias embedded another copy. The stylesheet is 989 KB instead of 1.67 MB with no glyph lost; the legacy icon names (fa-home,fa-cog) are aliases infontawesome.cssand still resolve. -
The logo on the loading splash is no longer painted at 300×150.
theme.logois handed to the shell and to the splash as the same<img>; in the shell it lands in a sized chip, on the splash it had nothing constraining it, and an SVG with aviewBoxbut no width/height attributes has no intrinsic size — so a square monogram filled a 380px card. It is now 52px tall and centred. -
A host-supplied auth brand panel disappears on a phone like the built-in one. The narrow-screen rule named only Genie’s own
.auth-brand, so a panel supplied throughauth.brandPanelsurvived the collapse to one column and pushed the sign-in form below the fold. Everything that is not the form panel is now hidden below 860px.
[v0.3.0] — 2026-09-14
Section titled “[v0.3.0] — 2026-09-14”-
Anything already on the report designer’s canvas can be moved. Drag a widget card, a filter, a Column, a Row or a whole Section onto any drop zone — or pick it up with the grip beside its ×, and click where it goes, which is the path that works from the keyboard. While something is held, the zones that can take it light up and read “Move here” and the rest drop out of the tab order; Escape puts it back down. Moves that the document cannot hold are never offered: across the filter bar/page boundary (an
<Item>there names a filter, not a widget), into a container that does not take that kind of node, or into the node’s own contents. One undo step per move, and it moves the placement rather than copying it.The canvas scrolls itself while you carry something near its edges — faster the closer you get — so a target below the fold can be reached at all. A drag holds the pointer and the wheel is unreliable mid-drag, which previously left everything off-screen impossible to drop onto. It follows a picked-up node the same way, and a new control dragged in from the toolbox.
-
The assistant is now handed the report XML contract.
report_get_draftreturns a condensed element and attribute reference after the document. Until now the assistant had to rewrite a report it had only ever seen one example of, so any element the open report did not already use was a guess drawn from ordinary charting conventions —Typeinstead ofKind, aSeriesattribute instead of<Series>children, aSplitBythat does not exist. Each wrong guess costs a full round trip, re-sending the whole document and waiting on another completion, and three of them is the entire turn: an observed request for a 30-day area chart spent all four iterations discovering the contract and applied nothing. The reference states the rules a model cannot infer, including the ban on SQL comments inside a dataset body and the fact that a chart has no split-by. -
Each assistant reply says which model wrote it, beside its timestamp. Stored per message (
AssistantChatMessage.ProviderName), because the model picker stays live for the life of a chat — labelling old answers with today’s choice would misattribute them. Host apps need a migration for the new nullable column; existing replies simply carry no label. -
The assistant panel can be moved. Drag it by its title bar and it stays where you drop it, clamped inside the viewport; a reset-position button appears in the header while it is away from its default corner. Like the panel’s width and height, the position is a per-session choice and is not persisted.
-
A chat picks which databases it reads and which model answers it. Two controls under the assistant’s title bar: a checkbox list of data sources and a model picker. The source list is filtered to what the caller may actually query, and a chat remembers its choice, so reopening a thread does not silently answer the next question from a different database than the ones above it in the transcript. Both stay live for the life of the thread.
This replaces one process-wide
Genie:Assistant:Source. A turn may now span several sources — the prompt carries each one’s schema and dialect, and every query names the one it runs against — but a single statement still cannot, so the assistant issues one query per source and combines the results itself rather than writing a join that no engine could execute. -
Genie:Assistant:Providers— several models, chosen per chat. An array of named entries, each with its own provider, endpoint, model, token budget and API key, listed in the picker in the order configured. 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”. Keys stay out of the file, supplied by the entry’s index —Genie__Assistant__Providers__0__ApiKey. A key addressed by name instead fails at startup rather than being silently ignored. -
Opening the assistant on a report pre-selects the sources that report already reads. This is what lets it edit a report at all. With more than one source in context it now asks which source a new dataset should use rather than choosing — “total products” against a live table and against a replica are different numbers, and only the user knows which they meant.
-
Source schemas can be loaded from a file.
SourceSchemaFile.Load(path)reads anISourceSchemaProvider’s description from XML, Markdown, or a directory of either, so a description that is documentation rather than derived lives in text a data engineer edits without a rebuild. XML earns load-time validation naming the file: a duplicate table, a nameless<Table>, an empty<Rule>, or a rootSourcethat disagrees with the provider all fail at load rather than surfacing later as an assistant that cannot find a table. -
Genie:Assistant:MaxIterationscaps the tool-calling passes in one turn (default 5, as before). Worth raising 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. -
Genie:Assistant:Sourceaccepts"Default"for the application’s own database, which used to be expressible only by leaving the setting out. The name is reserved — aGenie:Sourcesentry calledDefaultis never resolved — so the word keeps one meaning wherever it appears, and a host switching the assistant between its database and a warehouse edits one value instead of deleting a line. -
A source and its schema can now be declared entirely from the host project.
UseSource(name, source => …)sets every setting aGenie:Sourcesentry has — includingSchemaPath,TenantColumnandSingleTenant, which the three-argument form could not express — and configures rather than replaces, so a value bound from configuration survives unless you set it. That matters most forTenantColumn: an overload that rebuilt the entry would drop it, and a source with no tenant column refuses every non-System caller. The three-argument form now delegates to it, so it is no longer a quiet way to un-scope a source. -
ISourceSchemaProviderdescribes a warehouse to the assistant in code. Markdown under a source’sSchemaPathremains the default; register a provider when the description belongs beside the code that owns the warehouse instead. Providers run after the files, so a provider’s table overrides a file’s table of the same name; one naming a different source contributes nothing, and one that throws is logged and skipped rather than failing the turn. -
Source-wide query rules. A
_rules.mdfile underSchemaPath— or a provider’sRules— is rendered into the prompt as mandatory instructions for that source. This is what a warehoused ClickHouse target needs to be queried correctly rather than merely validly: rows land in aReplacingMergeTreeholding every version of a row and keeping soft-deleted ones, so a read withoutFINALandIsDeleted = 0returns superseded and deleted rows — no error, just inflated totals. -
The framework’s own tables are warehoused.
Workflow.Instances,TransitionLogs,InstanceStates,ApprovalsandDefinitions, plusWizard.Forms, now replicate alongside your entities — a complete star for process analytics, which is the thing an OLTP database most conspicuously cannot answer.Audit.AuditLogjoins them on theWatermarkstrategy. A host can mark its own hand-written EF entities the same way, and the engine installs their triggers at startup.Upgrade note: a target configured with
Entities: ["*"]starts replicating these immediately. Name them inExcludeEntitiesif you do not want them.Deliberately excluded: the assistant chat tables (user prose and generated SQL, and warehousing has no column-level projection), the report-definition tables,
FlowVersion, and all ofIdentity— the last of which cannot be marked at all, sinceISyncablerequires aCompanyIdand no identity table has one. -
Wizard responses are warehoused. Each provisioned wizard’s response table replicates to
wh_Wizard_Resp{Name}, and it is the one target whose shape is not a copy of the source: the field values that live inside one JSON column in the application database each get their own typed column in the warehouse, so a response is queryable without a JSON function in every expression. The whole submission is carried alongside them in aPayloadcolumn of the target’s native JSON type — which is what makes dropping a field safe, since the column can go and its values stay queryable asPayload.ThatField. Every field column is nullable regardless of what the definition says today, because a response submitted before a field existed has no value for it and an optional field may be required tomorrow; and editing a wizard adds its new columns on the next sweep, since warehousing only ever adds. Responses replicate byPollinglike any other mutable table, and soft-deleting one tombstones it in the warehouse.Upgrade note: existing response tables gain an
IsSyncablecolumn and its trigger at startup, defaulted to pending so the first sweep is a full initial load. This is applied outside the shape gate that skips an unchanged wizard, so no designer save is needed to pick it up. -
Warehousing discovers entities from the EF model. Structure — column names, nullability, precision, and the provider types behind value converters — now comes from the model EF actually uses against the database, rather than being re-derived from the XML. One mechanism now describes generated entities, the framework’s own tables, and a host’s hand-written EF entities identically. Search configuration (URL template, context roles) still comes from the model catalog, because EF cannot express it.
-
ClickHouse tables are now named
wh_{Schema}_{Table}—wh_Inventory_Productsrather thanProduct. Schema-qualified because ClickHouse has one flat namespace per database, so unqualified names let two entities in different schemas collapse into one table; prefixed so a warehouse database can hold other tables and you can still tell which ones the engine owns. Columns are ordered as the class reads, too: key, the entity’s own columns in declaration order, then the inherited framework traits. Existing warehouse tables are not migrated — drop them and let the engine recreate them, and the automatic full reload refills them. Meilisearch index names are unchanged; they follow the search config, which the read path derives independently. -
The
IsSyncablecolumn now defaults to pending, and is indexed for you. Every syncable entity gets a database default oftrueand a filtered index on the flag, written in the host’s own dialect. The default means a column added to a table that already holds rows starts them all queued — so the first cycle is an initial load, with no hand-edited migration. The index keeps the worker’s claim a seek rather than a scan, which previously had to be authored by hand per entity. Both arrive in an EF migration, so hosts should scaffold one after upgrading. -
New
Watermarkwarehousing strategy, for append-only tables. Declared asWarehousing="Watermark", it reads forward from a high-water mark on the entity’s monotonic key and records how far it got in the target — noIsSyncablecolumn, no trigger, and no write of any kind to the source table. That matters for a high-volume append-only ledger, where a pending flag would mean anUPDATEper row forever on the one table whose design exists to make inserts cheap.Pollingremains the default and the right answer for ordinary mutable tables. -
The audit ledger is replicated to analytics, and reshaped for it.
Audit.AuditLoggains a monotonic identityIdand is replicated with theWatermarkstrategy — so it costs the source table nothing at all.OldValuesandNewValuescollapse into a singlePayloadcolumn holding the image each change produced: the previous state of a row is the payload of the change before it, so the pair doubled the widest column in the product for no new information. In the warehouse the table is partitioned by event month (queries prune, and retention becomes dropping a partition) andPayloadis declared as a nativeJSONcolumn where the server supports it, so its paths are queryable rather than an opaque blob.Upgrade note:
OldValuesis dropped and its history is not recoverable. The scaffolded migration renames the wrong column — EF picksOldValues→Payload, which would silently make every historical payload a before-image — so check that yours dropsOldValuesand renamesNewValues. Adding the identity column is a size-of-data operation on a populated table. -
Watermarkcheckpoints live in the target, not the source. Each target records how far it has read in aSyncWatermarkstable of its own, so the order is write-then-advance: a crash in between replays a batch rather than skipping it. Checkpoints are per target, so a broken or lagging target no longer strands the others the way the shared pending flag does. The watermark also stops below a gap in the key sequence — an identity key is assigned at insert, not commit, so a lower key can still be uncommitted — and steps over one only afterGenie:Warehousing:WatermarkLagSeconds(default 60) has passed, since a rolled-back transaction’s key never arrives. -
Per-field warehousing control:
Warehouse="false"andWarehouseType. Warehousing replicates every column of an entity it covers, soWarehouse="false"on a field is what keeps a sensitive or very wide column out — and therefore what makes some tables warehousable at all.WarehouseTypedeclares a column as something other than its field type implies; most usefullyJsonfor a text column holding a JSON document, which a target with a real JSON type can index by path rather than store as an opaque blob. -
Framework entities can opt into warehousing. A
[Warehoused(...)]attribute plus theISyncableinterface opts any EF-mapped entity in — the attribute carries the policy, the interface carries the column, because an attribute cannot create an EF column and a type cannot carry a strategy well. Generated entities get both automatically; a host can put them on its own EF entities to have those replicated alongside. -
DataSourceon a view — read an object from the analytics warehouse. A*.view.xmlroot can now name a datasource fromGenie:Sources:<Table DataSource="Analytics">runs that object’s query against the named server instead of the application database, and builds the grid’s own SQL — paging, sorting, filters, quick-search — in that source’s dialect, including ClickHouse (backtick identifiers,LIMIT … OFFSET …,{name:Type}placeholders). Point the read source at the same server a warehousing target writes to and a reporting grid reads warehouse rows with no extra machinery. See Views →DataSource. -
An object that names a
DataSourceis read-only, and says so early. Authoring an<InsertSql>,<EditSql>,<DeleteSql>,<SubmitSql>or<ImportConfig>alongside aDataSourcenow fails to parse, naming every offending block — rather than loading a write path that could never run against a read-only warehouse. The write endpoints refuse the object at runtime too, and its Add/Edit/Delete controls never reach the UI. Reading and exporting are unaffected. -
Lookups on a warehouse-backed object still resolve against the application database. Only the object’s own query moves: an editor field’s or column’s lookup dataset keeps reading the primary source, which is where the reference data lives — so an analytics grid shows real product names next to warehouse aggregates rather than bare ids.
-
Warehousing — replicate entity rows to external stores. Set
Warehousing="Enabled"on an<Entity>and its rows are shipped to whatever stores the host configures underGenie:Warehousing:Targets: ClickHouse for analytics, Meilisearch for search. The model says only that an entity is replicated, never where to, so the same model file deploys to an environment with a warehouse and one without. It is the write-side counterpart toGenie:Sources— a deployment can warehouse into ClickHouse with one and report off it with the other, pointed at the same server. Disabled by default. See Warehousing. -
ClickHouse tables are created and evolved by the engine. A warehoused entity gets a
ReplacingMergeTreetable keyed on(CompanyId, Id), created on first sync and diffed against the model on every process start — columns the model gains are added automatically. Columns are never dropped: a warehouse keeps history the source no longer has, so a field removed from a model is reported in a warning rather than taking its collected rows with it. -
A target that starts empty triggers a full reload automatically. When a ClickHouse table or a Meilisearch index does not exist, the worker flags the whole source table pending before reading anything, so the following cycles replicate the entire table rather than only rows that change from then on. Drop a warehouse table to force a rebuild and it refills on the next start. The flagging UPDATE skips rows already pending, so it is cheap to repeat.
-
One short log line per cycle, with the detail as a structured property. A sweep emits
Warehouse 🏭 Sync Completed in 1.84 s — 7,420 row(s) synced— the cumulative count across every table — and attaches the per-table breakdown as a SerilogTablesproperty ('Products' synced to analytics in 890 ms and search in 240 ms (5,000 row(s))), so the console stays readable while Seq or the file sink’s{Properties:j}can expand it. A table with nothing pending earns no line at all, so the property lists only tables that actually moved rows.Informationwhen rows moved,Debugwhen nothing was pending, so a quiet deployment stays quiet. Errors and warnings are separate events, never folded into the completion line, so searching for problems finds events rather than substrings inside a success message. -
The cycle detail is held by the hosted-service monitor, the way the notification workers hold theirs: the dashboard’s run-details view shows a line per table, then anything that failed, then the cumulative total. Failures are kept there as well as logged, because run details are where someone asks what a cycle actually did and a list of only the successes would mislead.
-
One target selects entities with
Entities/ExcludeEntities."*"(or omitting the key) means every warehoused entity; a name list means only those; exclusions are subtracted afterwards and win on a tie. The set is always intersected with what the model opts in, so a target can never pull an entity that has no replication flag to drive it. -
Deletes now reach the targets. A soft-deleted row replicates as a delete — ClickHouse keeps a tombstone carrying the record’s final values, Meilisearch drops the document. Previously a deleted record simply stopped being pushed and lingered in the search index forever. Hard deletes still cannot be captured (the row takes its pending flag with it), so prefer soft-delete for warehoused entities.
-
ClickHouse report datasets now execute.
Dialect: "ClickHouse"was accepted in configuration but refused at run time with “not yet wired”; the ClickHouse org’s ADO.NET driver now ships with the engine, so a<DataSet Source="…">on a ClickHouse source runs like any other. One authoring difference to know: ClickHouse binds{name:Type}placeholders rather than@name, so a dataset on that source writes its filters that way. -
The assistant can query a data warehouse. Set
Genie:Assistant:Sourceto aGenie:Sourcesentry and the assistant reads that database instead of the application’s own — its schema, its dialect, its connection. Because a warehouse has no modeled entities to describe it, the host writes Markdown: every## TableNameheading under the source’sSchemaPathis a table, and only the headings and their first line enter the prompt, with full sections fetched on demand byget_table_schema. One source at a time, since no single statement can span two engines. Unset (the default) changes nothing. Two protections that the primary database provides move into the engine here: the source is its own RBAC resource, and non-System callers are scoped by wrapping their query in aTenantColumnpredicate — declare that orSingleTenant, or they are refused rather than served unscoped rows.
-
The assistant stops putting
CompanyIdin report output. The security rules tell it to includeCompanyIdin everyWHEREclause, and a model told to filter on a column reaches for it in theSELECTlist too — so datasets came back projecting it, and a<DataTable>with no<Column>children renders every column it is handed. Since the company is chosen once in the navbar selector, that is a single repeated value occupying a table column, a chart axis or a tile. Both the security rules and the report XML contract now say it is a filter and not an output column, and that it stays out of the projection unless the user asks to see it or to break the figures down by company. -
An unrecognised attribute on a report widget now fails the import instead of being ignored.
<Chart Series="OnHand" SplitBy="Category">parsed cleanly, discarded both attributes and drew a single unsplit line — the author asked for a breakdown and got an aggregate, with nothing to indicate why. Widget,<Series>,<Detail>and<Param>attributes are now a closed set, matching how the rest of the report contract already treats an unknown element or an unknownFormat. The three a chart is most often written with —Type,SeriesandSplitBy— are each rejected by name:Typebefore the missing-Kindcheck, so the message names the attribute that is wrong rather than the one that is absent, andSplitBywith the reshape to use instead, since a chart genuinely has no equivalent. See Report authoring. -
A reply now stays in the conversation that asked for it. Opening the assistant’s thread list while an answer was still being generated, then going back into the chat, showed an empty conversation with the typing dots still running — and the answer, when it arrived, was appended to whatever thread happened to be on screen. The in-flight turn is now tied to its own thread: the list marks the conversation still being answered, reopening it shows the pending question, and the reply lands in the right transcript. While any thread is being answered the other composers will not send, since the server runs one turn per connection and a second message would cancel the first. (History is also read over REST now: a hub request sent mid-turn was queued behind the running turn, which is what made the reopened chat come back empty.)
-
The SQL and XML editors no longer show two horizontal scrollbars. Every CodeMirror-backed editor — the report designer’s dataset and options SQL, the report XML dialog, and the wizard and workflow SQL/XML boxes — drew its own scrollbar stacked on top of a stray native one, which left the real one hard to hit and ate a row of the query. Only the editor’s own scrollbar is drawn now.
-
report_preview_dataandreport_apply_changeno longer disagree about the same document. Preview would run a candidate happily and apply then reject it, sending the assistant round the loop to rediscover at the last step what the first could have told it. In one observed turn it spent the whole iteration budget doing so. One validator, used by both. -
A turn that ran out of steps could claim a change it never made. Having failed every attempt to apply an edit, the assistant answered “The mixed-source problem is fixed — the report validates again”, with nothing written. The forced-answer path now states plainly when no change was applied, so the model cannot assert an outcome it did not reach.
-
report_apply_changenow refuses a proposal whose SQL has never been run. The prompt has always asked the assistant to preview a dataset before offering it; asking was not enough. An Apply button is something the user has every reason to trust, and one backed by an unexecuted query means the failure surfaces on the published report in front of whoever opens it. A dataset whose SQL differs from the stored document must have returned rows throughreport_preview_datain the same turn, and the refusal names the dataset and how to verify it. A layout-only edit still costs no previews — re-running a query that is already live proves nothing. -
Assistant SQL is no longer bracket-quoted for every engine.
[schema].[table]is T-SQL, but it was applied to whatever database the query was bound for. On ClickHouse[sm]is an array literal, so a model’ssm.ProductId = p.Idreached the server astupleElement([sm], [ProductId])and came back as “Unknown expression or function identifiersm” — an error about SQL the model never wrote. It then spent its whole iteration budget rewriting a join that had never been the problem. Bracketing now happens only on SQL Server; this was latent for PostgreSQL sources too. -
A model that calls a tool in its own native markup is understood, not published as the answer. Genie’s MCP loop is a plain-text protocol and sends no
toolsarray, but a model trained hard on function calling reaches for its native syntax anyway — DeepSeek R1 does, once a turn has several tools and a long transcript behind it. The parser saw noTOOL_CALL:, concluded the response was the final answer, and handed the user a wall of markup while the report change it described was never applied. The envelope is now translated back, and — the half that matters more — any unrecognised tool-call attempt triggers a format correction instead of being published as prose. -
A tool result is no longer sent with the
toolrole. Genie’s MCP loop is a plain-text protocol — the model writesTOOL_CALL:lines and the result comes back as prose — not OpenAI’s native function calling, where atoolmessage must answer an assistant message carryingtool_calls. Sending it anyway failed differently everywhere: OpenAI rejected the whole request, while DeepSeek answered with a null content, which showed up as a turn that simply went blank. DeepSeek carried a private workaround for its own symptom; the rule now lives in one place and every provider uses it. -
The OpenAI provider adapts to a model that refuses a parameter, instead of failing the turn. GPT-5 and the o-series reject
max_tokens(they wantmax_completion_tokens) and refuse anytemperaturebut their own default — so pointing a chat atgpt-5-nanoused to end in a 400 with the server’s complaint and nothing else. The provider now reads the rejection’s ownparamandcode, corrects the request, retries, and remembers the answer for that model. The GPT-5 and o-series families are known up front, so on OpenAI’s own endpoint they send the right request first time with no probe at all; anything else is learned. It only drops parameters it chose to send: a rejection namingmodelormessagesis still reported as-is. -
The OpenAI provider no longer rewrites a
localhostendpoint to OpenAI’s. That existed only because one flatEndpointwas shared with Ollama’shttp://localhost:11434default, and it silently sent a self-hosted model’s traffic to OpenAI. Endpoints are per-model now. -
The per-turn log line reports the model that actually answered, not the configured default.
-
A warehouse description must name the target’s tables, not the source’s. Warehousing creates
wh_{Schema}_{Table}and keeps no unprefixed copy, so a schema file heading or anISourceSchemaProviderentry calledProductssends the assistant at a table ClickHouse does not have. The sample’s provider had exactly that mistake and now derives its names fromWarehousingConventions.TargetTableName; the assistant configuration guide says so, and its code example was carrying the wrong name too. -
Warehousing could silently drop audit rows when stepping over a burned key. The
Watermarkstrategy — which only the audit ledger uses — holds its checkpoint below a gap in the key sequence until the gap is explained, because an identity key is assigned at insert rather than at commit. When a gap aged out (a rolled-back transaction burning its key, an ordinary event) the checkpoint jumped to the top of the batch, taking any younger gap above it along. Those keys had had no lag window of their own and may have been a transaction that started moments earlier, so they landed below the checkpoint permanently, with nothing to re-read them and nothing to notice — a silent hole in the one table whose value is that it has none. A gap now records how far the batch reached when it was first seen: keys at or below that mark have aged out with it and clear in a single step, so an identity cache skip of a thousand values still costs one cycle rather than a thousand; keys above it start their own window and the checkpoint stops below them. -
The assistant’s turn trace was being built on every message and then silently discarded. The whole rendered block went into a Serilog
Traceproperty, and the default console template prints{Message}without{Properties}— so tracing an enabled turn produced a one-line[Assistant] Answer in 43258.9msand nothing else, while the block itself reached only the file sink as one unreadable JSON-escaped line. The block now goes in the message, so it shows up wherever logs are read. Two gaps it was hiding are also closed: the token-limit compaction retry and the iteration-cap best-effort call each made a provider request that appeared nowhere in the trace, and those two exits recorded no outcome at all, so a turn ending either way rendered as a block that just stopped. -
The assistant no longer answers with the model’s chain-of-thought. When
deepseek-reasonerreturned a fullreasoning_contentand an emptycontent— which it does when it spends its budget working the question out — the provider promoted the reasoning into the answer slot. A user who asked for a chart got the model’s notes as their reply (“Let me build using a CTE… Let me call chart_result.”), printed directly underneath the same text already showing in the Thinking panel. The reasoning now stays in the field it belongs to, and the model is asked once for the answer it never wrote; if it writes nothing again, the turn says so plainly. Truncated responses signal truncation alone, and a turn that ends with nothing to show says that rather than rendering an empty bubble. -
Reasoning survives a turn that hits the iteration cap. That path collected each pass’s chain-of-thought and then never assigned it, so the Thinking toggle disappeared on exactly the long, multi-step turns whose reasoning is most worth reading. A pass that repeats the previous one word for word is also no longer printed twice — reasoning models routinely restate their whole plan after a tool result, and the duplicate read as a rendering bug.
-
A pie of mostly-empty data no longer renders as one solid disc. A negative value produced a negative
stroke-dasharraylength, which is invalid SVG — the browser discards the dash pattern and paints the circle whole, so eight categories of stock came out as a single blue circle claiming to be a breakdown. A share chart can only plot positive values: zero and negative rows are now left out, the remaining slices are measured against what is actually drawn, and the count that was dropped is reported under the legend rather than quietly swallowed. A category keeps its colour when an earlier one is dropped. -
A pie or donut from the assistant always gets its legend. Legends were suppressed for single-series charts, which is right for a bar (its axis names every category) and wrong for a circle, whose legend is the only thing saying which colour is which. The assistant is also told to reach for a bar when a measure can be zero or negative.
-
A negative value in a chart is now drawn. Bars were measured from the floor of the plot with their height clamped at zero, so a negative reading rendered as an empty slot while the legend still reported it — the chart contradicting itself. The axis now opens below zero to a round step and bars hang from the zero line. An on-hand quantity below its reorder level is exactly the case that hit this.
-
A horizontal bar chart no longer draws two series against two different rulers. Every series had its own scale, and each scale put zero wherever its own data fell — so a series running -5…0 placed its zero at the far right of a plot whose visible gridlines started at 0, and a single -5 painted as a bar stretching most of the width. Per-series scaling is now taken only for the case it exists for (one series’ maximum at least ten times another’s, a currency total beside a count) and never when a value runs below zero: there is one axis line, so there can only be one zero.
-
Comparable series stay on one axis. Reorder level against stock on hand are the same unit and the same order of magnitude; scaling them apart made a legitimate comparison read as two unrelated pictures. They now share a scale, and keep their true ratio.
-
Charts no longer claim “series scaled independently” when they share one axis. Vertical bars, lines and areas always put their series on one axis, as now do horizontal bars in every case above, so the warning was false there and made an exact comparison look untrustworthy.
-
A bar’s category label is centred under its bar. Bar labels were positioned with the line chart’s formula, which puts a point on each division rather than in the middle of a slot, so every label sat half a slot to the left of the bar it named.
-
Warehousingnow names the strategy, andWarehousingStrategyis gone. An entity declaresWarehousing="Polling"(or"Watermark") — one attribute saying both that it is replicated and how, where the pair it replaces could express the meaninglessWarehousing="Disabled" WarehousingStrategy="Polling".Enabled/truestill parse asPolling, so existing models keep working; a leftoverWarehousingStrategyattribute is rejected at parse time rather than ignored, because ignoring it would silently hand an author who asked forWatermarkthe polling behaviour instead. -
Warehousing no longer loses a change made while a batch is in flight. The sync worker used to select the flagged rows, ship them, and then clear the flag — so a row updated in between had its flag re-raised by the trigger and then cleared by the worker, and that change reached no target until the row happened to change again. The window was the whole fan-out, which is seconds on a full batch. Rows are now claimed: the flag is cleared by the same statement that takes them, so a concurrent update re-raises it afterwards and is replicated on the next cycle. A batch no target accepts is re-flagged and replayed as before.
-
Nullable(JSON)is never generated. ClickHouse rejects it outright — the JSON type represents absence itself — so a nullable JSON column is declared bare. Left unfixed this would have failed theCREATE TABLEfor the audit ledger on a live server. -
The audit documentation had
ChangedAtbackwards. It described the column as the database clock falling back to the row’s audit timestamp; the triggers do the opposite — they prefer the row’s ownCreatedAt/UpdatedAt, which the application stamps. That matters: an app clock can skew between instances and be backdated by an import, so it is not usable as a replication watermark. -
ClickHouse report sources actually execute. The docs said a ClickHouse
Genie:Sourcesentry was “accepted in config but not yet wired”; it has been executing since the driver shipped with the engine. Corrected, with the{name:Type}parameter form that a dataset on one has to use.
Changed
Section titled “Changed”-
The
Genie:Sourcesname is gone from the last error messages that still used it. A report dataset carryingDialectorConnectionStringwas told to move it toGenie:Sources, a section that no longer exists — the message now namesGenie:ReportingSources. -
sample/Inventorygives an assistant turn a designer-sized budget —RequestTimeoutSeconds300,MaxIterations8, andTimeoutSeconds120 per provider. Editing a report is several round trips by design (read the draft, preview the query, apply the change, answer), and on a reasoning model each one runs 15-50 seconds, so the framework’s chat-sized defaults expired mid-edit and handed the user a timeout instead of an Apply button. The defaults are unchanged; any host that enables the report designer should raise them the same way. -
A column’s remove button sits at its top right, beside the span badge, in the report designer. It used to trail the column’s contents, which left it floating at the bottom right — away from every other control that acts on the column, and close enough to the widget card’s own × to be picked by mistake. Like the span badge, it appears on hover, selection or keyboard focus.
-
Genie:Sourcesis nowGenie:ReportingSources, and an array. One map for report datasets, objects and the assistant, with each entry naming itself — the order is what the assistant’s source picker lists.Connection→ConnectionString; newEnabled(which takes a source out of every path at once, so a retired database fails loudly instead of being quietly read) andIsDefault. A staleGenie:Sourcessection fails startup with the replacement named.Defaultis now a declarable entry for the application’s own database, so reports and the assistant can read it through a least-privilege login — which is where a report’s read-only guarantee actually comes from. A report dataset with noSourcefalls through to it, and a startup warning names the reports that moved. Objects deliberately do not: they write. -
A source is described to the assistant in code, not by a path in configuration.
SchemaPathis gone; anISourceSchemaProviderbinds itself to a source by its ownSourceproperty, which the compiler checks. Inside, it returns the schema inline or loads a file — both produce the same thing. -
report_apply_changeaccepts a report whose datasets span several sources, as long as the turn has the schema for each. The old rule demanded one source per document, which was the closest approximation available when the assistant could only see one — and it made editing a report impossible whenever the report read a different database from the one the deployment had configured. The real invariant was never uniformity but “do not write SQL for a database you have not been shown”, and that is what it checks now. -
The Inventory sample now describes its whole warehouse to the assistant.
AnalyticsSchemaProviderlisted two tables; it lists all fourteen theAnalyticstarget actually holds — the seven*.entity.xmlmodels plus the framework’s own warehoused entities (audit log, workflow definitions/instances/states/transitions/approvals, wizard definitions), each with its real columns and types. The catalog is the assistant’s only knowledge of a warehouse, so a table left undescribed was a table it could not answer from. Its query rules gained thewh_naming rule, the framework columns every table carries, ClickHouse date functions, and how to join withFINALapplied to each side. Provisioned wizard response tables stay out deliberately: their shape is derived at runtime, so a hand-written description would go stale unnoticed. -
Every log line an assistant turn produces now carries the same short key. A six-character key identifies the chat session and
chatId:sequenceNothe message within it, sogrep 'A7C3F1-42:7'returns one message end to end — the provider’s request and response lines, the schema builder’s, each tool’s — andgrep A7C3F1returns the whole conversation. The key is derived from the conversation id rather than generated, so it is stable across restarts and a key copied out of an old log still finds the live chat. Two config details decide whether it is visible: theoutputTemplatemust name a{Turn}column, andFromLogContextmust come first inEnrich, ahead of theWithPropertydefault that fills the column outside a turn. Both are shown in the sample and in Tracing a turn. -
The trace block is now tagged by phase. Each step is prefixed
[IN],[CTX],[SYS],[ITER],[LLM-REQ],[THINK],[LLM-RES],[TOOL-CALL],[TOOL-RES],[BLOCK],[OUT]or[ERR], aligned into one column, so a turn can be read or grepped by phase.[THINK]is new information rather than a rename: the model’s chain-of-thought was accumulated for the UI’s Thinking toggle but never written to the log. -
Genie:Assistant:Trace:Verbositygains anIotier, betweenStepsandFull.Iokeeps the turn’s own traffic — the question, the thinking, every provider response, every tool payload and the answer — for roughly 5 KB a turn, without the system prompt and schema that makeFull50–100 KB and barely change between turns. Two knobs join it:MaxDetailChars(default 20,000) caps any single payload and marks the cut explicitly, andLiveechoes each step as it happens so a slow or hanging turn shows progress instead of looking identical to a deadlock. ExistingStepsandFullsettings are unaffected. -
The assistant’s per-phase log lines dropped from
InformationtoDebug. Around twenty lines across the hub, the orchestrator, the conversation service and the schema builder now duplicate what the trace block says, and having both is what made the output unreadable. The sample’sMinimumLevelmoved fromDebugtoInformationto match, and gained aSystem.Net.Httpoverride — theStart/End processing HTTP requestpairs were never covered by the existingMicrosoftoverride. Warnings and errors keep their level, so failures stay visible with tracing off; setMinimumLevelback toDebugfor the old firehose. -
Warehouse tables are now exactly the entity — the
_genie_versionand_genie_deletedcolumns are gone._genie_deletedonly ever mirrored the entity’s ownIsDeleted, which is replicated like any other column, so it was a second spelling of the same fact in a table people read by hand. The version stamp goes with it:ReplacingMergeTree’s delete marker cannot be declared without a version beside it, and with neither the engine keeps the last row inserted for a key — which is the current state, because one worker ships one entity’s batches in order.What changes for a reader: filter
IsDeleted = 0instead of_genie_deleted = 0.FINALstill collapses superseded rows, but it no longer drops deleted ones — a deleted row keeps its final values in the warehouse on purpose, and a reader excludes it with the same predicate the application database uses. A table with no soft-delete flag (Audit.AuditLog) needs onlyFINAL. Update any_rules.mdorISourceSchemaProviderrules you wrote for the assistant.Upgrade note: existing warehouse tables are not migrated. Drop them and let the engine recreate them — the automatic full reload refills them.
-
Warehoused timestamps keep their full precision. ClickHouse columns are created as
DateTime64(7)rather thanDateTime64(3), which is 100-nanosecond ticks — exactly what a .NETDateTimeand SQL Server’sdatetimeoffset(7)carry, and finer than PostgreSQL’s microsecondtimestamptz. At millisecond scale the tail of every timestamp was truncated, not rounded (11:44:16.4516775arrived as11:44:16.451), silently and on every row. ADateTime64is oneInt64whatever its scale, so the fidelity costs nothing. Parameters bound on theDataSourceread path match, so an equality filter on a timestamp with a sub-millisecond tail now compares like with like.To be clear about what is not a defect: a warehouse timestamp is the instant in UTC, while the source column also carries the offset it was written at. A row written
16:44:16 +05:00reads back as11:44:16. Same moment, and the offset is deliberately not replicated. -
Boolean columns land in ClickHouse as
Bool, notUInt8. They read astrue/falsein any query tool instead of1/0, which matters because a warehouse table is read by hand far more often than it is written. Bound parameters on theDataSourceread path follow, so a filter compares like with like. Existing tables keepUInt8until they are recreated; both are the same byte, so nothing breaks in the meantime. -
Charts are drawn at the size of the box they are in. The drawing box used to be a fixed 520 × 300 that the browser scaled to fit, which scaled the text with it: in the assistant panel, ~290px wide, axis labels specified at 9.5px reached the screen at about 5px. The renderer now measures its container and draws one unit per pixel, so labels are the size the stylesheet asks for in a full-width report card and in a chat bubble alike, and the value gutter widens when the labels need it (
$1.28Mneeds more room than40). -
Long category labels are shortened instead of overlapping, with the full text on hover. Dropping more labels was the alternative, but an unlabelled bar says nothing at all while a shortened label still identifies its bar.
-
The default series colours contrast. The rotation began
primary,info— both blues, sinceprimaryis the theme accent — so an unattended two-series chart drew two bars the eye read as one. It now beginsprimary,warning, the pairing the report mockup uses. Charts with an explicitColoron each<Series>are unaffected. -
The assistant’s charts are laid out for the panel. Each carries a head with its title and point count, sizes itself to its own shape, and a bar chart of long or numerous category names is turned on its side so every name gets a full row instead of a shared prefix. A report renders the same spec exactly as its author wrote it — this is a fact about the panel, not about the chart.
-
Fixed: syncing an entity with
Concurrency="Enabled"no longer bumpsRowVersion. The worker’s flag write was an ordinaryUPDATEas far as the other per-entity triggers were concerned. TheIsSyncabletrigger declines to re-flag on its own and the audit trigger excludes the column, but the concurrency trigger did neither — so every sync cycle advanced the concurrency stamp on every row it synced, and any form open on one of those records failed its next save with a 409 that no user action caused. Both flag writes now run under the framework trigger-suppression sentinel, in a transaction, which is what the docs already described. -
Warehousing reads are markedly cheaper. Rows stream straight off the reader into positional arrays instead of a
DataTablewith a dictionary per row, each target resolves its columns to ordinals once per batch rather than hashing a column name per row, ClickHouse rows are projected lazily as the driver streams them, and the live/deleted split shares the row arrays. DefaultBatchSizeis raised from 200 to 5,000 to match. -
ClickHouse versions are strictly increasing. The
ReplacingMergeTreeversion stamp is now the UTC tick plus a per-process counter rather than a millisecond timestamp, so two batches written inside the same tick still order. Equal versions let ClickHouse keep either row, which made an update non-deterministic. Read collapsed state withFINAL. -
<Search>now rides on the warehousing sync worker, and the trigger was renamed. Declaring<Search>still gives an entity itsIsSyncableflag, but one worker now drains that flag to every configured target rather than a Meilisearch-only loop. The per-entity trigger is renamedtrg_{table}_SearchSyncable→trg_{table}_Syncable(the old one is dropped automatically on the next model migration), and<Search>andWarehousing="Enabled"share it — an entity that declares both gets one trigger, not two. -
The sync worker no longer clears a row’s flag until every target has accepted it. A failing destination leaves the rows pending and the next cycle replays them; both providers write by key, so the replay replaces rather than duplicates. Previously the flag was cleared as soon as Meilisearch queued the write, so a batch the index then rejected was lost with nothing left to replay it. Meilisearch writes are now awaited to completion for the same reason.
-
The sync worker’s batch size is a bound parameter. It was interpolated into the generated SQL.
-
AddWorkers(...):AddMeilisearchSyncWorker()→AddWarehousingSyncWorker(). The worker appears on the hosted-services dashboard as Warehousing Sync Worker. A Meilisearch target’s read provider stays active regardless, so an API host can still query the index while a dedicated worker host does the writing.
Removed
Section titled “Removed”Genie:Meilisearch— Meilisearch is now a warehousing target. Move it intoGenie:Warehousing:Targetsas{ "Provider": "Meilisearch", "Url": …, "Key": … };Host→Url,ApiKey→Key,SyncBatchSize→Genie:Warehousing:BatchSize,SyncIntervalSeconds→Genie:Warehousing:PollIntervalSeconds. The fluentConfigureMeilisearch(…)becomesConfigureWarehousing(…)/UseWarehousingTarget(…). A leftoverGenie:Meilisearchblock throws at startup rather than being ignored — ignoring it would leave search quietly working on the SQL provider against an index nothing writes to any more, a symptom that points nowhere near the cause.
Changed
Section titled “Changed”-
Genie:Connectorsis nowGenie:Sources, and<DataSet Connector="…">isSource="…". One word for the concept across configuration, the XML contract and the fluent builder (UseConnector→UseSource). The feature had not shipped, so there is nothing to migrate beyond renaming the attribute and the section. -
execute_querynames the engine it is running against, rather than always saying T-SQL. The tool description was the last place a hardcoded dialect survived — the same omission that producedLIMITagainst SQL Server — and with a warehouse source it can now be a third engine.
-
A report designer test failed on any fresh Windows clone. The comment round-trip check compared a sample’s raw file bytes against serialised output, but
core.autocrlfchecks the samples out with CRLF while DOM parsing normalises a comment’s interior to LF — so every sample with a multi-line comment reported identical-looking text as different. It now compares content rather than newline style, which is what it was always testing for. -
In the report designer the assistant answered instead of editing. Opening the designer switched on the three report tools, but nothing in the system prompt told the model what mode it was in — the rest of the prompt is written for the data path (“write SQL”, “answer in business language”), right down to a closing reminder scoped to business data, which an authoring request is not. So “add a tile counting products next to the existing one” came back as “22 products in total.”: the question answered as data, no report tool touched. Report-designer mode now appends its own block at the end of the prompt, naming the open report and overriding what came before — read the report before describing or changing 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_changeas a complete document. Note that report authoring still needs a large-context model: a 13 KB report is ~4,000 tokens in and the same again out. -
The assistant was never told which SQL dialect to write. Genie runs on SQL Server and PostgreSQL, but the engine was named only inside
execute_query’s own tool description — so the model guessed, and against SQL Server that meantLIMITand a bareIncorrect syntax near 'LIMIT'. The system prompt now states the engine and its row-limiting syntax explicitly, chosen from the configured datasource. As a backstop, SQL written for the other engine is caught before it reaches the database and returned with the correction (“useSELECT TOP ninstead”), which a model can act on where a raw syntax error leaves it retrying the same shape. -
Smaller models answered data questions with invented data instead of querying. Asked “which products are low on stock?”, a 7B model would reply with a confident, entirely fabricated list and never call a tool. Two things in the system prompt caused it: the response rules opened with “Lead with the direct answer” with nothing saying they applied to the final answer, which reads to a small model as “answer now”; and nothing anywhere stated that the model has no data until a tool returns some. The rules are now scoped to the final answer, an explicit no-invented-data rule was added, and the tool-first instruction is restated as the prompt’s last line (position matters — small models weight the end of a long prompt heavily). Measured against qwen2.5-coder-7b on a local llama.cpp: 0 of 5 data questions produced a tool call before, 5 of 5 after, with no over-triggering on greetings or how-to questions. Larger models were already calling tools correctly and are unaffected.
-
Every OpenAI-provider failure was reported as a connection problem. A non-2xx response was thrown as an
HttpRequestException, retried three times, and then surfaced as “Failed to connect to OpenAI API … check status.openai.com” — so a rejected request (bad model name, wrong endpoint path, prompt over the context window) looked like a network outage, the server’s own explanation was discarded, and anyone pointed at a local llama.cpp was sent to check the status page of an API they were not using. A rejected request now reports its status code, the endpoint dialled and the server’s message verbatim, and is not retried; a genuine transport failure names the endpoint too, which is what reveals the common case of a configured endpoint not being read and the built-inapi.openai.comdefault being dialled instead. -
Tool calls whose parameters contained a
{were silently unparseable. TheTOOL_CALL:/PARAMS:reader matched the parameters with\{[^{}]*\}, which cannot match a brace inside the object. Any report carrying{{permission-filter}}— the placeholder that applies row-level security, so in practice every access-filtered report — therefore could not be passed to a tool at all: the model emitted correct JSON, the engine reported a malformed call, and the turn spent its whole iteration budget on format corrections it could never satisfy. SQL with a brace in a literal hit the same wall. Parameters are now located by scanning for a balanced JSON object, tracking string state and escapes, so braces inside a string are data. This also keeps the behaviour the old pattern was reaching for: prose written after the call is still excluded. -
The assistant could not edit a report longer than 8,000 characters. Tool results are capped at
Genie:Assistant:ToolResultMaxCharsto protect the context window, andreport_get_draftwas being cut by it — so on any real report (the shipped Inventory samples are 13–14 KB) the model was handed a document with no closing tag, could not tell that its input was incomplete, and every rewrite it proposed was rejected as malformed. It ended up reporting “the update didn’t go through” without ever having seen the whole file. Tools can now opt out of truncation viaIAssistantTool.TruncateResult, and the ones returning a document do. The cap still applies to query results and schema dumps, where the first N rows still answer the question.
-
A
+context picker above the composer. It offers what the current page can contribute — Current report on the report designer, nothing elsewhere — as a removable chip. The page’s offer is attached automatically on arrival, so nothing that worked before needs a click; what changes is that the attachment is now visible and reversible. It is not decoration: only an attached context reaches the server, so removing the chip genuinely withdraws the report-authoring tools from following messages. Hosts get this for free —GenieAssistanttakes acontextOptionsprop that the shell fills from the route. -
The assistant panel can be resized by dragging. Drag its top edge to change the height; double-click the handle to go back to the default. Works with touch as well as a mouse, and the panel is clamped so it can neither collapse onto its own composer nor grow off-screen.
-
The assistant can draw charts in the chat. Ask it to show, plot, compare or visualise something and the answer arrives with a chart under it, rendered by the same component report pages use — no charting library is added to the package. A new
chart_resulttool runs the SELECT through the identical safety chain asexecute_query(SELECT-only validation, row cap, and the tenant-scoped read-only executor), so charting is a way to display data, never a second way to query it. The panel also gains an expand toggle in its header, because a chart is cramped at the default width. See AI Assistant overview. -
The assistant can help author reports. Open the report designer and the assistant gains three tools scoped to that page: it can read the report you are editing (
report_get_draft— the draft if there is one, otherwise the published version), run one of its datasets to see what the query actually returns (report_preview_data), and propose a rewritten document as an Apply button in the chat (report_apply_change). Applying loads the change onto the canvas as a single undoable step — the Undo tooltip reads “Undo Assistant change” — and nothing is written anywhere: Save Draft and Publish stay your own actions, still gated onUpdateofReportDefinitions, which each tool re-checks for itself. A proposal is validated by the same parser that gates publishing before it is ever offered, and a rejected one goes back to the model with the parser’s own message so it can correct itself rather than handing you broken XML. See Assistant-assisted authoring. -
Assistant tools are now grouped into categories, scoped to what you are doing. The existing tools form an
informationcategory that is always available; report authoring is areportscategory offered only while the designer is open. This keeps the system prompt small — five tools by default instead of every tool the framework has — which is what decides whether a smaller local model can still call tools reliably. Host tools registered withservices.AddScoped<IAssistantTool, MyTool>()need no change:IAssistantTool.Categorydefaults toinformation. See Configuration & providers. -
One log block per assistant message (
Genie:Assistant:Trace). A single user message used to be spread across a dozen log events from four classes, stitched together by conversation id. WithEnabled: truethe whole turn — schema resolution, every MCP iteration, every provider call and tool result, and how it ended — is collected and written as one consolidated event under theGenie.Assistant.Tracesource context, with the step outline in aTraceproperty. It flushes on every exit, including timeouts, cancels and exceptions, because the turns worth reading are usually the ones that failed.Verbosity: "Full"additionally captures the verbatim system prompt, messages and responses. Off by default, and it replaces the old per-iteration Debug transcript. See Tracing a turn. -
Draft and Publish for report definitions. A live report now keeps serving its published version while you rewrite it. The designer’s Save is now Draft: it stores your work without going live, Open Preview renders that draft in a new tab, and Publish is what makes it what visitors see — appending a version and clearing the draft. One draft per report; reopening the designer opens the draft rather than the published document, so in-progress work never looks lost. A draft is deliberately allowed to hold text the parser would reject, because that is the state an author needs to see and fix; Publish is where the parser has the last word, and it writes nothing if it refuses. Publishing a draft identical to what is live reports no changes and still clears it. New endpoints
GET/POST/DELETE /api/v1/genie/report-designer/definitions/{key}/draft,POST …/{key}/publish, andGET …/{key}/draft/structure+POST …/{key}/draft/datafor the preview — the last two gated onUpdateofReportDefinitions, deliberately not sharing the published structure endpoint, which is ungated beyond authentication and would have exposed every author’s unpublished layout. A draft preview applies the same row-level filter as the published report, resolved against the stored report’s resource name, so renaming a draft is not a way out of a filter. See Draft, Preview, Publish.Hosts must add a migration for the new
Genie.ReportStoreDrafttable. -
Status, Continue Draft and Draft Preview on the report definitions grid. Status distinguishes
Never published/Draft/Published + Draft/Published, and the two new row actions appear only on rows that actually have a draft. -
Undo and redo in the report designer. Ctrl+Z steps back through your edits one at a time, Ctrl+Shift+Z or Ctrl+Y replays them, and the toolbar carries both with tooltips naming what they would reverse. A run of keystrokes in one field counts as one step, so undoing a rename returns the whole previous name rather than a character; loading a version from History or loading hand-edited XML is likewise a single step, so a paste that rebuilt the canvas is one Ctrl+Z away. Undo restores what was selected at the time too, so the property pane lands back where you were. Ctrl+Z inside a text box, the SQL editor or the XML dialog still belongs to that editor. See Undo.
-
A visual report designer, at
/report-designer. Builds a*.report.xmldocument in three fixed panes, each scrolling on its own so the page never does: a toolbox of every widget, layout control, filter shape and dataset mode; a flow-laid-out 12-column canvas showing the layout as the page will reflow it, with each container’s kind visibly tagged and a dataset dock beneath; and a property grid whose closed-vocabulary dropdowns and per-property descriptions come from the same contract the parser enforces. XML and History open dialogs from the toolbar, matching the workflow designer — the XML round-trips both ways, so a hand-authored file opens in it and Load rebuilds the canvas from a pasted document. Renaming a widget or dataset re-points everything that referenced it; a card’s × removes that placement while Delete widget removes the declaration and every<Item>for it, and each removal confirms first, naming what goes with it. A card’s colour is the colour of the toolbox item that created it — a Tile card is the Tile tool’s colour, a Grid container the Grid tool’s — so what you drag is what you get, and an authoredColoris the only thing that overrides it. An Issues tab mirrors the parser’s own failures (an<Item>naming no widget, aTileon aMultiRowdataset, SQL that is not a single read-onlySELECT) so a mistake surfaces beside the thing that is wrong instead of as a rejected save. An Outline tab lists the whole document with its own filter box — matching rows keep their ancestors, so a match still shows where it lives — and clicking a row (or an entry in Issues) scrolls the canvas to what it selected and flashes it. AddsGET/POST /api/v1/genie/report-designer/definitions/…(load, save, list versions, read a version), gated on theReportDefinitionsresource —Viewto open,Updateto save — deliberately not on the report’s own resource, which would make “can view the dashboard” mean “can rewrite it”. See The report designer. -
Add and Design on the report definitions grid. Add asks for a Name (Slug and Label optional), writes a minimal but parseable
<Report>document, and opens it in the designer — so a row can never exist without a document behind it. The row’s Design action opens an existing report for editing. The grid still offers no Edit:Name,SlugandLabelare denormalized copies of the XML’s root attributes, so editing them there would make the grid disagree with the report it describes — rename in the designer, where both change together. -
Report pages — a new
*.report.xmlmodel kind. A whole dashboard in one document:<Filters>(the identical grammar as a view’s PreFilters),<DataSets>(named read-only SELECTs),<Widgets>(Tile|Chart|DataTable|View) and<Layout>(the same 12-column form layout, where<Item Name>names a widget instead of a field). Served at/reports/{slug}, with charts drawn as inline SVG — Bar, Line, Area, Pie and Donut, no chart library and so no new dependency for a host. See Report pages. -
Three tiles over one query. A dataset declaring
Mode="SingleRow"is capped at one row and each projected alias becomes an output property any number of<Tile>s can map, so a KPI strip is a single round trip.MultiRowis the default and is what charts and tables bind. -
An existing view can be embedded in a report, read-only.
<View Object="low-stock-report">renders a view you already have — its own SQL, columns, badges and RBAC — instead of restating them. Read-only is structural: row actions and every write path are withheld whatever the target declares, and the target is still gated by its own resource, so embedding never launders a permission. -
Report definitions are managed, not migrated. Like a workflow or a wizard, a definition lives in the database and is imported through the new System → Automation → Reports grid;
*.report.xmlis a transfer document. Export hands the stored text back byte-for-byte, and every import that changes the document appends a version to its history under a fresh six-character id, so a definition can be listed, diffed and restored. Adding*.report.xmltoGenie:Migration:FilePatternsdoes nothing. -
Genie:Sources— named, host-owned datasources. A report dataset can point at one withSource="Name", and the host decides which engine (SqlServer|PostgreSql|ClickHouse) and which credentials that name resolves to.Connectionis either aConnectionStringskey or a literal connection string. This keeps environment detail out of the model file, and lets a report read through a read-only least-privilege login — which is where its read-only guarantee actually comes from. An unknown source throws rather than falling back to the app’s own database, which would read the wrong data and look like it worked. ClickHouse is accepted in config but not yet wired for execution. -
Two report endpoints, mirroring the object structure/data split:
GET /api/v1/genie/reports/{key}returns the SQL-free structure (fetched once), andPOST /api/v1/genie/reports/{key}/dataexecutes the datasets a visible widget reads (gated onList). A widget withheld byRolesAllowedcosts no query at all, and a shared dataset is executed once. -
A blank required filter gates the whole page. No dataset is executed and the page shows a prompt, rather than a grid of cards that each look independently broken.
-
reportsmodule flag increateGenieApp({ modules }), defaulting to on — a report is a peer of tables and forms, not an opt-in integration.
Changed
Section titled “Changed”-
A designer rename now updates the report in place instead of creating a second definition beside the live one. Renaming a report inside the document used to leave the original untouched and insert a duplicate, silently. Relatedly, the report cache is now evicted under the report’s previous name as well as its previous slug, so a renamed report no longer keeps answering on its old name.
-
Openis hidden on the report definitions grid for a report that has never been published, which previously navigated to a valid but empty dashboard. The grid’s badges also use Genie’s owngx-*colour keys now, so they follow the light/dark theme like every other badge. -
The report designer’s Preview is now a link that opens a new tab. It was a button that navigated in place, which meant right-click, middle-click and Ctrl-click did nothing useful and, worse, that leaving for a preview silently discarded anything unsaved. It also now URL-encodes the slug, so a report whose slug contains a space or
#no longer produces a broken link. -
The report authoring contract fails loudly at import. A duplicate widget or dataset name, a widget pointing at no declared dataset, a
<Tile>reading aMultiRowdataset, an<Item>naming no widget, or a dataset that is not a plainSELECTall throw with a message naming the actual problem, shown against the file it came from. Attributes that would be silently ignored (Type,MaxRows,Dialect,Connectionon a dataset;Format/Unit/ViewOnlyLabelon an<Item>) are rejected with a pointer to the right place. -
Row-level security in a report is explicit. A dataset honours the caller’s
Listrow filter through a{{permission-filter}}placeholder the author positions. If a filter is resolved and the placeholder is absent, the request fails naming the dataset — a report has no query builder to append a predicate to, and wrapping the SQL would break theORDER BYa report dataset relies on, so failing loudly beats quietly serving unscoped rows. -
Fixed a broken heading anchor in the import docs.
Multi-file & directory importsused a{#custom-id}suffix, which Astro does not support — the braces rendered in the heading and the table of contents, and the in-page link pointing at that anchor went nowhere. -
Corrected the endpoint paths in
CLAUDE.md, which documented ametadata/{name}controller and aPOST /object/valuesroute that never existed — the real routes areGET /object/{name}/metadataandPOST /object/{name}/{table|form|view}. -
A capability’s pre-write Check is now enforced.
Create,ImportandExecutehave no prior row, so neither a row filter nor a row condition can speak for them — only the values being submitted can. The check runs after field normalisation, so it judges exactly what will be written, and a violation is a 400 naming the capability rather than a 403: the caller holds the capability, the values are what is refused. Previously this rule was stored, merged and round-tripped throughrbac.xmlwhile being enforced nowhere at all — it read as protection and gave none. -
A check can run in the database.
<Constraint Mode="Sql">executes as a scalar guard, so a rule canEXISTSinto another table when the answer depends on data the submission does not carry. The predicate is admin-authored and composed verbatim, but the submitted values are bound as parameters, never interpolated. The defaultExpressionmode stays in process and is cheap enough to run per row on an import. -
A prompting row action’s payload is checked too. A row action names a row and can collect input, so it is the one capability answering to two rules: a Condition on the row, and a Check on what the prompt submitted. Without it, prompt values were validated only by the action’s own SQL.
-
Exporthas its own row filter, inheritingList’s when it says nothing about rows — the same relationshipViewalready had. It was hardcoded toList’s filter before, so an export could not be scoped differently from the grid even where that was the point. -
ImportinheritsCreate’s check when it states none of its own. An import that violates it is refused as a whole, naming the offending rows, because the import already runs in one transaction. -
The permission model is now Resources, capabilities and grants. A Resource is a protected thing and declares only what it can do; a Permission is a grant that says who may do it, and under what conditions. What used to be a verb bitmask on one row plus an overriding bitmask on another plus a side table of string-keyed attributes is one idea with one shape: a capability. See RBAC.
-
Verbs and business operations are the same kind of thing.
List,UpdateandApproveare all capabilities, granted the same way, merged by the same rule, and read the same way in SQL. Adding a business operation is data, not a change to an enum. -
A grant cannot confer what a resource does not declare. The capability is a foreign key, so the ceiling is structural rather than a formula every caller has to remember to apply — which is what closes a real divergence: the check guarding a record read used to apply a wider verb set than the one guarding the write, so a control could render and then fail on click.
-
Permissions can be granted directly to a user, not only to a role — time-bounded and company-scoped. Giving one person access to one report no longer means inventing a single-member role. Role grants and direct grants resolve through the same most-permissive merge.
-
Three rules per capability, each enforced where it can actually be answered, and which one a capability carries follows the capability. A Filter decides which rows you reach (SQL, before any row returns). A Condition decides whether this row is in a state that permits the operation (after mapping, and re-checked on write). A Check decides whether the values you are submitting are acceptable (before the write).
-
Access windows.
StartTime/EndTimeon a grant express “until 31 December” directly. They used to sit on the resource, where the only thing they could say was “this switches off for everybody”. -
The query can answer a capability itself. A view returning
Permit__Approvetakes over that per-row test — useful when the rule is a join or anEXISTSthe expression dialect cannot express. It can never confer a capability the grant withholds. -
Deploying a view no longer leaves it invisible. Wizard response grids register their resource and capabilities automatically, granting nothing — the decision stays with whoever administers access.
-
The Access Dashboard at
/authorizationreplaces the read-only/authzdashboard: one page for users, roles, resources, grants, the four per-capability rules, and RBAC import/export. Its centre is a two-pane workspace — principals on the left, one row per resource on the right — where each row’s grant is shown as the capabilities it actually confers, as tags you can add and remove. Granting “Supervisor may Approve Orders” was seven steps across three pages, none of which ever showed what that role already held. -
A tag says what kind of access it is. Green is granted outright; amber is granted but narrowed by a filter, condition, constraint or field rule, so restricted access cannot be mistaken for full access — clicking it opens the rule editor.
+ Addoffers only what the resource declares and the principal lacks: a capability the resource never declared is absent rather than disabled, because the grant references it by foreign key and it was never a choice. -
Changes are staged, reviewed, then applied. A toggle joins a batch instead of writing immediately; Review lists it as sentences and Apply changes sends it in one request. Granting a capability across six resources is one decision rather than six silent writes, a mis-click is undone by toggling again, and a refusal is reported per change — so seven of eight apply and the one the server refused stays staged carrying its reason instead of vanishing. Switching principal asks before discarding a staged batch, because the batch belongs to one principal.
-
Each grant’s access window is visible and editable in place. The workspace shows
Always, orPending/Active/Expiredfor a time-bounded grant, and opens the grant itself to change it — an expired grant confers nothing, which previously took opening the record to discover. -
The resource list can be narrowed to one company or to the shared resources. The
Company scopeselector only ever narrows what your own scope already allows: naming a company outside it is refused rather than answered with an empty page. -
The principal rail states how far a change reaches — which company a role belongs to and how many people hold it, or a person’s email and how many roles they hold. Editing one person’s access and editing a hundred people’s look different before you start.
-
Granting one capability grants one capability. Where the principal holds no grant on the resource yet, the board creates one conferring exactly the capability that was added. (Adding a grant from the Roles or Users grid still starts from everything the resource offers — that is what “add a grant” means there.)
-
RBAC import can be previewed.
POST /api/v1/genie/auth/import-rbac/previewreports every row an import would create, update or remove — each removal with its reason — and changes nothing. It is not a second implementation of the merge: the server runs the real one inside a transaction and rolls it back, so a preview cannot disagree with the apply. This matters most forprune=true, which soft-deletes orphan resources, their capabilities, role grants and whole roles, and was previously a single irreversible button with nothing to inspect first. -
Direct user grants can be made from the workspace, not only from the User record — a person is another principal behind the same
Principal typeswitch as the roles. -
Genie:Migration:FilePatternschooses which kinds of model file the startup sweep picks up. The default is*.entity.xml,*.view.xml,*.sql,*.navbar.xml; adding"*.rbac.xml"opts a host into deploy-time RBAC. Configuring the list replaces the default rather than adding to it, an unsupported pattern is rejected at startup instead of collecting files nothing can run, and the effective list is logged next to the models directory. See Model migration.
Changed
Section titled “Changed”- Which rule a capability can carry now follows the capability.
List/View/Exporttake a row Filter,Update/Delete/row actions take a Condition,Create/Import/Executetake a Check. The authoring form offers only the applicable one, so it can no longer accept a rule the engine would never read — a Condition onListhas no row to test, and a filter onCreatehas no rows to scope. - A row filter must state its placement.
BuilderorInline, chosen explicitly.Autois no longer offered when authoring, because the placement check has nothing to check without a stated intent andInlinewith no placeholder would drop the filter entirely. It remains the stored value for a grant carrying no predicate, where placement is a question about nothing. - Creating a capability now refuses
Unrestrictedtogether with a filter, the same contradiction editing one already refused. The pair could previously be created and then fail to save on first edit. - A capability tag’s tooltip now lists only the rules that capability can carry, matching the authoring
form. It previously showed all three against every tag, so a
Listreported “Row condition: None” and “Submitted values: Unchecked” — rules aListcan never hold, reading as unset rather than inapplicable. A silentVieworExportnow reads “Scoped by List” instead of “Not set”, which had it exactly backwards: silence there means the grid’s filter applies. Inheriting still does not paint the tag amber, since the predicate belongs to the capability it is borrowed from. - The row-scope checkbox is now labelled “Allow every row — deliberate, so no other filter narrows
it”, replacing “Every row”. The old label read like “no filter set”, which is a different and much
weaker thing: an empty filter defers — it lets
View/ExportinheritList’s scope and lets another role’s predicate stand — while this checkbox opens the capability to every row and overrides both. The grid’s Rows column and the contradiction message use the same wording, and the checkbox gets its own row so the label is not truncated. The stored column, the entity property and theUnrestricted="true"XML attribute are unchanged. Can<Action>row cells are nowPermit__<Capability>.Cancould not be told apart from real data:Cancelled,CandidateIdand a business column literally namedCanUpdateall matched it. The__separator is the framework’s existing ownership marker, as inParent__andForm__, and the prefix is reserved — a view declaring aPermit__*column is rejected. Breaking for the published UI contract (FormValues.RowCapabilitieskeys, and the capability columns the grid adds) and for any view SQL already answering one. On PostgreSQL the alias must be quoted:AS "Permit__Update".TablePermissions.Attributesis nowTablePermissions.Capabilities— a list of names rather than a bag of string values. Only names cross that boundary; a capability’s rules are authored predicates and stay server-side by construction, rather than being filtered out by inspecting each value’s type.hasPermissionAttribute(...)/permissionAttribute(...)are nowhasCapability(...)/capabilities(...). Breaking. The value-reading helper has no replacement: express a numeric bound as aConstraint, which is enforced per operation instead of trusted from a bag.@PermissionAttributesis now@PermissionCapabilities, carrying the granted capability names.- Authorization resolves once per request. It used to be six independent lookups, each opening its own
DbContextand each re-querying the caller’s roles to answer part of the same question — a grid with four sub-views ran roughly eighteen authorization queries per page. - A row action is gated on the capability of its own name.
<RowAction Name="Approve">requiresApprove, with nothing declared twice and no magic string in a second file.Permission="…"still overrides it, and because verbs are capabilities that needs no separate code path. - The RBAC file, the deploy validator and the transfer verifier all fail closed. A document that parses but declares nothing is an error at every one of them. Previously an unrecognised shape parsed to an empty model: the deploy logged a warning and did nothing, the validator called the file valid, the verifier reported “0 facts match” and passed — and the database ended up with no permissions, with every surface reporting success.
- Model drift is loud again.
PendingModelChangesWarningis no longer suppressed. Suppressing it is exactly how a column added to the model but never scaffolded into a migration reached production code: the host booted clean and failed much later withInvalid column namefrom hand-written SQL. export-rbac/import-rbacleave direct user grants alone. They cannot be written down in a source-controlled file, so a file that omits them says nothing about them. Every sweep is scoped to a principal explicitly — the previous cascade keyed on the resource alone, with no principal predicate, and would have deleted a person’s access on the next deploy with no log line naming it.AccessManagercan reach the authorization API. The nav offered the Access & Control section toSystem,AdminandAccessManager, and every management view already scopes that role by company in SQL — but the controller admitted only the first two, so the third saw a link that answered 403.GenieAuthzDashboardis nowGenieAuthorizationBoard, and the route moved from/authzto/authorization. Breaking for the published export and for any host or navbar item pointing at the old path. The board’s Explorer tab is the previous dashboard’s permission trees, unchanged.AuthManagementControlleris nowAuthorizationBoardController. Same base route (/api/v1/genie/auth) and same paths forstats,user-tree,resource-tree,export-rbacandimport-rbac, so nothing that called it needs to change.AuthStatsgainsGrantsandDirectGrants.- An import result now lists what it did, not just how many rows it deleted:
created,updatedanddeletedarrays naming each row, with a reason on every removal. The per-kind counters are derived from those lists, so a summary cannot describe a different change set than the one that ran. - Adding a grant can start from a chosen capability. The role-grant and direct-grant views accept an
optional
SeedCapabilitiesparameter; left blank — which is what both grids do — they still seed every capability the resource offers. *.rbac.xmlis now reachable from the startup sweep at all. The strategy, its DI registration and the filename mapping all existed, but the sweep only ever collected entity/view/SQL/navbar files — so a shipped.rbac.xmlwas silently never executed, and the only way to apply one was the import endpoint or GenieClient. It is swept when a host lists it inFilePatterns.
Removed
Section titled “Removed”- Grant-level field rules (
<Fields Allow/Deny>) are gone. They were stored, merged and exported while being enforced nowhere, so a grant could promise that a column was read-only and not make it so. Anrbac.xmlstill carrying the element is now rejected on import, naming the role and capability, rather than applied as though the protection had not been asked for. Breaking for any file using it. Restrict columns withAllow/Denyon the view’s own<EditorField>instead — that mechanism is enforced on submit, and a locked field keeps its stored value on edit and its default on create. - The
.rbac.xmlv1 grammar.<Permissions>/<Permission>,<Allow>and<Attribute Key="…">are replaced by<Resources>/<Resource>,<Grant>and<Capability>with<Filter>,<Condition>,<Constraint>and<Fields>children. Each retired element is rejected at import with the replacement named. The dotted key grammar (List.Filter,RowAction.Approve.Condition,row.action.Approve) is gone with it — the pair it encoded is now a row. RolePermission.Allow. It was filtered in every resolution query, only ever writtentrue, dropped by export, impossible to produce by import, and rendered as a grant by the effective-permissions screen — four subsystems disagreeing about a column with one value. The entity documented deny-precedence that nothing implemented. Real deny inverts the most-permissive union that capabilities, filters and conditions all assume, so it needs a precedence pass of its own.Permission.FilterExpressionand the legacy<Permission Expression="…">bridge. Nothing read the column at enforcement time, and retiring the attribute left it with no writer at all.- Attribute value types and defaults. A capability is permitted or it is not.
Boolean/Number/Expression/Text,DefaultValueand per-role value overrides are gone; a numeric bound likeApprovalLimitbecomes aConstraint, which the server actually enforces. IPermissionManager.GetAccessListAsync— no callers anywhere.- The
{User.Id}/{User.CompanyId}/{User.Roles}token syntax. They were rewritten to@SessionUserId/@SessionCompanyId/@SessionRolesand did nothing else, so a filter now references session parameters directly. That also matches how the set actually works: it is open-ended, since a host contributes its own throughISessionParameterProvider. - The
/authzdashboard’s Management tabs. Their four grids are the authorization board’s own tabs now, and one of them had never worked: it openedPermissions— the Roles sub-view, whose SQL filters on@Parent__Id— with no parent, so nothing bound the variable and SQL Server refused the query. Grants are reachable only through a principal, which is what the Access tab is for.
-
Wizard responses now get one table per wizard, with indexable fields. Submissions land in
Wizard.Resp{Name}instead of a single shared table, and any field markedIndexed="true"becomes a real database column that can be filtered, sorted and searched. On a shared table that was impossible: making a field queryable means giving it a column, and one column per field per wizard blows past SQL Server’s 1024-column limit. A per-wizard table also confines an index build to that wizard’s rows rather than locking every wizard’s data. See Storage and indexes. -
Declare indexes in the wizard designer. A new
<Indexes>block at the<Wizard>root — same grammar as entity indexes (<Index Name Unique Filter><FieldRef Name Sort/></Index>) — supports unique, composite and partial indexes, and can key on built-in response columns such asCompanyIdandCreatedAtas well as wizard fields. The designer edits it under Storage & Indexes in the Terminal pane, with a picker over every field in the flow. -
A response grid for every wizard, for free. Once a wizard’s table is provisioned, its submissions are browsable at
/table/Resp{Name}— a generated Genie table view with the full RBAC, row-security and export stack, no hand-authored view and no new endpoint. The object is registered as a permission resource automatically so it appears in the authorization UI, but no role is granted it: grantResp{Name}to the roles that should see the submissions. -
SchemaHash— a second, shape-only version stamp. A 6-character lowercase hash of a wizard’s field set and indexes, stamped on the form and on every response alongsideVersionHash. It is also what gates DDL, so moving a node on the canvas no longer implies a schema change:VersionHashmoves on any edit,SchemaHashonly when the storage shape actually differs. -
Navbar designer — author the navigation without editing XML. A new System-only page at
/navbar-designer(System → Automation → Navbar Designer) edits the same tree*.navbar.xmldeclares: a drag-to-reorder outline that enforces the placement rules, a live rail/sidebar preview, a flat items grid, a kind-aware properties panel (route picker, icon preview, badge kind/colour/SQL with a Run query preview), and a two-way XML pane — import an existing*.navbar.xml, or export a designed tree straight back into one, through the engine’s own parser and a new matching writer. Backed by/api/v1/genie/navbar/designer/*, every endpoint[Authorize(Roles = "System")]because the authored tree carries the badge SQL that runs for every user. See Navigation → Navbar designer. -
Server-side navbar validation. Saving a navbar now runs
NavbarValidatorand is rejected on any error — a missing or duplicateName, a nested<Module>, a badge on a non-<Item>, a badge with no SQL. Warnings and notes flag the quieter traps: a node with neither route nor children (the filter drops it), an empty section, a root-level<Section>,Target="_blank"on an in-app route, badge SQL that isn’t a plainSELECT, and routes whose permission check falls back to the item’sName. -
The model catalog: two System-gated read endpoints.
GET /api/v1/genie/object/listenumerates every deployed object (name, slug, label, kind, version hash) — previously there was no server-side way to ask a deployment what it runs.GET /api/v1/genie/scripts(+/{name}) serves the executed scripts, latest per name, including the verbatim authored XML the deploy stored — so a client can export what is live and diff it against a local file. See API reference → model catalog. -
GenieClient is now a full agent-enablement MCP server — 50 tools, all annotated. New since the regression suite:
genie_validate_model/validate-modelnow open with a parser gate: every model file (view/entity/navbar/rbac) is run through the real Genie.Source parsers — the same codeexecute-scriptruns — so “this file will not deploy” is decided by the deployment’s own parser, offline. The structure rules’ vocabularies are also derived from Genie.Source’s enums instead of a hand-kept mirror, which fixes a real drift:<Email>and<DateRange>exist inEditorFieldTypebut the parser rejects them, and the old list silently accepted both.- Discovery:
genie_list_deployed,genie_export_views(pull the deployed XML down),genie_diff_deployed(hash fast-path, then canonical-XML compare — “is what’s deployed what I have?”),genie_describe_object(a compact capability summary),genie_get_navbar,genie_search. - Deploy:
genie_deploy_modelsdeploys a whole directory in dependency order (entity → view → rbac → sql → navbar), replacing hand-sequenced per-file calls. - Runtime: workflow instances (
genie_workflow_start/_act/_inspect) and wizard runs (genie_wizard_get/_execute/_submit) — a definition that imports cleanly can still mis-route at runtime; these are how that gets proven. Plus row actions, sequence preview, attachment upload/delete, and import templates. - Docs in-band:
genie_docssearches the published documentation corpus section-by-section, and three MCP resources (genie://authoring/contract,view-skeleton,loop) carry the XML contract so an agent with no checkout authors valid models. - A ready-made
genie-clientsubagent ships attools/GenieClient/agents/genie-client.md— copy it into a caller project’s.claude/agents/and Claude Code delegates Genie work to an agent that already knows the authoring traps, the validate → deploy → diff → verify loop, RunId cleanup, and tenancy. It is versioned with the tool surface: any GenieClient tool change updates it in the same commit, so re-copy it when you update GenieClient.
Changed
Section titled “Changed”- A wizard’s
Nameis immutable once it has a response table. The table name is derived fromNameand then frozen, because it holds submitted data — so a save that changesNameis now rejected with an explanatory error.LabelandSlugremain freely editable, andLabelis what users see. - Response tables are provisioned in the background. A designer save returns immediately and records
ProvisionState(Pending/Ready/Failed) plusProvisionError; the DDL runs on a background worker, which also reconciles every wizard at startup. Creating a table is effectively instant, but building an index over millions of existing rows is not, and a save should not block on it. A wizard whose provisioning failed refuses new submissions rather than writing rows that silently lack their indexed columns. - Workflow instances record
EntityTable. With one response table per wizard,EntityTypealone no longer identifies whereEntityIdlives. The task grids use it to resolve the originating wizard directly, so they no longer read the response row at all. - Existing responses migrate automatically. On the first startup after upgrading, responses are
copied out of
Wizard.FormResponsesinto the per-wizard tables with their ids preserved, so running workflows keep resolving;EntityTableis backfilled and the shared id sequence is advanced past every migrated row. The migration is idempotent and resumable, and the legacy table is left in place — no longer read or written — so you can verify the counts before dropping it. - Navbar sync no longer overwrites a navbar you designed.
Genie.NavbarDefinitionsgainsIsDesignerOwnedandUpdatedAt. Saving a scope from the navbar designer marks it designer-owned, and*.navbar.xmlsync then skips that scope (logging that it did) instead of replacing it on the next model change; “Hand back to XML” in the designer clears the flag. Existing navbars are unaffected — the flag defaults to false, so file-driven navigation behaves exactly as before. Host apps need a migration for the two new columns (seesample/Inventory/api/Migrations/…_NavbarDesignerOwnership.csfor the shape). - The MCP server is protocol-complete for agent harnesses. Every tool now declares MCP annotations
(read-only / destructive / idempotent — previously everything defaulted to “destructive”, forcing
approval prompts on harmless reads); the server sends its name, version and usage instructions at
initialize; responses are compact JSON (no indentation, nulls omitted); and large surfaces are bounded (genie_query_tablecaps pages at 200, lookups and validation findings take alimitand report aTruncatedcount).
-
The report designer’s XML dialog scrolls again. The editor was showing only its first screenful of a long document with no scrollbar, because the shared CodeMirror theme lets the editor grow to its whole content and the dialog clipped it instead of capping it. The XML dialog and the property pane’s SQL editor now both bound the editor’s height, so it scrolls inside itself while the dialog’s header and footer stay put.
-
The report designer no longer strips XML comments. Opening a hand-authored
*.report.xmlin the designer and publishing it used to delete every<!-- … -->block from the stored document — the file header, the section banners, the note on the dataset carrying{{permission-filter}}. Import stores the text verbatim, so those comments reached the database and then vanished on the first publish, takingExport’s copy with them. Comments now round-trip verbatim, including a multi-line comment’s own interior formatting, each attached to the element that follows it. The one shape that still does not survive is a comment trailing at the end of a block with no element after it. Whitespace is still normalised to the designer’s canonical layout. -
GenieClient’s
import-rbacsummary reported real numbers again. The capability rebuild renamed the response’spermissions/allowscounts toresources/grants, and the client kept reading the old names — so it printed a confident0for resources and grants on every successful import. The table now also shows how many changes were actually applied, which is the number that says whether an import did anything. -
Pickers no longer offer what is already assigned. Adding a grant to a role listed every resource including the ones it already held; assigning a role to a person listed roles they already had; and the same went for a grant’s capabilities, a role’s members and a company’s members. Every one of those views refuses a duplicate on submit, so the only thing offering it produced was an error to read. The exclusion is gated on the caller being able to administer the parent, so it cannot be turned into a way to read someone else’s assignments off what is missing from a list.
-
The Users and Roles admin grids load again. Both — and the Assigned Roles tab inside a user — still joined
Identity.RolePermissions, a table the permission rebuild dropped, so opening any of them answered500 Invalid object name 'Identity.RolePermissions'. Their permission counts now come from the grant tables: a role’s count is its grants, and a user’s effective count is the grants reached through their roles plus grants made to them directly, which the old role-only join could not see. -
The Roles grid’s “Role Permissions” tab opens. It pointed at a view named
RolePermissions, which the rebuild renamed toPermissions, so the tab rendered and then answered404 View not found. -
Hardcoded views are now checked against the model by the test suite. Two tests assert that every schema-qualified table a framework view names exists in the EF model, and that every sub-view it declares resolves in the registry — per dialect, with no database. Both defects above are exactly what they catch: view SQL is only ever parsed by the database, at the moment a user opens the grid, so a rename that misses a view stays invisible until someone browses it.
-
genie_validate_sqlknows the row-security parameter’s real name. It still allowed@PermissionAttributesand flagged@PermissionCapabilitiesas undeclared — exactly backwards since the rename. -
A
<FormParameter>declared on a table-style object is no longer discarded. The derived editor form replaced its parameter list with just the primary key, so aTableobject’s own declarations vanished — and since a declared parameter is what makes a name both sanitizer-allowed and always bound, Insert/Edit SQL referencing one failed at run time withMust declare the scalar variable @X. Nothing had noticed because no shipped view had one: none could work. -
Startup reconciliation of wizard response storage actually runs now.
WizardViewInitializerServicewas documented as regenerating every wizard’s response view at startup, but was never registered as a hosted service, so it never ran at all. Its replacement is registered. -
Wizard response DDL no longer interpolates unvalidated identifiers. The old view generator formatted the wizard name and every field name straight into
CREATE VIEWtext. Identifiers are now validated against a strict identifier rule before they reach any SQL, and every response read and write binds real parameters. -
API failures now carry the server’s
traceId. The global error envelope always included it, but the client dropped it — an agent (or a human) had no way to correlate a failure with the server logs.GenieApiExceptionnow carries it, every CLI error panel shows it, and MCP tools return{ Error, StatusCode, TraceId }.
Removed
Section titled “Removed”Wizard.FormResponsesand[Wizard].[vw_{Name}Responses]. The shared response table is replaced by the per-wizard tables (data is migrated automatically, see above), and the auto-generated flattening view is replaced by the generated/table/Resp{Name}grid. The legacy table is no longer read or written and can be dropped once you have verified the migrated counts.
[v0.2.0] — 2026-08-04
Section titled “[v0.2.0] — 2026-08-04”-
Reports and files now open in the UI, not just download. Two new pages —
/report-view?report={key}&title=…and/file-view?path=…&title=…— fetch the document from the API with your bearer token and render it in the app shell. A report PDF gets a real viewer (page navigation, zoom, Download, Print); images render inline; anything else downloads immediately and says so. This closes a long-standing gap: the report and file endpoints are[Authorize], but a plain link orwindow.opencan’t send anAuthorizationheader, so linking straight at them just produced a 401. Link to the pages from a row action or the navbar exactly like any other route —Url="/report-view?report=sales.invoice&invoiceId={Id}"— with no XML-contract change. See Reports. -
Target="Modal"on a link row action opens that report/file in a dialog over the current grid or record instead of navigating away. See Link actions. -
GET /reportlists the registered reports (key, display name, description, category) so a client can offer a picker or resolve a report’s title without generating it. -
?inline=trueonGET /report/{key}marks the responseContent-Disposition: inline, so opening the raw URL in a browser previews the report instead of saving it. The default is still a download. -
genie.AddReports(...)declares which assemblies are scanned for[Report]builders, and the newpdfWorkerSrcUI config option points the PDF viewer at a self-served pdf.js worker for hosts with a strict CSP. -
Workflows start and advance from your SQL, on the server. A
<Sql>block that returnsSELECT 'startWorkflow' FunctionName, 'MyWorkflow', 'MyEntities', @Id;now starts that workflow — and the engine runs it inside the same request instead of handing the row to the browser to call back with. Same fortransitionWorkflow(by instance id), the newtransitionEntityWorkflow(by business record), andworkflowSubmitApproval. Because the callback envelope is a list of rows, a set-basedSELECTstarts one instance per record, each with its own context — which is how a bulk import now starts workflows, from<ImportConfig><AfterSql>, with no new endpoint and nothing SQL-Server-specific. A record that already has an Active instance is skipped (alias a columnAllowMultipleto opt out), so an edit form firing the callback on every save no longer stacks up duplicates. See Starting & advancing workflows. -
A submit can return a value and a callback. Result sets are now classified by shape rather than position: the first set whose first column is
FunctionNameis the callback envelope, the first other set is the value. That lets a cart parentSELECTits new key and fire a workflow callback from the sameInsertSql— previously impossible, since one result set can’t be both. A single result set behaves exactly as before. -
Trailing callback columns become workflow context. Anything you alias after a callback’s fixed arguments (
…, @Id, po.Number, s.Email AS SupplierEmail) is carried into the instance’sContextData, layered over the submitted parameters.@Session*and framework keys are deliberately excluded — they’re re-supplied live at every execution point, so a frozen copy would go stale and shadow the real value. -
Bind a workflow to its record and the status follows the flow. A workflow’s Start node can now carry a
<Binding Entity PrimaryKey StateColumn CompanyColumn? RemarkColumn? RemarkText?/>. Every transition that carries a status writes it straight to that record — noExecuteSqlAction node, no hand-writtenUPDATEper edge, and portable across SQL Server and PostgreSQL. Binding is optional: leave it out and transitions just move the instance as before. Where the record’s own state column has a narrower vocabulary than the flow (a fixed<Select>, say), a transition can carryEntityStatusfor the record whileFlowStatusstays the flow’s own richer label. If aRemarkColumnis bound it receives the comment the user typed — so a rejection reason lands on the record — falling back toRemarkTextor a generated “Updated automatically by the … workflow.” line.CompanyColumndefaults toCompanyIdso the update can never reach another tenant’s row (authorCompanyColumn=""for a non-tenant table), and entity/column names are validated as plain identifiers when the definition is saved, so a bad binding fails in the designer rather than mid-transition. See Bound record. -
Transitions can run SQL. A
<Transition>can carry a<Sql>child that fires when that edge is taken, for updating objects the binding doesn’t cover. It gets the same parameters as Action-node SQL (@EntityId,@InstanceId,@FlowStatus, session parameters, every context variable) plus the edge’s own@TransitionLabel,@TransitionFrom,@TransitionToand@Comment. The bound-record update, the transition SQL and the instance state now commit together: if the SQL fails, the transition is rolled back and the instance doesn’t advance. See Transition<Sql>. -
Field validation you declare once and get on both layers. A field can now carry
RegexValidation(with an optionalRegexMessage) and a<Validations>block of<Validate>rules — each an assertion that must hold for the value to be valid.Type="Expression"(the default) runs in the browser and on the server;Type="Sql"runs server-side only, for checks a single row can’t see (uniqueness, referential state) — its query is never shipped to the client. Type bounds (MinValue/MaxValue/DecimalPlaces,MinDate/MaxDate,MinLength/MaxLength, a multi-select’sMaxSelection) are now enforced server-side too.RegexValidationpreviously existed on the schema but no parser ever read it, so it did nothing; it now works. See Validation. -
The form tells you what’s wrong, on the field. A rejected submit shows the message inline under the offending field (with
aria-invalid/aria-describedby), scrolls to it and focuses it, instead of only raising a toast. Rules are also re-checked when you leave a field, so a bad pattern surfaces before you press Submit. Validation failures the server catches — includingType="Sql"rules the browser can’t run — now land on the right field too: a 400 envelope carriesfieldErrors(field name → message), and a directed form or modal stays open for them instead of closing with a toast. -
@FormLoadTimeis bound on every submission — the UTC instant the user loaded the form/view, stamped server-side, round-tripped by the client, and available to<SubmitSql>/<InsertSql>/<EditSql>and toType="Sql"validation rules. Useful for “opened at” audit stamps, dwell time, or staleness heuristics; it is replayable by a caller, so it is not a security control. -
validate-sqlstatically checks the SQL authored in your views — offline.test-viewexecutes the grid query, so a broken<Sql>surfaces; butInsertSql,EditSql,DeleteSql, row actions and import blocks are never executed by any read-only check, so a bad@parameterin one waited for a real user’s submit. The new command reads the*.view.xmlfiles directly — no endpoint, no auth, no deployment — and reports errors (a parameter nothing binds; a caller-supplied value concatenated into SQL instead of bound; aCartTablechild with noInsertSql/EditSql;BEGIN TRANin a cart child, which the engine rejects; a cart or<WorkflowStartup>parent whoseInsertSqldoesn’t return the new key) and warnings (SELECT *, aSortBycolumn that is never projected, an unused<Parameter>, a query missing@SessionCompanyIdvia--require-tenant-scope, and constructs that won’t run on the other database via--dialect sqlserver|postgres|both). Errors fail the command;--strictfails on warnings too. Also available as thegenie_validate_sqlMCP tool — run it as a pre-flight beforeexecute-script. -
GenieClient can now do regression testing, not just smoke testing. Until now every check only asserted that a call succeeded —
test-viewpassed on “12 rows, 8 columns” whether the SQL returned the right rows, the wrong rows, or a renamed column. Four new commands close that:snapshotrecords golden files per object covering metadata, the first grid page, form and view values, and every lookup’s option set — so a silently widened, narrowed or leaky dataset is caught, which counting options never could.verify-snapshotre-captures and diffs, reporting which section drifted and which fields or rows changed. GUIDs, timestamps and the framework’s audit columns are masked automatically so baselines stay stable;--volatileadds more. A missing baseline fails rather than passing quietly, so “no baseline” never looks like “no regression”.run <manifest.xml>executes a whole<Suite>of objects and checks in one pass, with--tagfiltering,--parallelfor read-only objects, and--report junit|jsonfor CI.cleanupdeletes rows recorded by earlier runs.test-crudonly deletes what it created when add and verify succeeded, and--keepskips deletion by design, so rows used to leak with no record of what to remove; every created row is now journalled under a run id and removed in reverse creation order (children before parents).
All four are also MCP tools (
genie_snapshot,genie_verify_snapshot,genie_run_suite,genie_cleanup), andgenie_test_crud/genie_run_suitenow returnRunId+PendingRowsso an agent can clean up after itself.
Changed
Section titled “Changed”- The report endpoint moved to the root
/reportpath, a sibling of/filesrather than a child of/api/v1/genie. If you had linked to/api/v1/genie/report/{key}directly, update it — though the recommended target is now the/report-viewpage, which handles authentication for you. When your UI dev server and API run on different ports, add/reportto the dev proxy using a regex key ("^/report(/|$)") so it doesn’t also capture the/report-viewpage. - A report only ever sees its own parameters.
report,title,inlineanddownloadare reserved by the endpoint and the viewer, and are no longer forwarded to the report builder as report parameters. - The browser can no longer drive the workflow engine.
transitionWorkflow,workflowSubmitApprovalandstartWorkflowsBatchwere client-side actions: the server handed the row to the browser, which calledPOST /workflow/instances/…back. Those three are gone from the client registry along with the matching API-client methods, and the endpoints — previously reachable by any signed-in user with a guessed instance id, because only[Authorize]guarded them — now require the caller to be the instance’s assignee, a member of its assigned role, a pending approver, or holdSystem/Admin. They remain for programmatic and administrative callers. Authoring is unchanged: the sameSELECT 'functionName', …rows, executed in a place the caller can’t reach. - The transition and cancel endpoints take the acting user from the session.
UserIdis gone from both request bodies. It let a caller attribute a transition to anyone, and being a non-nullablelongit silently bound to0whenever omitted — which the UI always did, so every transition log recorded no actor at all. - Every declared editor field now reaches the submit SQL, with its default applied. Previously a
DefaultValuewas only used for a field the client sent blank; a field the caller omitted entirely got no default and no parameter at all, so a<SubmitSql>referencing@Fieldfailed with “must declare the scalar variable”.@Fieldis now always bound: on create, absent or blank ⇒DefaultValue; on edit, absent ⇒ the stored value, elseDefaultValue. See Default values. - Clearing a field that declares a
DefaultValuenow writesNULLon edit instead of silently restoring the default — previously such a field could never be blanked. Absent still means “unchanged”; only an explicitly cleared value is written as empty. - GenieClient’s tests now run in CI. The tool lived in its own solution, so
dotnet test src/Genie.slnxnever covered it. Both projects are now referenced from the main solution. - Trait timestamps are normalized to UTC on save.
CreatedAt,UpdatedAtandDeletedAtare rewritten to a+00:00offset as they pass throughGenieContext, including values supplied by your own host code. The instant is preserved — only the offset changes. On SQL Server, whosedatetimeoffsetcan hold a non-zero offset, previously-stored local offsets stay as they are but new writes read back as+00:00. A caller-suppliedCreatedAtis still respected (backdated imports and designer paths keep working); it is only normalized, never replaced.
-
Code-defined reports actually run now. Two independent wiring faults meant a host’s
[Report]class was never usable: the assembly scan filtered on assembly names containing “Genie” (so a host assembly was never scanned), and the half that registers report types in the service container was never called — leaving even a discovered report failing with “could not be instantiated”. Discovery now registers both halves together, over the entry assembly by default or whatevergenie.AddReports(...)declares. -
Generating a report no longer leaks a DI scope (and a
DbContext) per request. The registry created a scope it only disposed on the failure path. Report instantiation moved toReportService, which resolves the builder from the request scope — so a report now also shares the request’sDbContext, unit of work and session instead of getting a detached set.IReportRegistry.GetReportis gone; the registry is a metadata catalogue. -
A failed report returns the standard error envelope, with the right status code (an unknown key is a
404, not a400) and without listing every other registered report key in the message, which the old hand-rolledBadRequestdid. -
An expired session on a root-routed endpoint returns 401, not a login page. The cookie handler only treated
/apipaths as machine endpoints, so an unauthenticated request to/reportor/filesgot a 302 to/auth— whichfetchfollows transparently, handing the caller HTML with a 200 status. Those prefixes (and/hubs) now get a clean 401. -
A row action’s
{Column}token can no longer corrupt its own URL. Values were substituted raw, so a name containing&or#truncated the query string and invented parameters. Tokens in query position are percent-encoded; tokens in the path are unchanged. -
A UI callback returned from an import’s
<AfterSql>now runs. The import response carries a dispatch envelope like every other mutation, but the API client typed it as just a message string and dropped the rest — soSELECT 'showSuccess' FunctionName, …in anAfterSqldid nothing. (Server callbacks such asstartWorkflowwere unaffected: they execute in-request, before the response is built.) The import dialog now runs the envelope, and the grid still refreshes exactly once even when the envelope also asks for arefreshTable. -
The approval form is actually registered.
WF_ApprovalForm— the form every Approval node routes its task to by default — was never added to the view registry in either dialect branch, so the Tasks → approve click-through resolved to nothing. It is registered now, and picks its own dialect (it previously had SQL-Server-only SQL and no PostgreSQL variant). -
A Decision node’s default branch fires. The designer draws it as
Expression="Else", which the guard evaluator didn’t recognise — and since it returnsfalsefor anything it can’t parse, the edge was skipped and the instance parked on the Decision node.Elseis now a fallback literal alongsidetrue, so definitions already saved inWorkflow.Definitionsstart working with no migration. The engine also tries guarded edges before any fallback regardless ofOrder: every new edge is created withOrder = 0, so a drawnElsewould otherwise shadow the very conditions it was meant to fall back from. -
Workflow
ExecuteSqlaction payloads now run on PostgreSQL too, braces and all. They were executed through a SQL-Server-specific parameter type, so an Action node’s SQL failed on a PostgreSQL datasource, and literal{/}had to be worked around because the payload was treated as a format string. Action SQL now goes through the same provider-agnostic path as the new transition<Sql>: the same statement runs on both databases, braces need no escaping, and the command joins an ambient transaction when one is open. -
An optional field with a pattern no longer rejects an empty value. The regex check ran even when nothing was entered, so leaving an optional field blank returned 400. Patterns, bounds and
<Validate>rules now skip a blank value — demanding one isRequired’s job — and are skipped for fields the user can’t edit (hidden, disabled,Allow/Deny-locked,<Sequence>), so pre-existing bad data can’t wedge an unrelated edit. -
Numeric and date bounds are checked even when the field isn’t required. The server only range-checked a field that was literally
Required="true"and declared both bounds, so aMaxValue-only or optional field was unchecked. Each bound now applies on its own, andMinLength/MaxLengthare enforced server-side (they were UI-only). -
PostgreSQL hosts in a non-UTC time zone can start again. The engine stamped audit and domain timestamps with the machine’s local offset, and Npgsql rejects any non-UTC offset on a
timestamp with time zonecolumn. The first thing to hit it was the boot-time engine-SQL bootstrapper, so the app never started — and because the SQL batches ran before the failing write, every restart re-executed them and failed again. Roughly 70 call sites are now UTC. Beyond startup this also fixes account lockout, OTP issue/validate, password reset, push-subscription verification, email/SMS/in-app notification delivery, role-permission time windows and every workflow transition, all of which threw on the same hosts. If you were running withAppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true)as a workaround, you can now remove it. -
The seeded
systemadmin is no longer expired on arrival. ItsPasswordLastChangedAtwas a fixed date baked into the EF seed, so any deployment stood up more thanPasswordExpiryDays(default 180) later hit a forced password change on the very first login — which returns no tokens and so also dead-ended non-interactive clients such as GenieClient. The clock now starts at first boot, stamped by the same run-once step that encryptsSeedAdminPassword. A database seeded by an older version keeps the old date; see Configuration → secrets for the one-line reset. -
Import expressions
GETDATE()/SYSDATETIME()now yield UTC, so imported values no longer land offset by the server’s zone. -
Grid column filters on date/time columns no longer shift by the session offset. Picking a date in a column filter sent a bare
YYYY-MM-DD, which the database read as UTC midnight rather than the user’s — so at+05:00a “from 29 Jul” filter silently dropped everything before 05:00 on the 29th, and in a negative-offset zone it pulled in rows from the previous evening. The filter now sends the UTC instant at which the chosen day starts in the user’s effective zone (DST-correct). The filter box still displays the date that was picked. -
<BulkCopy>import is now fully supported on PostgreSQL. The binaryCOPYpath sent the parser’s raw strings, but — unlikeSqlBulkCopy, which converts against the destination’s metadata — binaryCOPYdecodes every field with the destination column’s binary format, so anynumeric,boolean,timestamptz,uuidordatecolumn failed. It also emitted unquoted column names, which PostgreSQL folds to lower case, so a"CreatedAt"column could not be addressed at all. The engine now reads the destination schema before copying, converts each cell to the column’s type, and addresses columns by the identifiers the table actually has (so oneColumns="…"list works for both a quoted-PascalCase table and a lower-cased staging table).TargetTablecan now point straight at a real typed table on either dialect — staging is an optimisation, no longer a workaround. Conversion is strict: a bad cell fails the import naming the row, column, value and expected type, rather than silently becomingNULL. See Import → BulkCopy on SQL Server vs PostgreSQL. -
SYSDATETIMEOFFSET()in a BulkCopy<Expression>now works. It was documented but never registered, so using it failed the import with “Unknown import expression function”. It returns the current UTC-offset value, fordatetimeoffset/timestamptzcolumns. -
An ambiguous decimal comma in an import file is now rejected.
1,5parsed as 15 (the comma read as a thousands separator), a silent tenfold error on price and quantity columns. Properly grouped values such as1,234.56are still accepted; malformed grouping now fails with a message naming the row and column.
Removed
Section titled “Removed”-
<WorkflowStartup>and<WorkflowTransition>are gone from view XML. Both are replaced by the dispatch callbacks above, which are strictly more capable: the condition is ordinary SQL rather than a six-operator expression grammar, the entity id is passed explicitly instead of being inferred from the first cell of the result, and a failure surfaces instead of being swallowed. A view still carrying either element now fails to parse, with a message naming the replacement — rather than silently doing nothing, which is the failure mode this whole change exists to remove. To migrate:<!-- OLD --><WorkflowStartup><Condition Expression="@Status == 'Submitted'">OrderApproval</Condition></WorkflowStartup>-- NEW, in the view's InsertSql/SubmitSqlIF @Status = 'Submitted'SELECT 'startWorkflow' FunctionName, 'OrderApproval', 'Orders', @NewId;
[v0.1.2] — 2026-07-27
Section titled “[v0.1.2] — 2026-07-27”- New
ViewActionattribute on<Table>/<Form>—Enabled(the default) orDisabled, controlling whether the built-in View button is offered on a row. It is independent ofDisableActions, so you can now author either combination: a read-only grid whose records can still be opened (DisableActions="true"on its own), or a queue whose rows are acted on only through their authored sub-view / row action (both switches off).
Changed
Section titled “Changed”DisableActionsis write-only in scope again — it hides Add New, edit, delete and bulk-delete, and no longer hides the View button. This corrects v0.1.1, which folded View into the same flag and left no way to keep a read-only grid inspectable. If you relied on v0.1.1’s behaviour, addViewAction="Disabled"alongsideDisableActions="true".
-
The workflow Tasks inbox (
wf-tasks) no longer shows a generic View button. It carried the built-in CRUD controls plus a View button that opened an empty record, since the view declares no editor fields; rows are now acted on solely through their activity button. (v0.1.1 announced this fix but only shipped the flag it needed.) -
Image attachment previews and row-action prompts opened from a sub-view grid no longer render behind the parent table’s sticky column header. Both dialogs are now lifted out of the sub-view panel, whose sticky positioning had trapped them underneath it regardless of stacking order.
[v0.1.1] — 2026-07-26
Section titled “[v0.1.1] — 2026-07-26”-
Model-authored dynamic task sub-views now preserve
ActivityTypeColumn. A project can define its own assigned-task table in XML and use one activity button for both form tasks andActivityType="Route"tasks; the view parser now carries that column through to the React model. -
DisableActionsnow hides every built-in CRUD control consistently. Compact tables no longer retain a generic View button, and create/edit/delete/bulk-delete controls are suppressed too. Explicitly authored row actions and sub-views remain available, so model-owned task queues can hide record CRUD while keeping their activity button.
[v0.1.0] — 2026-07-25
Section titled “[v0.1.0] — 2026-07-25”Changed
Section titled “Changed”-
Every engine-owned appsettings section now lives under the single
Genie:root.Storage,Notifications,Assistant,Security:Cors,DataProtection,ForwardedHeaders,RateLimiting,DiagnosticsAccess,OpenApi,AppSettings:DatasourceandStartupall moved in — so it is unambiguous which keys are the framework’s and which are yours. Any other root section is now safely your own: a host can keep its ownSecurity:PublicFormsorRateLimiting:PublicFormswithout overlapping the engine.ConnectionStrings,Serilog,Logging, andAllowedHostsstay at the root, where the platform and Serilog expect them. The full old → new table is in the configuration reference. -
The legacy paths are no longer read, and a stale appsettings now fails at startup instead of booting on defaults.
LoadFromConfigurationchecks for every pre-consolidation section and throws once, listing each stale path beside its replacement. This is the upgrade aid: if the app starts, the configuration migrated. Matching is on the exact section that moved, so a host’s own root sections are never flagged, and only sections carrying a real value are reported (empty leftovers are ignored). The check covers environment variables too — a__-separated legacy path such asAssistant__ApiKeyis reported and needs renaming wherever it is defined, including persisted developer-machine variables. -
Startup→Genie:Migration, andExecuteMigration→MigrationExecution(values unchanged:No/Yes/Forced). In code,ConfigureMigrations(m => m.MigrationExecution = …)and the enum is nowMigrationExecutionMode. -
Genie:Authis the only path for auth configuration. The scatteredSecurity,Authentication:MFA,Authentication:JwtSettings,Authentication:CookieName,AuthorizationandPasswordEncryptionsections previously bound first as back-compat; they are gone. Database password-encryption secrets now live atGenie:Auth:PasswordEncryption:{SeedAdminPassword, MasterKeyPassword, PasswordEncryptionKey}. -
The JWT file-system key path is a real option:
Genie:Auth:Jwt:KeyPath(wasAuthentication:JwtSettings:KeyPath), defaulting to%ProgramData%\Genie\jwt-keys.
-
Genie:Migration:ModelsPath— say where your model files are. Previously the models directory was convention-only, probed at three hardcoded locations (including a five-levels-up source-tree path) with no way to override it. Now: an absolute path is used as-is, and a relative path resolves against the app output directory first, then the content root — so"models"works both published and from a source checkout. A configured path that doesn’t exist is reported as an error and migration is skipped; it deliberately does not fall back to the conventional locations, because a typo silently migrating the wrong folder is worse than a visible stop. Leave it unset to keep the convention (models/next to the binaries, then../models). -
Genie:Notifications:VapidandGenie:OpenApiare documented in the configuration reference — both were readable before but absent from the docs.
Removed
Section titled “Removed”- The dev-only models probe
<appBase>/../../../../../models. It existed for running from inside a monorepo checkout;ModelsPathcovers that case explicitly now. Hosts that copy their model files to the build output (the documented shape) are unaffected.
[v0.0.8] — 2026-07-25
Section titled “[v0.0.8] — 2026-07-25”-
A successful save that shows a
showAlert 'success'now returns to where the form was opened from. A directed create/edit form only returned to its origin forshowSuccess/refreshTable/closeModaldispatches — but the common (and sample-taught) success patternSELECT 'showAlert', 'success', …was treated as “the dispatch owns the response”, so the form showed the confirmation and then stayed put. A success-severityshowAlert/swalis now recognized as a benign confirmation that still returns to origin; error/warning alerts still keep the form open so you can correct and resubmit. -
Row-action
Urltemplates now resolve{Id}in view (record) mode. A link row action likeUrl="/…/report?invoiceId={Id}"filled its{Id}(and other{Column}) tokens on the grid but not on the single-record view, because the read-only view doesn’t render the hidden primary key as a field. The record’s route parameters (which carry the primary key) now seed the token source, so the same template works in both places. -
A directed edit/create form now returns to where it was opened from on failure too, not only on success. Previously a failed save left you on the form; now the form (page or modal) returns to its origin and surfaces the error as a toast — which, being a 30s copyable error toast, is readable back on the list. (Validation you want to fix inline is still shown; the toast carries the message.)
-
A field that is hidden and required on the same condition now saves correctly when you flip that condition on edit. With the common pair
Hidden="@Status != 'Discontinued'"+Required="@Status == 'Discontinued'", changingStatustoDiscontinuedand filling the date in one edit previously failed with “Field is required” — the server judged the field hidden by the stored status (discarding the value), then required by the submitted status. Visibility (Hidden) andRequiredare now both evaluated against the submitted state; access locks (Disabled/Allow/Deny) still evaluate against the stored row so a driver can’t be flipped to dodge a lock.
-
<DataSet Type="search">— dataset shorthand that writes the search/paging boilerplate for you. A dataset-backed<Select>(lookup pickers, ModalSelector cart columns, grid filters) no longer needs the repetitive resolve +@SearchText+@CurrentValue+ORDER BY … OFFSET/FETCHplumbing. Give it just the core query —SELECT … AS Value, … AS Label … FROM … WHERE <base>— plusSearch="Label,…"(columns to type-search, defaults toLabel) and optionalOrderBy, and the engine wraps it with label-resolution, server-side search, ordering, and dialect-correct pagination (SQL Server / PostgreSQL). The existingType="sql"remains as the verbatim, full-control form. -
View-backed
<CartTable>— line items now live in their own child view. A cart can be bound to a child view with<CartTable Name="Lines" View="OrderLines" ParentKey="OrderId" />. The child view owns the cart’s columns (inherited from its<EditorFields>— no more redeclaring them on the parent) and its persistence (its own set-basedOPENJSON<InsertSql>/<EditSql>). The cart loads its rows from the child view’s table endpoint (scoped by@Parent__Id), and on parent submit the engine writes every cart through its child view’s SQL inside one transaction, so the header and all its line items commit or roll back together (atomic). A form may declare more than one cart. This replaces the old pattern of hand-writing aFOR JSON PATHpre-fill in the parent<Sql>and mirroringOPENJSONexpansion in the parent’s Insert/Edit SQL; the local-JSON<CartTable>(inline<Columns>, noView) is unchanged and still supported. -
CartTable
ModalSelectorcolumns now search server-side and scroll-paginate. A dataset-backedModalSelectorcart column no longer preloads the whole dataset — its picker queries the field- dataset endpoint as you type (debounced) and loads one page at a time, appending the next page on scroll. Two new always-injected SQL parameters,@PageSizeand@PageOffset, let a dataset page a large table (OFFSET/FETCHon SQL Server,LIMIT/OFFSETon PostgreSQL); a dataset that ignores them is unchanged, and label-resolution requests are never paged. This makes a product/line- item picker over a large catalog fast instead of loading every row. -
Error toasts stay up longer and can be copied. A toast raised with
icon: "error"now lasts 30 seconds (vs 3), shows a close (×) button, and shows a Copy button that puts the message on the clipboard; hovering the toast pauses the auto-dismiss. Non-error toasts are unchanged. Makes a server/validation error readable and shareable instead of vanishing after a few seconds.
Changed
Section titled “Changed”- Raw view SQL now enrolls in the ambient transaction when one is open. When the engine wraps a
submit in a transaction (a view-backed cart is present), the parent write and each cart’s child
write share it. View SQL executed outside a transaction is unaffected. Child cart SQL must be
transaction-free (no
BEGIN TRAN) — the engine owns the transaction; a nested one is rejected with a clear error.
[v0.0.7] — 2026-07-24
Section titled “[v0.0.7] — 2026-07-24”- The docs site now publishes itself as LLM context. Every docs build emits
/llms.txt(an index of the documentation),/llms-full.txt(the entire corpus concatenated as clean markdown), and/llms-small.txt(a condensed variant for smaller context windows), following the llms.txt convention. AI agents working on Genie can load the full framework documentation — XML contract, endpoints, security model, host wiring — in a single fetch.
- Updating a view via
execute-scriptnow takes effect immediately — no app restart needed. The resolved-schema cache is keyed by whichever alias a view is addressed by (its Name or its Slug — the React UI addresses views by slug), but re-pushing a view only evicted the Name alias, so a slug-addressed schema stayed stale until the cache TTL (30 min) or a restart. A view re-push now evicts both its Name and Slug cache entries.
[v0.0.6] — 2026-07-24
Section titled “[v0.0.6] — 2026-07-24”- Cart columns support dependent option filtering via
FilterBy. A dataset-backed<Select>inside a<CartTable>can declareFilterBy="{SiblingColumn}": each row’s options are filtered (client-side, over the already-loaded dataset) to rows whose same-named dataset extra column equals that row’s sibling cell value — e.g. a Category cell filtering the Item picker per line. An empty sibling shows all options, and the row’s current selection is never filtered away. sumLinesgains a single-column form.sumLines(cart, 'Column')sums one cart column (the fit for per-line computed amounts likeLineAmount); the existing two-key qty×price form is unchanged.
- The browser tab title now reflects the current view. As you navigate,
document.titleupdates to the active view’sCoalesce(Label, Name)(its friendly Label, falling back to the humanized name/slug until the model loads) — covering Genie object views (table/form/view), the wizard runner, and the workflow & wizard designers, plus the built-in tool pages (Background Jobs, Access Control, Object Explorer, Hosted Services). Previously the tab kept the host’s static title on every page.
[v0.0.5] — 2026-07-23
Section titled “[v0.0.5] — 2026-07-23”- Task actions honor
ActivityFormStyleandActivityType="Route"in the React shell. Dynamic activity sub-views (thewf-tasksinbox row action) now forwardActivityTypeColumn/FormStyleColumnthrough the table schema (SubViewSchema→buildTableModel→SubView), andGenieTableroutes each click per row:Routenavigates to theActivityRouteURL,Redirectedopens the form full-page, andModal(the engine default for tasks) opens theActivityRouteform in aGenieFormModalwithParent__*row params — matching the server-side contract instead of always rendering an inline accordion panel. Ordinary sub-views (no routing columns) keep the inline panel. - GenieClient supports workspaces — multiple clients/agents can use it simultaneously. A workspace
is derived automatically from the endpoint URL (no naming or setup): each deployment gets isolated
token storage under
workspaces/<slug>/, so concurrent CLI processes and MCP servers never clobber each other’s credentials. A singlemcp --transport httpserver now multiplexes many deployments — every authenticated MCP tool takes an optionalendpoint(omit it for the server’sGENIE_ENDPOINTdefault), and each call runs in an isolated scope so concurrent calls to different endpoints don’t cross tokens. New CLI verbsworkspaces(list) andworkspace-forget <endpoint>(clear a stored token). Back-compat: the previous singletoken.dat/config.jsonlayout is replaced by per-endpoint workspace directories — runauthonce to re-establish your endpoint’s token.
- GenieClient CLI: every command failed with “No active workspace”.
GenieSession.ResolveEndpointAsyncsetWorkspaceContext.Current(anAsyncLocal) inside its own async state machine, so the assignment was reverted when the method returned and the command handler’s subsequent API calls saw no ambient workspace. The method now resolves synchronously and mutates the caller’s execution context. (The MCP path was unaffected — it sets the workspace inline in each tool call.) - Unblocked the npm pipeline: bumped two dependencies flagged by the audit gate.
dompurify3.4.11→3.4.12(GHSA-c2j3-45gr-mqc4) and the transitivefast-urito3.1.4(GHSA-v2hh-gcrm-f6hx / GHSA-4c8g-83qw-93j6), refreshed in thegenie-engine-uilockfile sonpm audit --audit-level=highpasses again and tagged releases can publish.
[v0.0.4] — 2026-07-21
Section titled “[v0.0.4] — 2026-07-21”- The wizard & workflow designers get a selection mode for moving multiple nodes at once. Toggle it with the new button beside the zoom controls: drag on empty canvas to rubber-band a marquee that selects every node it touches (Shift+click adds/removes individual nodes), then grab any selected node to drag the whole group together. Delete removes them all at once. In selection mode the mouse wheel pans the canvas (Figma-style; Shift scrolls horizontally, Ctrl/⌘+wheel still zooms), and a middle-mouse (scroll-wheel) drag grabs and pans the whole frame from anywhere.
- The wizard & workflow designers now have a canvas search box. Type in the toolbar search field (beside the XML button) to find any node — or, in the wizard designer, any field — by its name or label. Matching is debounced; matched text is highlighted in yellow, matched nodes get a highlight ring so they’re findable when zoomed out, and a live count sits beside the box. Press Enter to hop through the matches one at a time (Shift+Enter to go back): the canvas centres on each hit and marks the current one in a distinct colour.
- GenieClient can now export & import RBAC, Workflows, and Wizards as XML (CLI verbs +
MCP tools). Export writes
rbac.xml/<Name>.workflow.xml/<Name>.wizard.xmlto a directory;--against <models-dir>verifies each export against your source-of-truth file (a semantic diff for RBAC, a canonical-XML compare for workflows/wizards). Import merges them back. New verbs:export-rbac,import-rbac,export-workflows,import-workflow,export-wizards,import-wizard(and matchinggenie_export_*/genie_import_*MCP tools). - New endpoint
GET /api/v1/genie/wizard/list— lists every wizard (id/name/slug/label) so tooling can enumerate wizards for export. - GenieClient — a CLI + MCP client for testing and operating a Genie deployment
(
tools/GenieClient). Authenticate with a System-role account, then run model/SQL scripts (execute-script), import seed data mapped by aseed-models.xmlfile (<Map ObjectName="" ImportFile="" />), and verify views (test-view), CRUD with runtime fake data (test-crud, using Bogus — lookups resolve to real options), imports (--verifyre-queries the list endpoint), exports (export, downloads the file and checks rows), and relations (test-relations— lookups, sub-views, and reverse references). It calls the current object API (metadata+object/{name}/{table,form,view}+submit/delete-row/import-data/export-data), not the legacy render endpoints. The same tool runs as an MCP server (mcp, stdio or HTTP/SSE) so an external LLM (Claude Code, Codex) can drive every capability through structured tools — credentials viaGENIE_ENDPOINT/GENIE_USER/GENIE_PASSWORD. See Tooling → GenieClient.
Changed
Section titled “Changed”- The wizard & workflow designers zoom out much further (down to 5%, was 30%) and zoom smoothly. The mouse wheel now steps zoom multiplicatively (Figma-style) instead of in fixed 10% jumps, so large flows can be zoomed right out to fit on screen.
POST /api/v1/genie/auth/import-rbacgains an opt-in full-sync mode (?prune=true). By default the import is unchanged (upsert permissions/roles/attributes; fully sync each named role’s grants). Withprune=trueit also soft-deletes orphan roles, permissions and attributes absent from the file — guarding the seededSystem/Admin/AccessManagerroles and any role that still has active user assignments (reported, not deleted), all within one transaction — and returns deletion counts in the summary. GenieClient’simport-rbacuses prune by default (--no-pruneto opt out). Also corrected the stale “additive — never deletes” doc comments onRbacSyncService/import-rbac, which never matched the actual per-role grant deletion.
- The wizard and workflow designers no longer lag on large flows. Dragging or panning a graph with hundreds of nodes/links (e.g. a ~285-node checklist) was re-rendering the entire canvas on every mouse frame and rescanning all links/transitions against all nodes. The canvas nodes and sources are now memoized and the node lookup is indexed, so moving one node only re-renders that node and the links attached to it.
[v0.0.3] — 2026-07-21
Section titled “[v0.0.3] — 2026-07-21”Changed
Section titled “Changed”- The navbar loads much faster on large menus: badge counts are now fetched separately from the
tree.
GET /api/v1/genie/navbarno longer runs any badge SQL — it returns the permission-filtered tree immediately regardless of how many items carry badges. A newGET /api/v1/genie/navbar/badgescomputes the badge values (counts / status dots), running each link’s query concurrently with session parameters, and the UI merges them onto the tree by item name once they arrive. Previously every badge ran a separate query sequentially while building the tree, so a navbar with many badged items could take ~2s to return; now the tree is instant and the counts fill in a moment later. Badge behaviour, colours, and the “a failing badge query never breaks the navbar” guarantee are unchanged.
- The module rail now scrolls instead of clipping when there are many modules, and taps behave correctly on mobile. The rail is split into three parts of one bar — a pinned logo header, a scrollable middle list of module buttons (with a thin scrollbar), and a pinned profile — so extra modules no longer overflow out of bounds. On mobile, tapping a grouped module now keeps the off-canvas drawer open and just switches which nav items the sidebar shows (only selecting a nav item, or a direct-link module, auto-closes the drawer). The rail is also slightly wider (70px) with a larger, more readable module label.
- Releases can publish again: patched two dependencies that were failing the pipeline’s security
gates.
Microsoft.AspNetCore.DataProtection.StackExchangeRedisis bumped10.0.9→10.0.10, which resolves the transitiveSystem.Security.Cryptography.Xmlto the patched10.0.10(clearing four High advisories, e.g. GHSA-cvvh-rhrc-wg4q), and thegenie-engine-uilockfile is refreshed to a patchedbrace-expansion(GHSA-3jxr-9vmj-r5cp). The build-and-test workflows’ vulnerability scans now pass, so a version tag once again publishes the NuGet and npm packages.
[v0.0.2] — 2026-07-20
Section titled “[v0.0.2] — 2026-07-20”- Generated EF configurations now declare their database triggers. Entities that carry
Genie-managed triggers — change logging (
Logging), concurrency (Concurrency), sequence numbering (aSequencefield), or global search (Search) — now emitHasTriggerin their generated EF Core configuration. Previously a host doingcontext.Add(...)/Attach(...)+SaveChanges()on such an entity failed on SQL Server (a table withAFTERtriggers rejects EF’s defaultOUTPUT-clause save path); EF now uses a trigger-safe save strategy automatically. The trigger set is computed from the same flags that drive trigger creation, so hosts can also introspect it viacontext.Model.FindEntityType(typeof(T))!.GetDeclaredTriggers().
[v0.0.1] — 2026-07-19
Section titled “[v0.0.1] — 2026-07-19”Initial release of Genie under Orbyn Technologies. Genie is a low-code framework where XML model
definitions become model-driven tables and forms: the backend (Genie.Engine) returns data +
metadata as JSON and the React UI (@orbyn-technologies/genie-engine-ui) owns all rendering.
- XML model contract.
*.model.xml,*.table.xml,*.form.xml, and*.navbar.xmldefine entities, views, fields, layout, datasets, cart, sub-views, and the navbar. A Roslyn source generator and strongly-typed schema (Genie.Source) parse and validate the contract at build time. - Unified Object model + API. A single
ObjectViewdrives both grids and forms, served through a metadata/values split:GET /metadata/{name}(SQL-free structure),.../form|/view(layout blueprint), andPOST /object/{values,query,submit,delete-row,execute-row-action,export-data,import-data,field-dataset,sequence-number,upload}for data and mutations. All errors flow through a consistent{ success, error, traceId }envelope. - Identity & auth. JWT (RS256) + refresh tokens, cookie auth, MFA/TOTP, password encryption, and session management, with the signing keypair DataProtection-encrypted in Redis.
- RBAC + row/field security. View / Create / Update / Delete verbs re-validated on every operation,
a
ParameterSanitizerallowlist that guards mass-assignment and cross-tenant injection, permission attributes for resource-level actions, row-level security expressions, and server-enforced field-level security. - Multi-tenant company scoping. System / Admin / AccessManager roles with SQL-enforced company scope (assigned companies + descendants).
- Workflow & Wizard engines. Form-driven workflow start/transition with approvals, flow versioning + history, and company-scoped definitions.
- Platform features. Change logging / audit (per-entity triggers into
[Audit].[AuditLog]), sequence generation, notifications (email/SMS/push + SignalR), the Assistant (DB-backed multi-chat LLM), reports, navigation/navbar, concurrency (RowVersion → 409) + idempotency keys, timezone handling, and optional performance logging. - React UI (
@orbyn-technologies/genie-engine-ui). Configurable app viacreateGenieApp({...}): theme, layout, auth screens,GenieTable/GenieForm/GenieView, wizard, chat, an eval-free expression engine (required/disabled/hidden/value rules), and rich cell renderers. - Dual database. SQL Server and PostgreSQL, selected by
AppSettings:Datasource; hardcoded system views ship both dialects. - Packaging & CI. Published to GitHub Packages under
Orbyn-Technologies—Genie.SourceandGenie.Engine(NuGet) and@orbyn-technologies/genie-engine-ui(npm) — built and released onv*tags by self-hosted Linux runners. - Sample app & docs. The Inventory reference app (
sample/Inventory) and this Starlight documentation site.