# Changelog All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- ## [Unreleased] ## [6.17.1] — 2026-11-08 **Issues fixed:** #244 (most questions got a `PersianMonthName = N'دی'` filter they never asked for); #237 (a data source variable missing from `.env` stopped the setup tools with no reason given). ### Fixed - **A month, weekday or season name is found only as a whole word (`retrieval/value_retriever.py`) (PR #245).** Most questions got a `WHERE [d].[PersianMonthName] = N'دی'` filter they never asked for. The month, weekday and season names were looked for as a substring of the question with every space removed, so the two letters of «دی» were found inside ordinary words («جدیدترین», «تولیدی», «موجودی», «دیگر», «ردیف») and across word joins («تعداد یک» reads «تعدادیک» once the spaces go), «تیر» inside «تیرآهن», «آذر» inside «آذربایجان» and «مهر» inside «مهربان». A name now matches only when no letter is directly before or after it. Every spelling that named a real month, weekday or season still matches: spaces inside the name («پنج شنبه», «اردی بهشت»), ZWNJ («سه‌شنبه», «دی‌ماه»), «ماه» attached or not («دیماه», «دی ماه», «اسفندماه»), and digits right after it («اسفند۱۴۰۲»). Over the 2,346 Persian strings in the repository (example questions, golden cases, fixtures), the old and new matching differ on 39, and every one of the 39 was a false month filter that is now gone; no string lost a month, weekday or season it had matched before. The warehouse-vocabulary matcher was not affected: it ignores values shorter than three letters. - **A data source variable missing from `.env` now says why (`core/dotenv_check.py`, `config.py`, `database/datasources.py`, `database/setup_sources.py`) (PR #241).** The setup tools (`python -m database.schema_inspector_cli` and the other tools that read `datasources.yaml`) stopped with only `environment variable Auction_DM_PASSWORD (named by password_env) is not set or is empty`, with nothing to show which `.env` was read or what was wrong with it. The message for a variable named by `username_env` or `password_env`, and for a `url_env` variable or `DB_CONNECTION_URL` that is unset or empty, now adds the reason, without printing any value (only paths, line numbers and variable names): which `.env` file was loaded, or that none was found, naming the folder the search really started from (the folder of `config.py`, or the working directory when python-dotenv searches from there: interactive, under a debugger or tracer, or frozen) and a `.env.txt` in it or above it as the likely cause, since a file manager that hides extensions saves that name; that `PYTHON_DOTENV_DISABLED` is set; a line python-dotenv could not parse; the same name written with different letters (`AUCTION_DM_PASSWORD` for `Auction_DM_PASSWORD`, where names are case-sensitive; on Windows they are not, so such a line is read as the variable itself); a name that is not in the file; a name present with an empty or blank value, or with a `${...}` reference that expands to an empty value when the variable it names is not set; a name that is in the file but not in the process environment (the process started before the file was edited, or another `.env` was loaded); an empty or blank variable in the process environment, which python-dotenv never overrides, reported first and also when there is no `.env` or the file does not assign it, with how to remove it (PowerShell and bash); and python-dotenv not being installed in the Python environment that runs the tool, in which case `.env` is not read at all and the message names that interpreter and says to install the requirements into it (`pip install -r requirements.lock`). `Settings.validate()` (so server start-up and the preflight) gives the same explanation for the same cases (a `url_env` value or `DB_CONNECTION_URL` that is set but wrong, such as a placeholder or the wrong dialect, is reported as before). The explanation never replaces the real error: if building it fails, the original message is raised unchanged. The errors for an unset variable are now `UnsetVariableError` (a `ValueError` subclass, same message). One more change outside the message: when python-dotenv is installed but `core/dotenv_check.py` cannot be imported, `.env` is still loaded (only the scan is skipped); before, nothing was loaded. No other setting is read differently. ### Documentation - **The design documents describe 6.17 (`docs/design/DESIGN.md`, `docs/design/DESIGN-INVARIANTS.md`, `docs/design/RETRIEVAL.md`, `docs/admin-panel-architecture.md`) (PR #236).** - **Turn anatomy (`DESIGN.md` §5.2).** It gains the rating footer and the empty transcript's start questions. The resolved question is shown only when it differs from the question. The interpretation is listed in the main flow, where the code always put it (`DESIGN-INVARIANTS.md` §9 notes the correction). - **Decision log.** New entries D15 (both ratings on the answer), D16 (admin views), D17 (start questions) and D18 (Persian dates). - **`RETRIEVAL.md` §12.** It records the `ring_aliases` rule of 6.16.3. - **The admin architecture §3.2.** It describes the grouped views. - **README (PR #236).** The feature table names the thirteen admin sections in their views, both ratings, the start questions and the filter-value fixes. - **The GitHub Pages site (`site/`) (PR #236).** - **Three new cards in all five languages:** filter values spelled as the warehouse stores them, ratings and start questions, and the admin panel. - **The quick start** installs `requirements.lock` and ends with the preflight and `uvicorn` instead of the REPL. - **Phone widths.** The page no longer scrolls sideways there: the hero's glow reached 2 px past the edge. ### Upgrading - **Restart the server; nothing to configure (PR #245, PR #241).** No setting is renamed or added, no file format changes, `requirements.lock` has no new pin (`git diff v6.17.0 -- requirements.lock` is empty), and the preflight has the same checks. The answer cache is in memory and starts empty on every restart, so no answer computed with a false month filter outlives the upgrade. - **Some answers change, as they should (PR #245).** A question that named no month but contained «دی», «تیر», «آذر» or «مهر» inside another word («جدیدترین», «تولیدی», «تعداد یک», «تیرآهن») was filtered to that month; it now covers the whole period it asks for, so its figures are larger. A question that names a month («دی ماه ۱۴۰۳», «اسفندماه») is filtered exactly as before. One spelling is still read as a month: «تیر آهن» written with a space, where «تیر» is a separate word. - **The setup tools explain a missing `.env` variable (PR #241).** If `python -m database.schema_inspector_cli` or the preflight stopped with `environment variable … is not set or is empty`, the message now adds why; it never prints a value. ## [6.17.0] — 2026-10-30 **Issues fixed:** none were open for these; they are usability work on both pages, asked for directly: the admin panel shows one view at a time with readable Persian dates, and an empty conversation offers the deployment's own example questions. ### Added - **An empty conversation offers questions to start with (`GET /v2/examples`, `core/start_examples.py`, `web/js/main.js`) (PR #234).** Under «برای شروع، پرسش خود را در پایین بنویسید» the analyst page now shows up to six of the deployment's `examples.yaml` questions as chips («یا یکی از این‌ها را بپرسید:»); a click asks it. The new route returns question text only, never SQL or tags, and offers a question only when its SQL passes the guard with the caller's `denied_columns`: an example over a column the analyst may not read is not suggested to them, and one the guard refuses for any other reason (a write, an unknown table) is suggested to nobody. With no `examples.yaml`, no valid example, an older server or an error, the empty state is exactly as before. The demo mode's sample stories are unchanged. - **The admin panel shows one view at a time (`web/admin/views.js`, `web/admin/main.js`, `web/admin/admin.css`) (PR #232).** Thirteen sections and the summary rail made one page about 6,800 px tall on a laptop screen. The sticky nav now opens with five buttons: «وضعیت» (maintenance, deployment checks), «کیفیت و استفاده» (audit, feedback, quality, usage), «دسترسی و کلیدها» (access requests, keys, failed authentication), «داده و پیکربندی» (cache, configuration, schema drift, vocabulary) and «همه», the page as it was. Only the chosen view's sections and jump links are shown; the summary rail is shown in every view. The panel opens on «وضعیت» and then on the view last chosen in that browser. A link to a section (`#sec-keys`, a bookmark) opens the view that holds it, and each summary-rail item now opens its section. A view that needs attention (a failed check, maintenance on, open feedback) has a dot on its button and the words «نیاز به توجه» for screen readers. Sections are hidden, not moved or unloaded, so every card loads and refreshes exactly as before; if setting the views up fails, every section stays shown. ### Changed - **A turn no longer repeats the question as «برداشت سامانه از پرسش» (`web/js/render/turn.js`) (PR #234).** The box is shown when the resolved question differs from the question (a follow-up rewritten as a full question), and left out when it is the same text, spacing aside: it was the same sentence twice under the question bubble. - **Admin dates are Persian calendar dates (`web/admin/time.js`, `web/admin/main.js`, `web/admin/access-requests.js`) (PR #232).** The audit range, the baseline date, the vocabulary card's `fetched_at` and an access request's creation time were printed as raw ISO strings (`2026-10-27T10:46:31.435668 → …`); they now read «از ۵ آبان ۱۴۰۵، ۱۰:۴۶ تا …», and the exact ISO string is the element's title (shown on hover). A string without an offset, as the audit log writes it, is shown on the browser's clock, which is the server's when the panel is opened where it runs. A value that is not a date is shown unchanged. - **The deployment-checks card leads with what needs reading (`web/admin/main.js`, `web/admin/admin.css`) (PR #232).** FAIL and SKIP checks are cards, as before; the passed checks, usually most of them, fold into one line («۱۷ بررسی موفق») that opens to the same cards, so a PASS detail such as an `advice:` is one click away. The cache-age line no longer takes a cell of the card grid. - **The summary rail writes «۲ از ۲۱» (`web/admin/main.js`) (PR #232).** «2 / 21» was laid out right to left in the Persian card and read as «21 / 2»; every rail figure now uses Persian digits, like the rest of the panel. ### Fixed - **The admin top bar's backend-address field has its style back (`web/admin/admin.css`) (PR #232).** Its rules left `style.css` together with the analyst page's copy of the field, so the admin field and its «اتصال» button showed in the browser's default look; they now match the key controls beside them. - **A race between two LLM warm-up tests (`tests/test_llm_warmup.py`) (PR #233).** The startup test released its blocked warm-up and ended without waiting for the thread, which still held the warm-up lock when the next test's warm-up started; on a slow runner that one sent nothing and failed. The module now waits for every warm-up thread a test started. Tests only; no product code changed. ### Upgrading - **Reload the page after the upgrade (PR #232, PR #234).** The analyst page's and the admin panel's scripts and stylesheets are not versioned, so a browser that cached them keeps the old ones until a hard reload (Ctrl+F5). - **Optional: make the start questions worth clicking (PR #234).** The empty conversation offers the `examples.yaml` questions whose SQL the guard accepts for that analyst. A question whose SQL names a table `schema.yaml` does not have (or a qualified column it does not list for that table) is offered to nobody, so the template's placeholder examples show nothing, and the empty state then looks as before. The questions are shown in file order, so put the ones a new analyst should see first at the top. - **Nothing else changes.** No setting is renamed or added, no file format changes, `requirements.lock` has no new pin, and the preflight has the same number of checks. `GET /v2/examples` is a new read-only route behind the same key as every v2 route; the admin panel's sections, their ids and their refresh are as before, with «همه» showing the page as it was. ## [6.16.4] — 2026-10-29 **Issues fixed:** #223 (an «incorrect» button beside «پاسخ درست است»); #219 part 2 (after a clarification is answered, the page stays where it was while the new answer appears at the foot of the conversation). #219 part 1 (`Ring A`, `Ring B` offered as halls) is the template `session_policy.yaml`; `python scripts/draft_policies.py` (6.16.1) fills it from the real values. ### Added - **«پاسخ نادرست است» on the answer, beside «پاسخ درست است» (`web/js/render/turn.js`, `web/js/render/feedback.js`, `web/styles/style.css`) (PR #230).** The wrong-answer flag («این عدد درست نیست؟») was only in the collapsed «جزئیات» drawer, so it was not found next to the confirmation. The new button in the result footer sends nothing itself: it opens the drawer and the flag form, puts focus on the category list and brings the form into view; the report is sent from that form, with the same categories, note and endpoint as before. Once sent, the button reads «✓ گزارش شد» (text and a critical border, not colour alone), and a later confirmation that replaces the report clears it, as it already reopened the form. It appears on every answer that has a result and a flag control (the confirmation's own rule is unchanged); on a narrow screen the two buttons wrap. The drawer's control is where it was. ### Fixed - **After a clarification is answered (or an assumption edited) in live mode, the page moves to the new answer (`web/js/main.js`) (PR #230).** `PATCH .../assumptions` re-asks the question and the server answers with a new turn under its own `turn_id`; the page appended its card at the foot of the conversation but did not scroll, so the answer appeared out of sight. `rerenderTurn` now scrolls to the new card's result (to the card when it has none), as a streamed answer does once it settles. A turn that already has a card is still replaced in place, without a scroll. The page also keeps the record of the turn that was re-asked (its card stays on the page) and adds the new turn after it; before, the new turn was written over it. ### Upgrading - **Reload the page after the upgrade (PR #230).** The web interface's script and stylesheet are not versioned, so a browser that cached them keeps the old ones until a hard reload (Ctrl+F5). Nothing else changes: no setting is renamed or added, no endpoint or file format changes, `requirements.lock` has no new pin, and the preflight has the same number of checks. ## [6.16.3] — 2026-10-29 **Issues fixed:** the root cause of #206 and #207 (a hall named in the question was filtered on a table name, `N'Ring'`, because the wizard had written table keys into `ring_aliases`); a deployment whose `aliases.yaml` came from the wizard must also restore its ring names (see Upgrading). `ring_aliases` in `aliases.yaml` maps the **exact stored value** of the ring dimension's name column to the forms analysts type for it; `ValueRetriever.extract_ring` finds a form in the question and the key becomes the ring filter (the session's default scope, `WHERE r.[Name] = N''`). The setup wizard of 6.13 to 6.16.2 wrote one entry per **table** there instead (key = the table's key, values = the table's aliases): on one deployment 75 entries keyed by table. A question that named a currency filtered the ring by `N'Currency'`, one that asked for the halls by `N'Ring'`, a trade question by `N'Schema.Trade'`, and the answer was empty; the preflight passed that file (`Aliases are curated: 0 synonym key(s), 75 ring alias(es)`). The same deployment's earlier curated file (real ring names with their forms) gave the right exact names for the same questions. A file whose keys are real ring names behaves exactly as before (pinned by `tests/test_ring_aliases_pins.py`, written before the change and unchanged since). ### Fixed - **A `ring_aliases` entry keyed by a table is ignored when `aliases.yaml` loads (`core/ring_names.py`, `knowledge/aliases.py`) (PR #228).** An entry whose key is, as a whole and with letter case ignored, a key of `schema.yaml`'s `tables` is left out of `RING_ALIASES`, and one `WARNING` names how many and which (at most ten, then `and N more`); no alias is logged. Only that exact match counts: a ring called `North Hall` is not touched because a table is called `Hall`, and a bare name that merely resolves to a table key (`Customer` for `sales.Customer`) is not a match either. When `schema.yaml` is absent or does not parse, or the guard fails for any reason, every entry is used as written, as before; when no key is a table key the very same mapping is returned. - **The setup wizard no longer writes a table's aliases into `ring_aliases` (`setup_project.py`, `core/wizard_drafts.py`) (PR #228).** Step 3 keeps a table's aliases in `entities.yaml`, where it always also wrote them and where retrieval reads them, and leaves `ring_aliases` as it found it (`{}` on a first run). When it merges into an earlier `aliases.yaml` it drops the entries whose key names a table (a `schema.yaml` key, a bare name that finds a key, or a `Schema.Table` spelling of a table keyed differently) of this run, of the earlier `entities.yaml` or of the output folder's `schema.yaml`; the aliases of a dropped entry are added to that table's entity (an entity is made for a table that had none), and the summary and `.setup_log.json` (`step6_write.dropped_table_rings`: `count`, `keys`, `aliases_added_to_entities`, `entities_created`, `unplaced`) give the count and the keys, which are table names. Every other entry, a ring name, is not touched, whatever words it shares with a table. A `--resume` no longer reuses the table-keyed copy a log of 6.16.2 holds. - **The preflight check `Aliases are curated` fails on a `ring_aliases` key that is a `schema.yaml` table key (`scripts/verify_deployment.py`) (PR #228).** `N of M ring_aliases key(s) are keys of schema.yaml tables: ...` (up to ten keys, then `and N more`), the exact entries the application ignores, with the way out. The check count is unchanged. When the running application has cached the ring dimension's values (the admin panel's card; the command line has no cache) the `[PASS]` detail adds `advice:` with the number of keys that are not among them, as a count only. The advice of `Entities match schema keys` no longer tells you to give `ring_aliases` the same keys as the entities. ### Added - **`scripts/draft_policies.py --ring-aliases` drafts `ring_aliases` from the stored ring values (`scripts/draft_policies.py`, `core/policy_drafts.py`) (PR #228).** It adds, to a copy of `aliases.yaml` written to `project_config_draft/`, an entry for each stored value of the scope column that has none: the key is the exact value, the forms are the value itself and, with `--generic-word WORD` (repeatable), the value without that leading word when a whole word is left and no other value gives the same form. New entries are written longest value first (the first entry with a form in the question wins). Existing entries, `synonyms`, comments and line endings are kept byte for byte, the result is loaded with the application's validator and compared with the input before anything is written, and an inline `ring_aliases: {...}` is refused, not rewritten. It prints counts, including existing keys that are table keys (left as they are) and existing keys that are not among the stored values. Nothing changes without the option. ### Changed - **The wizard's `ring_aliases` is empty (PR #228).** The six assertions in `tests/test_setup_wizard_output.py` and `tests/test_setup_wizard_drafts.py` that expected a table-keyed `ring_aliases` from a run now expect none. ### Upgrading - **If your `aliases.yaml` was written by the wizard of 6.13 to 6.16.2, do this (PR #228).** On start the log has `aliases.yaml: N ring_aliases key(s) are keys of schema.yaml tables and are ignored` and `python scripts/verify_deployment.py` fails `Aliases are curated` with the same count. The application already ignores those entries, but a hall you want recognised needs its own entry. Either restore the curated ring names from a backup, or run `python setup_project.py` again over the folder that holds the file (`--output`; with `--overwrite-live` for the live folder, which backs it up first): it drops the table-keyed entries, keeps their aliases in `entities.yaml` and reports the count and the keys. Then add the real ring names, by hand or with `python scripts/draft_policies.py --ring-aliases [--generic-word WORD]` (read the forms, then copy the file across), and run the preflight again. - **A file whose `ring_aliases` keys are real ring names is not changed at run time, and the preflight still passes it (PR #228).** The one case that differs: a ring whose stored name is exactly, letter case aside, a key of `schema.yaml`. The rule cannot tell it from the wizard's entry, so it is ignored and the preflight fails. There is no setting that switches the guard off; if you have such a ring, report it on the issue tracker. - **Nothing else changes (PR #228).** No setting is renamed, no file format changes, `requirements.lock` has no new pin, and the preflight has the same number of checks. ## [6.16.2] — 2026-10-29 **Issues fixed:** #214 (an attached plural such as «تالارها» was not recognised), #218 (a filter value written with Persian ی/ک found nothing when the column stores Arabic ي/ك). Two reports of the same kind: the question and the model write Persian text, and the configuration and the warehouse hold it in a slightly different spelling. A statement filtered on a hall name returned nothing because the warehouse stores `ي`/`ك` (Arabic) where the model writes `ی`/`ک` (Persian), and a question that wrote a plural attached to its word (`تالارها`) did not find what `تالار` or `تالار ها` found. Neither change removes anything a request does today: the first acts only on a literal the column does not contain, the second only adds a term (pinned by `tests/test_filter_spelling_pins.py` and `tests/test_plural_matching_pins.py`, written before the change and unchanged since). ### Fixed - **A filter literal written with the Persian letters now finds a value the warehouse stores with the Arabic ones (`retrieval/filter_spelling.py`, `retrieval/filter_values.py`, `core/persian.py`, `llm/sql_agent.py`, `session/engine.py`, `config.py`) (PR #226).** `WHERE r.[Name] = N''` returned no rows while the same statement with the value as stored (`ي` where the model wrote `ی`) returned them, because under the deployment's collation the two strings are different. The filter value check of 6.16.0 could not help: it compares normalised forms, so it counts the Persian spelling as a known value and changes nothing. A statement the guard has accepted is now read once more before it runs, with no model call: for `=`, `<>`/`!=`, `IN` and `NOT IN` on a column whose whole value set is cached (a table's `prefetchable_columns`, the rules of the filter value check: a missing, empty or possibly truncated list, a denied or join-only column and a column that is not a plain read of a stored one are never touched), a string literal that is **not byte-equal to any stored value** but equals **exactly one** of them up to letter form is replaced by that stored value, character for character; two or more candidates, none, or another stored value that only differs from the literal by what the question matching folds (a trailing space, case) leave it as written. "Up to letter form" is `core.persian.letter_form_key`: `ي` and `ى` with `ی`, `ك` with `ک`, Arabic presentation forms and ligatures to their base letters, canonical composition (NFC), and ZWNJ with none (`می‌خواهم` and `میخواهم`). Nothing else is folded: not case, not digits (`۱۴۰۲`, `١٤٠٢` and `1402` stay three different literals), not spaces (a ZWNJ is not a space), not `ة`/`ه` (different letters), `ۀ`/`هٔ` (a letter and an optional mark) or the alefs. For `LIKE` and `NOT LIKE` on such a column the variant letters of the pattern are written as the column stores them when, for that group (`ی ي ى` or `ک ك`), every stored value that has one of its letters uses the same form; a column that stores both forms of a group, or an Arabic presentation form anywhere, is left alone (an `OR` expansion would change the shape of the statement the model wrote; that case is left to the model and the filter value check). The edit replaces only the characters of the literal's own token (`N` prefix and quoting kept, the rest of the statement byte for byte), the result is parsed again and must read as the original with just those literals changed, and the guard validates the rewritten statement again in the dialect it runs in; if either refuses, the original runs. It is used in `/query` (mode `result` and `full`) and in a session turn's fresh and carry-forward generation, on every statement that is about to run, including the corrected statement of the filter value check; mode `sql` (nothing runs) and the `_prev` refinement are not touched. With `FILTER_VALUE_CHECK` on it runs first, so a spelling difference is a known value and costs no correction round. `result.sql`, the SQL the UI shows and the audit record's `generated_sql` are the statement that ran (`SQLGenerationResult.raw_response` is still the model's text). - **A Persian word written with an attached plural is matched as its stem too (`core/persian.py`, `schema_data/retriever.py`, `retrieval/table_evidence.py`, `retrieval/source_selector.py`) (PR #226).** `تالارها`, `تالار‌های` (with the half-space, which the normaliser folds to the attached form) and `تالارهایی` were one token that no description, trigger phrase, synonym or data-source keyword contained, while `تالار ها` (with a space) put `تالار` in a token of its own and matched. `core.persian.strip_plural_suffix` gives the stem of a word with one of the endings `ها`, `های`, `هایی`, or `ها` with a possessive (`هایم`, `هایت`, `هایش`, `هایمان`, `هایتان`, `هایشان`), when what is left is at least three letters; nothing in Latin script; no `ان` or `ات`, which end `تهران`, `ایران` and `حیات` as well as plurals. It is used **in addition to** the word, never instead of it: the TF-IDF scorer adds the stemmed question's terms and bigrams (`rank_tables`), the `always_include` triggers also try the stemmed question (`forced_tables`), a synonym is found under the stem as well as under the word, the column evidence tries the stems (so `هایی` and the possessives are known there too, where only `ها` and `های` were), an alias of several words in `entities.yaml` or `fact_patterns` is also compared with such words reduced (`alias_hits`), and the data-source `keywords:` are also looked for in the question with such words reduced (`keyword_hits`, still whole words: `تالار` matches `تالارها`, not `تالاری`). A question without such a word is matched and scored exactly as before; for one with such a word every table that scored still scores, and no lower, though a table that now scores can rank above it and, with more than six candidates, push it out of the top six. The matching of a stored value against the question (`retrieval/dimension_vocabulary.py`) is not changed: it does not use this tokenizer, it already finds a value inside a longer word, and an extra term there could make two values tie. ### Added - **`FILTER_VALUE_SPELLING` (default `true`) switches the spelling fix off (`config.py`, `.env.example`) (PR #226).** It is on by default because it acts only where the statement would otherwise compare with a value the column does not contain, and what it writes is the one stored value the literal is a spelling variant of; it issues no query and starts no background refresh (the vocabulary cache is only read), and a column without a cached vocabulary is left as it is. Set `FILTER_VALUE_SPELLING=false` and every statement runs exactly as the model wrote it, every audit line, prompt and response is as in 6.16.1 (pinned). - **`value_spelling` in the audit record (`observability/audit.py`) (PR #226).** Written only when the fix rewrote a literal: `{"fixed": 1, "columns": ["Ring.Name"]}`, how many literals and the columns they are compared to. Neither the literal nor the stored value it became is written, the same names-never-values rule as `value_check`; a block with any other key or type raises. With the setting off, or nothing rewritten, the key is absent and the line is byte for byte what it was; lines written before this release load as before. - **`core.persian.letter_form_key`, `core.persian.strip_plural_suffix` and `strip_plural_suffixes`, and the shared helpers `usable_vocabularies`, `comparison_literals` and `like_patterns` of `retrieval/filter_values.py` (PR #226).** `normalize_for_matching`, `normalize_compact` and `NORMALIZER_VERSION` are unchanged, so the query cache keys are too (pinned). The filter value check now uses `usable_vocabularies` for the rule it had inline; its results are unchanged. ### Upgrading - **One behaviour is on by default (`FILTER_VALUE_SPELLING=true`) (PR #226).** No setting is renamed, no file format changes and `requirements.lock` has no new pin. A literal that is a stored value, a column with no cached vocabulary, a literal two stored values could be, and a `LIKE` over a column that stores both forms all run as written. If you would rather see every statement exactly as the model wrote it, set `FILTER_VALUE_SPELLING=false` and restart. - **Reading the audit log (PR #226).** `value_spelling` present means the statement that ran (`generated_sql`) has literals the model wrote differently; the model's own literal is not kept anywhere in the record. Requests it fixed spend no correction round, so they do not raise the "needed a correction" share. The cache it reads is the one `FILTER_VALUE_CHECK` reads: a list that is stale is used as is, so a value added to a dimension since its last refresh is not known to the fix, and a literal is only ever rewritten to a value the list holds. - **Retrieval for a question with an attached plural can change (PR #226).** A table whose description, trigger phrase or synonym holds the singular is now found for `...ها`, `...های`, `...هایی`; tables that were found are still found. Check a question you rely on with the plural written attached. ## [6.16.1] — 2026-10-29 **Issues fixed:** the first part of #219 (the template's «Ring A» / «Ring B» options in the default scope and the analysts' memory). An operator reported that "the file for the analysts' memory was removed entirely". Nothing was removed: `project_config/memory_policy.yaml` (the closed set of values an analyst may pin) and its sibling `session_policy.yaml` (the scope a ranking question gets when it names none, and the options its clarification offers) are two of the ten required files, but no setup tool wrote them, and the preflight only checked that they load. On a first install both were the template, whose options are made-up samples (`Ring A`, `Ring B`, `All rings`), so every real value an analyst tried to pin was outside the closed set and refused, and the clarification offered values the warehouse does not have. The feature looked gone. This release writes the real values and says when they are missing. No runtime behaviour of memory or of the default scope changes. ### Added - **`scripts/draft_policies.py`: draft the real values of `memory_policy.yaml` and `session_policy.yaml` from one dimension column (`scripts/draft_policies.py`, `core/policy_drafts.py`) (PR #224).** `--table` and `--column` pick the dimension, by default the `filter_key` of `session_policy.yaml` and the `column` of the memory key that shares it when they name a real table and column of `schema.yaml` (a table is found as the application finds a configured table name, bare or `Schema.Table`). It reads the column's distinct values with one bounded statement (`SELECT DISTINCT TOP (N+1) ... ORDER BY`, `--max-values`, default 200; a column with more is refused as not a small dimension) through the application's own engine and login, on the data source of the table's `datasource:`. It keeps the values the wizard's rules accept (`core.value_shapes`: no date, number, code, phone number, address, masked value or error text; a column named like personal data is never read), without the session policy's `default_label`, one spelling of each value under the project's Persian and Arabic folding, sorted. It writes `project_config_draft/memory_policy.yaml` and `project_config_draft/session_policy.yaml`: the current files with only `options` replaced (and `filter_key` / `column` when the dimension is moved); comments, `max_length`, `field_name`, `default_label`, `clarification_prompt` and every other field stay byte for byte, the memory options never contain the `default_label`, and the result is loaded with the application's validators and compared with the input before anything is written. Nothing is written into `project_config/` or `project_config.example/` without `--overwrite-live` (then each replaced file is first copied to `.bak-`); an existing draft needs `--force`. It prints counts only; `--show-values` adds the first ten values, `--dry-run` writes nothing. It is a separate command, like `draft_descriptions.py`, because it reads one column and asks no model, and has to be run again when the dimension gains a value. - **A preflight check, `Session and memory policies use real values` (`scripts/verify_deployment.py`, `core/policy_values.py`, `core/config_doctor.py`) (PR #224).** It fails, naming the file and the key and never a value, when a `filter_key` names no table of `schema.yaml`, when a memory key's `column` is not a column of that table, or when the options of a file are exactly the sample values of `project_config.example/` (compared as a set), and points to the new command. The shipped example is not compared with itself, so it passes its own preflight. The PASS detail advises, as counts, how many options are not among the dimension values the running application has cached (the admin panel's card; the command line has no cache). File reads only, so the admin panel runs it without "deep checks". The preflight now has twenty-six checks with one data source (twenty-seven with `FILTER_VALUE_CHECK`). - **Documentation (PR #224).** Runbook section 2.5 and the check's row in section 3.1; section 0.3.4.1 and the check's row in both setup guides; the first-install checklists name the command in the wizard's step; `project_config.example/README.md` and the README list the script. ### Upgrading - **Nothing changes at run time (PR #224).** No setting is renamed, no file format changes, `requirements.lock` has no new pin, and the server answers exactly as before with the files you have. - **The preflight can now fail on a configuration that works (PR #224).** If `project_config/` still holds the template's `memory_policy.yaml` or `session_policy.yaml` (a first install, or an upgrade that never replaced them), the new check fails, and so does a `filter_key` that is no table of `schema.yaml`, though the server runs. Run `python scripts/draft_policies.py`, read `project_config_draft/memory_policy.yaml` and `project_config_draft/session_policy.yaml`, copy them into `project_config/` and run the preflight again. Files that already hold real values and a real table pass untouched. - **`filter_key` stays the key the retriever stores the scope under (PR #224).** A ring named in a question reaches the session under the key `Ring` (`retrieval/value_retriever.py`), so `--table` should be used to move `filter_key` only when you know your retrieval stores the scope under that key; without `--table` the command leaves `filter_key` as it is and only refreshes the options. ## [6.16.0] — 2026-10-27 **Issues addressed:** #204, #206, #207 (filter values the model invented for the ring table); the check is opt-in, and the deployment's configuration is part of the fix (see Upgrading). The model sometimes invents the value it compares a dimension's label column to: `WHERE r.[Name] = N'Ring'` (the table's own name), a phrase of the question that names no ring (`N'online channel'`, which is a whole source database, not a ring). The statement is valid, the guard has no reason to refuse it, and the answer is empty or wrong. This release adds an opt-in check for it. It is **off by default**; with it off no request, prompt, statement, audit line or response differs from 6.15.2 (pinned by `tests/test_filter_value_check_pins.py`). ### Added - **`FILTER_VALUE_CHECK` (default `false`): a filter value that does not exist gets one correction round instead of an empty answer (`retrieval/filter_values.py`, `llm/sql_agent.py`, `session/engine.py`, `config.py`) (PR #212).** With it on, a statement the guard has accepted is read once more before it runs: the string literals it compares to a column by `=`, `<>`/`!=`, `IN` or `NOT IN` are resolved to the stored column they read (through table aliases, CTEs and derived tables) and compared with the value set `retrieval/dimension_vocabulary.py` already caches for a table's `prefetchable_columns`, with the same normalisation as a question is matched with (Persian and Arabic letter forms, digits, ZWNJ, case, whitespace). A literal that is not among the values gets ONE correction message through the usual correction layout (appended to the question segment; the static prefix is untouched, so the prefix cache is not disturbed): the column, that the value does not exist, the nearest real values (up to five, character trigrams, deterministic; fenced as warehouse data like every value the prompt quotes), and "if the question does not name a specific value of this column, remove this filter". The corrected statement runs only if it passes the guard, keeps no unknown literal (and, in a session turn, no filter the question named) and executes. Anything else (the model keeps the value, the guard refuses the new statement, the model or the database fails) runs the original statement exactly as it would have run without the check, so the check can add one model call but never refuses a request. It counts in the correction counter (`llm.corrections`, `correction_attempts`). It is used in `/query` (mode `result` and `full`) and in a session turn's fresh and carry-forward generation; mode `sql` (no correction loop) and the `_prev` refinement are not checked. It reads the cache only: no database read on the request path, a column with no cached values is not checked (a stale list is used, and the existing background refresh is started for a missing or stale one), and a list as long as the prefetch cap (`DEFAULT_TOP_N` distinct values) is not trusted to be complete. A column the key's `denied_columns` denies, or restricts to join-only, is never checked, never named in the message and none of its values is offered. - **`security.sql_guard.column_origins`: the stored columns a column reference reads (`security/sql_guard.py`) (PR #212).** The resolution the guard does for a denied or join-only column, with the table named, through aliases, derived tables, CTEs, `*` and set operations. Nothing in `validate_sql` calls it, so no allow or deny decision changes. - **`value_check` in the audit record (`observability/audit.py`) (PR #212).** Written only when the check ran and found something to compare: `{"checked": n, "unknown": [{"column": "Ring.Name", "value_sha256": "<16 hex>", "length": 10}], "corrected": bool, "fallback": bool}`. The literal is not written (this log keeps names, never values; the statement that ran is in `generated_sql` as ever) and neither are the real values offered to the model; the hash lets the same invented value be counted across requests. `fallback` is true when there were unknown literals and the original statement ran. The record cannot be given a value to write: a block with any other key or type raises, as `columns` and `resolved_columns` do. With the check off, or with nothing to compare, the key is absent and the line is byte for byte what it was; a reader treats a missing key as no check. - **Preflight advice `Filter value check has vocabularies` (`scripts/verify_deployment.py`, `core/config_doctor.py`) (PR #212).** Listed only while `FILTER_VALUE_CHECK` is on, so the preflight is unchanged with it off. It names the dimension tables (those `entities.yaml` names) that have label-like columns (`Name`, `Title`, `Kind`, `Status`, `Type` ..., not a person's name) and no `prefetchable_columns`: the check cannot help there. Advice only (a `[PASS]` whose detail starts `advice:`, table and column names only), never a `[FAIL]`; file reads only, so the admin panel runs it too. ### Upgrading - **Nothing changes until you turn it on (PR #212).** No setting is renamed, no file format changes, `requirements.lock` has no new pin, and `FILTER_VALUE_CHECK` defaults to `false`. To try it, set `FILTER_VALUE_CHECK=true` in `.env` and restart. - **It needs `prefetchable_columns` (PR #212).** The check compares with the value sets the dimension vocabulary caches, and those exist only for the columns a table lists under `prefetchable_columns` in `schema.yaml` (small dimensions only; leave out tables of people and anything large). Run the preflight with the setting on: the advice line `Filter value check has vocabularies` lists the dimension tables that have label-like columns and no `prefetchable_columns`. With `DIMENSION_VOCABULARY_WARM_ON_STARTUP=true` (the default) the sets are cached at start-up. - **What it costs (PR #212).** At most one more model call per request or turn, only when a literal is not a known value and `MAX_CORRECTION_ATTEMPTS` is not zero (outside that budget: a request that used all of it can still get this one call). It is not a refusal path: when in doubt the original statement runs. A value that is real but not yet cached (a ring added since the last refresh) costs the extra call and then runs as written. - **Reading the audit log (PR #212).** `value_check.unknown` non-empty with `corrected: true` is an invented value that was replaced; with `fallback: true` it is one the model kept (or whose correction failed) and that ran as written. Requests it corrected count in `llm.corrections`, so the real-use quality card's "needed a correction" share rises by them. Lines written before this release, and lines written with the check off, have no such key, and every reader treats it as absent. The quality card has no figure of its own for the check. ## [6.15.2] — 2026-10-27 **Issues fixed:** #183 (schemas and tables to leave out of discovery), #203 (a ring id drawn as a chart), #205 (volume reported in Rial). A patch for three things seen on a deployment: the answer summary put Rial/ریال on volumes and counts, a list of ids was drawn as a chart, and the setup tools could not skip a single table or keep a staging schema's tables without its change-capture tables. Every existing file behaves as before; the new keys are optional. ### Fixed - **The answer summary no longer writes Rial/ریال on numbers that are not money (`llm/interpret.py`, `llm/column_units.py`, `api/runner.py`) (PR #210).** The summary prompt said that every monetary value is in Rials and that amounts are always written with the unit Rial/ریال, and the model put the unit on every number it saw: a total traded volume of `33,493,503,232` came back as "33,493,503,232 Rial", and «حجم کلی معاملات چه قدره؟» as "۸۲,۱۹۰,۶۶۳ ریال". The rule is now "Rial/ریال only for a value that is money, never for a count, quantity, volume, weight, percentage, id, code or date", and the prompt lists, for each numeric column of the rows shown, whether it is money: first from the column's `column_units:` in `schema.yaml` (below), else from its name (`price`, `value`, `amount`, `cost`, `fee`, `ارزش`, `قیمت`, `مبلغ`, `کارمزد` and the like are money; `count`, `quantity`, `volume`, `weight`, `percent`, `id`, `code`, `date`, `حجم`, `تعداد`, `درصد`, `کد` and the like are not; a name with both, such as `FeeCount`, is not), else "not known" and the model decides. A money column is still asked for in Rial/ریال, the replacement of toman/تومان by Rial/ریال in the answer is unchanged, and so are the thousands separators and the 20-row preview. `/query` (`interpret: true`) and the conversational engine share one prompt; the unused copy of the template in `api/runner.py` is now a name for the shared one. Tests: `tests/test_interpret_prompt_pins.py` (what must not change), `tests/test_interpret_units.py`. - **A numeric identifier column is no longer drawn as a chart measure (`web/js/num.js`, `web/js/render/table.js`, `web/js/render/chart.js`, `web/js/chart-engine/recommend.js`) (PR #210).** "All trading rings with their codes" returned (Name, ID) rows, `determineShape` took the one numeric column of the two as the measure, and the ids were drawn as a bar chart. A numeric column whose name is an identifier is now a label: such a result is a table. The rule, `isIdentifierName` in `web/js/num.js`, reads the name only: `ID`, `Id` or `id`; `id` or `code` as the last word after a space, `_`, `-` or `.` (`ring_id`, `Broker Code`); an upper-case `ID` after a lower-case letter or a digit (`RingID`); a capitalised `Id` or `Code` after a letter or a digit (`ringId`, `BrokerCode`, `ISOCode`); and the Persian word `کد` or `شناسه`. A name that merely ends in the same letters is not an identifier (`Paid`, `Valid`, `Width`, `Barcode`, `Postcode`), and neither are all-capital names such as `PAID`, `VALID` and `BROKERID` (write `BROKER_ID`). Only a numeric column is affected, so a text `Code` still labels a chart. Every other shape decision is unchanged: a one-row result is still a record, a 1 × 1 numeric result is still a scalar (also when the column is an id), two numeric columns are still a table, and the 2 to 30 row range is the same. `chart.js` takes the first numeric non-identifier column as the measure, and `chart-engine/recommend.js`, which may not import and keeps its own copy of the rule, recommends nothing for a result whose only numeric column is an identifier. No server code decides a result's shape, so nothing changes in the API. Documentation: `docs/design/CHART-ENGINE-BOUNDARY.md`. Tests: `tests/web_ui/test_web_ui_shape_pins.py` (what must not change), `tests/web_ui/test_web_ui_identifier_columns.py`, scenario 6 of `tests/web_ui/run_result_shapes.mjs`. ### Added - **`exclude_tables:` per source and `EXCLUDE_TABLES` for every source: skip single tables when the setup tools discover tables (`database/schema_filter.py`, `database/datasources.py`, `config.py`, `database/schema_draft.py`, `schema_data/sync.py`, `setup_project.py`, `scripts/sync_schema.py`, `scripts/verify_deployment.py`) (PR #210).** `exclude_schemas:` skips a whole schema; a deployment that wanted to skip `X_Fact.Order`, or the `_Delete` tables of one schema, had no way to say so. The patterns are `fnmatch`, compared without regard to case with `schema.table` (`X_Fact.Order`, `X_Stg.*_Delete`, `*.Order`); a pattern with no `.` is compared with the table name alone (`Order`, `*_Delete`). `EXCLUDE_TABLES` (comma-separated, `.env`) applies to every source and the source's `exclude_tables:` list to that source, exactly as `EXCLUDE_SCHEMAS` and `exclude_schemas:` do. They are used where those are: the first-install inspector (`database.schema_inspector_cli`, new rule `configured-tables` in its summary), the wizard (counted as `named by a table pattern`, also in `.setup_log.json` as `excluded_tables`), and `sync_schema.py` (a table they name is neither listed nor added by `--add-tables`; the report has the section `tables named by an excluded table pattern, not listed or added`). Discovery only, like `exclude_schemas:`: a table `schema.yaml` already lists is synced as before, and the preflight check `No table in an excluded schema` now also lists a listed table that a pattern names (its text is unchanged when only schemas are involved). The legacy `SchemaInspector.inspect` that `AUTO_DISCOVER_SCHEMA` uses at run time does not read `EXCLUDE_TABLES`. `database/datasources_edit.py` gains `add_exclude_tables`, `record_exclude_tables` and `exclude_tables_snippet` (the same comment-preserving, backed-up edit as for `exclude_schemas:`); the wizard does not ask for table exclusions to record. Documentation: `.env.example`, `project_config.example/datasources.example.yaml`, runbook §2.3 and §16.3, section 0.3.3 of both setup guides, `docs/design/DATASOURCES.md`. Tests: `tests/test_exclusion_pins.py` (what must not change), `tests/test_exclude_tables.py`. - **`keep_staging_schemas:` per source: scan a staging schema without its change-capture tables (`database/schema_filter.py`, `core/config_doctor.py`) (PR #210).** `STAGING_PATTERNS` drops a staging schema (`*_Stg`, `*stg*`, `*staging*`) and the change-capture tables (`table:*_Inserts`, `table:*_Updates`, `table:*_Deletes`) together, and `--include-staging` keeps both. A schema named in the source's `keep_staging_schemas:` is exempt from the schema patterns, so its tables are drafted, while the `table:` patterns and `exclude_tables:` still remove its change-capture tables; `exclude_schemas:` still wins. The default `STAGING_PATTERNS` are unchanged and still know the plural names only; the singular `_Insert`, `_Update`, `_Delete` are dropped with `exclude_tables: ["X_Stg.*_Insert", ...]` or by adding `table:*_Insert,table:*_Update,table:*_Delete` to `STAGING_PATTERNS`, as the runbook now says. The preflight check `No staging or change-capture tables` does not count a kept schema's tables against the schema patterns. `core.config_doctor.is_staging` and `staging_tables` take the keep list as an optional argument. - **`column_units:`, an optional per-table map `{column: rial | none}` in `schema.yaml` (`schema_data/registry.py`, `schema_data/sync.py`) (PR #210).** It says whether a number in a column is money, for the wording of the answer summary only: the prompt's schema block, the SQL guard's allowlist and every query are unchanged. `columns:` keeps its `{name: description}` shape, which every reader and the sync's line editor rely on, so the unit is a map of its own beside `column_types:`; a file without it loads and behaves as before, and a file with it still loads on an earlier release, which ignores the key. Values are `rial` or `none` (any letter case; write `none`, not `no`, which YAML reads as false), keys must be columns of the table's `columns:`, and a result column is looked up by its name in the result (a name that two tables give different units is not looked up). The sync leaves the map as it is; `sync_schema.py --prune` removes the entry of a column it removes, so the file stays valid. `schema_data.registry` gains `get_column_units` and `COLUMN_UNITS`. Documentation: the header and the `Order` table of `project_config.example/schema.yaml`, `project_config.example/README.md`, runbook §16.3, section 0.3.3 of both setup guides. Tests: `tests/test_schema_column_units.py`, `tests/test_schema_sync_units.py`. ### Upgrading - **No setting is renamed, no file format changes and `requirements.lock` has no new pin (PR #210).** The three new keys, `column_units:` in `schema.yaml` and `exclude_tables:` and `keep_staging_schemas:` in `datasources.yaml`, are optional and change nothing when absent; `EXCLUDE_TABLES` is a new optional setting. The SQL the server writes, the guard and the audit log are as in 6.15.1. - **Where the summary's money guess is wrong, say it in `schema.yaml` (PR #210).** Write `column_units: {Quantity: none, TotalPrice: rial}` under the table. A column that is not listed is judged by its name, as described above. - **To skip single tables or keep a staging schema's tables, edit `datasources.yaml` and re-run the setup tool (PR #210).** The default `STAGING_PATTERNS` is unchanged, so earlier runs propose the same tables; singular change-capture names (`*_Insert`) need an `exclude_tables:` entry. ## [6.15.1] — 2026-10-27 **Issues fixed:** #202 (a `denied_columns` entry that did not hide the column it named). A security patch for column restrictions. On a deployment, a key that denied `NationalID` still saw it in web answers. The guard refuses that column when the entry is written exactly, but an entry with a space or an invisible character at either end (easy to paste from a Persian editor) denied nothing, a plain name that matches no column was never reported, and the audit log of the web UI did not say which key asked. Entries are now cleaned at their ends, unmatched names fail a new preflight check, look-alike columns are reported as advice, and every web turn records `principal_id`. Every key that works today still works. ### Security - **A `denied_columns` entry with a space or an invisible character now restricts the column it names (`security/column_policy.py`, `security/auth.py`, `appdb/key_store.py`) (PR #208).** The guard rejects a statement that selects a denied column, but an entry such as `"NationalID "`, `" NationalID"`, `"NationalID"`, `"NationalID"` or one wrapped in a bidi embedding or isolate or a byte-order mark never equalled the column name, so it denied nothing, and nothing logged that. Persian editors insert U+200C, U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069 and U+FEFF without showing them, so a list copied from a document could be fail-open and look right. Every entry is now normalised in one place, `normalize_denied_entry`, before it is read: whitespace and Unicode format characters (category `Cf`) are removed from the **start and the end** of the entry, repeatedly, so a run such as a mark, a space and a non-joiner goes whole. Nothing inside an entry is changed, format characters included: a Persian column name may itself hold a zero-width non-joiner, and the entry stays exactly what the operator wrote in the key file, in the panel, in the stored list and in the `PATCH` response. Letters, case and inner spaces are not touched either (`Price List` stays `Price List`, and an entry with an inner space that no column has is reported, below). For a plain entry that does hold a format character inside (`NationalID`), the policy the guard matches against denies the entry as written and also the same name without that character, so the restriction can only be wider than the entry, never narrower; the second name is not written back anywhere. The file and `API_KEYS_JSON` keys, the keys of the admin panel (when they are read and when `PATCH /admin/keys/{key}/acl` writes them), the access-request approval and the policy parser all go through it, and so does `Principal`, so a principal built any other way is cleaned the same. A scoped entry (`Table.Column`, `Source:Table.Column`) is cleaned at its two ends the same way before it is checked against `schema.yaml`. - **An entry that normalisation changed is logged once per key, as a WARNING that names the key id and shows the entry with `ascii()` (PR #208)** (so the invisible character appears as `\u200f`). The key's hash is never logged. ### Fixed - **A plain `denied_columns` name that matches no column of `schema.yaml` is reported instead of silently denying nothing (`security/auth.py`, `scripts/verify_deployment.py`) (PR #208).** At load, one WARNING per key per process lists the entries that match no column (the match ignores case, as the guard does; an entry matches when it, or it without the format characters inside it, is a column name). The server still starts, so no installation that starts today is stopped. The new preflight check below finds them again, with the key id. - **The audit records of the web UI carry `principal_id` (`session/engine.py`, `api/v2_routes.py`) (PR #208).** Every record written for a turn of `/v2/sessions` had `principal_id: null`, because the turn engine built the record without it (the `/query` path always set it), so the audit log could not say which key asked what. A first turn, a follow-up, a streamed turn and the re-run of `PATCH .../assumptions` now write the caller's id. `TurnEngine.ask` takes an optional `principal_id`; without it the field stays empty, as before. Existing fields are unchanged and earlier records load as they did. Records written by 6.15.0 and before keep the `null`; the other audit writers are listed under Upgrading. - **Approving an access request removes the entry whichever way it was stored (`appdb/access_requests.py`) (PR #208).** A key whose list still held the entry with a trailing invisible character (written before this release) is matched on the cleaned entry too, so the approval no longer finds nothing to remove. ### Added - **A preflight check, `Column restrictions name real columns` (`scripts/verify_deployment.py`, `core/config_doctor.py`) (PR #208).** It reads the keys of the key file or `API_KEYS_JSON` and of the application database, as the server does, and FAILs, naming the key id and each entry (`ascii()` form), when a plain `denied_columns` entry matches no column of `schema.yaml`. It SKIPs without any key, with an invalid key configuration or without a readable `schema.yaml`. The server is not stopped by the entries (see Fixed). It reads files and the key set only, so the admin panel's preflight card runs it. The preflight has twenty-five checks with one data source (twenty-four in 6.15.0). - **Look-alike columns, as advice in the same check (`security/column_policy.py`) (PR #208).** The same personal number lives under many names in a warehouse (`NationalID`, `NationalCode`, `CustomerNationalID`). When every entry matches, the PASS detail starts `advice:` and lists, for each denied plain name, the columns that probably hold the same data and are not denied: a column whose name contains the denied name, or contains its stem with another identifier ending (`ID`, `Code`, `No`, `Num`, `Number`). It counts and names columns, never values, ignores the denied name itself and what the key already denies, compares the denied name without any format character inside it, ignores names shorter than five characters (`ID` sits inside thousands), and never fails: it is a guess from spelling, so read each name and add the ones that hold the data. - **`security.column_policy` gains `normalize_denied_entry`, `normalize_denied_entries`, `unmatched_bare_entries`, `look_alike_columns` and `schema_column_names`; `core.config_doctor.SchemaTable` gains `columns` (the column names, as written) (PR #208).** Documentation: runbook §2 (denied_columns), §3 and §3.1; sections 2.2 and 0.4 of both setup guides; `docs/api-contract-v2.md`. ### Upgrading - **No setting is renamed or added, no file format changes and every `api_keys` file that starts today still starts (PR #208).** A key whose entries were clean behaves exactly as before, and its cache scope is the same, as is that of a key with a Persian column name that holds a zero-width non-joiner. A key with an entry that normalisation changes now restricts that column, so an answer that used to show it is refused (`denied_column`), and the key's cache partition changes once, so earlier cached results for it are rebuilt on the next request (they were produced under the fail-open list). - **Run the preflight and read the log once after upgrading (PR #208).** `python scripts/verify_deployment.py` prints `Column restrictions name real columns`: a `[FAIL]` lists each key id and the entries that name no column (a typo, a renamed column, a name with an inner space); correct them in `API_KEYS_FILE` or `API_KEYS_JSON` and restart, or with `PATCH /admin/keys/{key}/acl` for a key of the admin panel. The start-up log has the same entries as WARNING lines, and one more WARNING per key whose entries were cleaned (`denied_columns changed by normalisation`, with each old and new spelling). An entry with nothing visible in it (only spaces or invisible characters) is kept as written, as before, and reported; remove it. Then read the `advice:` line of the same check and decide, for each look-alike column, whether it holds the same data. - **Panel keys keep what is stored until they are read or written (PR #208).** A stored list with an invisible character is cleaned when the key is read (one WARNING) and rewritten clean the next time the list is saved. `PATCH /admin/keys/{key}/acl` now stores, and returns in `denied_columns`, the cleaned entries (the same list when none had a space or an invisible character). - **The audit log now says which key asked (PR #208).** `principal_id` is filled on every record of the web UI from this release on. It is still empty on two kinds of record: those written before the upgrade, and the note the model router writes when a task is sent to a remote model provider (`request_id` starting `remote-llm-`; it belongs to no single caller). No other code writes to this audit log. ## [6.15.0] — 2026-10-20 **Issues fixed:** #197 (the first-install order of the setup guide), #198 (aliases lost when the model's answer looped and was cut off). A setup release for a first install. On a deployment with two SQL Server data sources and no earlier curated configuration, the setup guide could not be followed to a passing preflight: no tool drafted the table and column descriptions the `curated` check asks for, the guide described `datasources.yaml` after the tools that read it and the first API key after the preflight that needs it, and the wizard's alias step left many tables with no alias when the model repeated itself until its answer was cut off. This release adds `scripts/draft_descriptions.py`, puts the install steps in an order that works, keeps the aliases of a cut-off answer, and stops asking for value rules on dimension tables. No setting or file format changes; one pin moves, `certifi`, for a published advisory. The **Upgrading** notes say what to run. ### Added - **`scripts/draft_descriptions.py` drafts the descriptions `schema.yaml` lacks, with the wizard's model, for review (`schema_data/describe.py`, `schema_data/describe_prompt.py`) (PR #200).** On a first install the inspector and `sync_schema.py --add-tables` describe a table by its own name and a column by ` column`, the preflight rightly refuses that, and there was no tool that wrote anything better: a deployment of 76 tables and 1388 columns had to write 1464 descriptions by hand to pass `project_config/ is curated, not a raw draft`. The command finds every table description that is empty, the inspector's placeholder or only the table's name, and every column description that is empty or only ` column` (the preflight's own tests), asks the model once per table (the table key, schema and data source, column names and types, the notes already written, the relationships that name the table and its aliases in `entities.yaml`; no row value), and writes `schema.described.yaml` next to `schema.yaml`. Every description a person wrote, every key, flag, comment and the order stay as they were (the script parses its output and refuses to write a file that differs in anything but the drafted descriptions); each drafted line ends with `# REVIEW`. `--language fa|en` (default `fa`), `--tables 'Schema.*'`, `--resume`, `--force`, `--dry-run`, `--max-tokens`, and `--samples` (see below). A table the model fails on is recorded and the run goes on; the summary gives the counts and lists the failed tables, and the output is written after every table. `schema.yaml` itself is never overwritten, and an existing output is not replaced without `--resume` or `--force`. - **The file starts with `# AUTO-GENERATED by draft_descriptions.py`, a draft header the preflight recognises (`core/config_doctor.py`, `scripts/verify_deployment.py`) (PR #200).** `project_config/ is curated, not a raw draft` fails on a file that still carries it and passes once the descriptions are real and the line is deleted, the documented way to say a draft was reviewed. `core.config_doctor` gains `DESCRIBE_DRAFT_HEADER` and `DRAFT_HEADERS`, the per-description tests `is_placeholder_description`, `is_name_only_description` and `is_type_only_note` (the counters now use them), `read_schema_text`, and the aliases of `read_entities`. - **`--samples` for the descriptions, with the wizard's rules (`core/value_shapes.py`) (PR #200).** Off by default, so no row is read or sent. With it, up to five distinct values of each label column (a name, title, kind, status, type, category, class, label, caption or group, of at most 50 distinct values) are shown to the model beside the column; a column named like personal or contact data is never read, and no date, number or code, phone number, address, masked value or error text is shown. `core.value_shapes.label_values` is the part of the wizard's `--value-synonyms` step that decides this, now shared by both. - Documentation: runbook §2.4 (the command), the first-install list, §2.1, §2.3, §3.1 and §16.3; sections 0.3.4 (the command) and 0.3.5 (the first key) and the install list, 0.4 and the checklist of both setup guides; the README scripts list (PR #200). ### Changed - **The `project_config/ is curated, not a raw draft` failure now gives the next step for a first install as well as for an upgrade (`scripts/verify_deployment.py`) (PR #200).** It said to restore the curated files from a backup or version control, which does not apply when there are none. It still says that for an upgrade, and for a first install tells the operator to draft the descriptions with `python scripts/draft_descriptions.py`, read `schema.described.yaml`, replace `schema.yaml` and delete its header line. The same two cases are in runbook §3.1 and in section 0.4 of both guides. - **`setup_project.setup_wizard_llm` takes `mock_fallback` and `sends` (`setup_project.py`) (PR #200).** `mock_fallback=False` raises `WizardLLMUnavailableError` instead of continuing with the mock model, and `sends` names what the caller sends in the notice about a remote endpoint. The wizard's own behaviour and output are unchanged. `draft_descriptions.py` uses it, so it takes the endpoint, the key and the `LLM_ALLOW_REMOTE` rule exactly as the wizard does. - **`generate_with_meta` accepts a per-call `max_tokens` (`llm/base.py`, `llm/providers.py`) (PR #199).** `OpenAIBackend.generate` and `generate_with_meta` take an optional keyword-only `max_tokens` that replaces `LLM_NUM_PREDICT` for that one request; nothing is remembered between calls, and the SQL path passes none, so its request is unchanged. A backend that cannot apply it (`LLMBackend`'s default, `MockBackend`) ignores it. ### Fixed - **The install order in both setup guides and the runbook did not work for a first install (PR #200).** It described the databases (`datasources.yaml`) after the tools that read them, put the first API key after the section that needs it, and sent the operator to the preflight with no way to pass its curated check. The order is now: prerequisites, dependencies, `.env`, `datasources.yaml` (every source, passwords in `.env`; with one database the file is optional, since `DB_CONNECTION_URL` and `DB_PASSWORD` are the one source `default`), the template, the `schema.yaml` draft, `sync_schema.py --check`, the descriptions, the wizard, the per-source settings, the first admin key and `api_keys.json`, and only then the preflight. Part 0 of the guides gets a section 0.3.5, "The first key", before the preflight section 0.4, so a reader going top-down meets key issuance first. The template is copied into an existing `project_config/` (`cp -r project_config.example/. project_config/`), because `datasources.yaml` is written before it. - **A table no longer ends with no aliases because the model looped until it was cut off (`llm/providers.py`, `llm/wizard_llm.py`, `setup_project.py`) (PR #199).** Behind an OpenAI-compatible gateway, a Qwen3-class model answered the alias prompt for many tables with `{"aliases": ["a", "b", "a", "b", ...` repeated until `max_tokens` stopped it. The JSON never closed, and `WizardLLM.generate` "repaired" it by sending the truncated text back, which looped again: `Could not extract valid JSON after retry`, 0 aliases, although the first strings were usable. The alias call (`WizardLLM.generate_aliases`) now keeps every complete string of the cut-off list (`salvage_string_list`, which salvages only a flat list of strings and never a half statement, a rule or an example), deduplicates and caps it with `tidy_aliases` as before, and only when nothing can be salvaged asks once more with the original prompt plus a stricter instruction; the truncated text is never sent back. The alias prompt now says at most 8 distinct aliases, no repeats and no reordered repeats, stop after the list. The call carries its own `max_tokens` (320; see Upgrading) so a loop is cut after a few hundred tokens, not after `LLM_NUM_PREDICT`. No `frequency_penalty` or `presence_penalty` is sent: servers differ in whether they accept them, they would have to be reserved in `LLM_EXTRA_BODY` and in the `llm` status block, and a repeat penalty also penalises the legitimate repeated tokens of a multi-word Persian alias. - **The wizard says when a list was salvaged or when nothing came back (`setup_project.py`) (PR #199).** The step line reads `3 aliases (salvaged 3 from a truncated answer)` or `0 aliases (no usable answer: cut off at the token limit, ...)`, step 3 and the final summary count and name the tables concerned, `.setup_log.json` records them (`aliases_salvaged`, `aliases_failed`), and the error keeps a short excerpt (80 characters) of the last answer and says what was tried; it is no longer the only signal. - **Dimension and lookup tables are no longer called fact tables without a measure (`setup_project.py`) (PR #199).** The summary said `N fact table(s) got no rule because they have no numeric column that is not a key` and listed tables of a schema named `..._Dim` and tables of a key and a title. The inspector classes a table by its name first, so `OrderStatus` is a fact table (`order`). Step 4 now leaves out, and asks the model nothing about, a table that is classed `dim`, whose schema is named like a dimension (`dim`, `dims`, `dimension`, `lookup`, as a word of the name), or that is a lookup in shape: one key column, one or two non-numeric descriptive columns and no relationship starting at it. The summary names those apart (`N table(s) classed as fact by name were left out because they look like dimensions`) and keeps the old line for the fact tables that really have no measure. A dimension that had a numeric non-key column, such as `SortOrder`, used to be asked for a value rule. ### Security - **`certifi` 2026.10.19 (`requirements.lock`).** `pip-audit` reports GHSA-6q48-6c7w-9gw2 against the pinned `certifi` 2025.11.12, the CA bundle `requests` uses for outbound HTTPS (the model endpoint, when it is reached over HTTPS). The fixed release is pinned; no code changes. ### Contributors - **The CI test legs upgrade pip with `python -m pip` (`.github/workflows/ci.yml`).** pip 26.2.2 refuses, on Windows, to replace its own running executable when called as `pip`, which failed every Windows leg before a test ran. ### Upgrading - **No setting is renamed or added and no file format changes (PRs #199 and #200).** `requirements.lock` moves one pin, `certifi` (see Security): run `pip install -r requirements.lock`. `scripts/draft_descriptions.py`, `schema_data/describe.py` and `schema_data/describe_prompt.py` are new, and one preflight message changed. A deployment that is already curated never meets the new command or the new header. - **A first install can now reach a passing preflight without writing every description by hand (PR #200).** After the `schema.yaml` draft and `python scripts/sync_schema.py --check`, run `python scripts/draft_descriptions.py` (the wizard's endpoint and model; add `--language en` for English), read `project_config/schema.described.yaml`, replace `schema.yaml` with it and delete its first line. The command sends table and column names, types and relationships to the wizard's endpoint, so a remote one is refused unless `LLM_ALLOW_REMOTE=true`. - **Run the wizard again for the tables that got no alias, and drop the rules it wrote for dimension tables (PR #199).** Run it over the same output folder: the merge keeps what you reviewed, and the step line of each table that had none says how many aliases were salvaged. A `business_rules.yaml` from an earlier run may hold value or volume rules for dimension or lookup tables, which the wizard no longer drafts; delete them. Copy the files you accept into `project_config/` yourself, as before. - **The alias call has its own `max_tokens` (PR #199).** It is 320, or `LLM_NUM_PREDICT` if that is lower. It is not sent at all when `LLM_NUM_PREDICT` is above its default of 512 or `LLM_EXTRA_BODY` is set: both are how the setup guide (section 2.9) makes room for a reasoning model, whose thinking counts against `max_tokens`, and a small cap would leave it no room to answer. Such a deployment still gets the salvage, the stricter prompt and the single stricter retry. The 320 is an estimate from character counts (a worst-case Persian answer of eight phrases and a sentence is 386 characters), not a token count of your model: if your tokenizer is unusually expensive for Persian, raise `LLM_NUM_PREDICT` above 512 to lift the cap. ## [6.14.2] — 2026-10-20 A patch release for the drafts the setup wizard wrote. Run over two SQL Server data sources and the inspector, the wizard of 6.13 and 6.14 produced drafts that passed `verify_deployment` and answered almost every question about one source with `OUT_OF_SCOPE`: two entities for nearly every table, `aliases.yaml` full of raw values from the rows, an empty `examples.yaml`, value rules on dimension tables and a 22-alias list. This release fixes each of them, builds no synonym from a row value unless asked, and teaches the preflight (now twenty-four checks, two of them new) to recognise the drafts that slipped through; an installation that ran that wizard has files to check, and the **Upgrading** notes say which. ### Fixed - **One entity per table, and the keys of `schema.yaml` (`setup_project.py`, `schema_data/registry.py`, `core/wizard_drafts.py`) (PR #194).** An `entities.yaml` the inspector had drafted (bare keys, `schema:` where the wizard read only `schema_name:`) was read as a second schema of every name, so every name looked shared, every table was written `Schema.Table` and the bare entry survived beside it, without aliases; `schema.yaml` keys a table whose name is unique by its bare name, so the qualified entity matched no key and retrieval lost the table. The wizard now reads the schema from `schema_name:`, from `schema:`, from the key's qualifier or from the one table of that name it found; an earlier entry whose key is the table's bare name or `Schema.Table` is the same entity and is merged; two earlier entries that end up on one key become one (aliases united) in `entities.yaml`, `aliases.yaml` and `business_rules.yaml`, and the summary lists them. Keys are spelled by one function, `schema_data.registry.table_key_for`, shared by the sync engine, the inspector and the wizard, so a name with a space, a dot or a non-Latin letter is the same string everywhere (`[Price List]`, `ref.[Run.Log]`). - **No synonym is built from a row value by default (`setup_project.py`) (PR #194).** `aliases.yaml` `synonyms` held hundreds of sample values mapped to table names: dates, times, numeric and national-id-like numbers, phone numbers, addresses, IP addresses, partly masked names, error messages and hashes, in the prompt and in a versioned file. The wizard writes none; `--value-synonyms` brings back the safe part (see Added). - **`examples.yaml` is no longer written empty without a word (`setup_project.py`) (PR #194).** Step 5 built its schema summary from the first 20 tables of the combined snapshot, whichever source they were in; swallowed any model error into a log line; counted items it later dropped for lacking a question or SQL; and a `--resume` reused an empty stored result and left the empty file in place. It now asks once per data source about that source's tables (fact tables first), accepts the answer however the model nested or named it, drops anything that is not a question with a `SELECT`/`WITH` statement and says how many, asks once more with a smaller request when nothing is usable, never reuses an empty result, rewrites an `examples.yaml` that holds no example, and when the model gave nothing says so in the step, in the summary (`WARNING: examples.yaml has no example from this run`) and in the file (`# NO EXAMPLES`). - **No value or volume rule for a table that has no measure (`setup_project.py`) (PR #194).** A rule such as `Value column: ID. Volume column: ID.` was drafted for dimension tables. A fact table now gets a rule only when it has a numeric column that is not a key (not the primary key, a foreign key or join column, or a name ending `ID`, `Key`, `Code`); the prompt lists those columns, a column the model names that is not one of them is dropped, and the tables without one are listed in the summary. - **Alias lists are cleaned (`core/wizard_drafts.py`) (PR #194).** One table got 22 aliases that were concatenations of the same three words. Aliases are compared after folding case, Persian and Arabic letter forms, digits and punctuation; repeats are dropped; a list of more than 8 loses the aliases whose words all belong to a shorter alias of the same table and then keeps the shortest 8. What an operator types is kept as typed. - **The preflight caught the 6.13 drafts only by their header line (`core/config_doctor.py`) (PR #194).** `project_config/ is curated, not a raw draft` passed a sync-format `schema.yaml` whose header had been deleted, because its descriptions are the table's own name (`description: 'Sales_Fact.Trade'`) and its column descriptions `bigint column`, not the 6.11 placeholder. It now fails when most table descriptions are only the key, the bare name or `Schema.Table`, or most column descriptions are only ` column`, with the counts; and the wizard's own header, `# AUTO-GENERATED by setup_project.py`, counts as a draft header like the inspector's. ### Added - **`setup_project.py --value-synonyms` (`core/value_shapes.py`) (PR #194).** Writes `aliases.yaml` synonyms from the values of label columns (a name of `name`, `title`, `kind`, `status`, `type`, `category`, ...) that hold at most 50 distinct values, at most 20 per table. Never taken: dates and times, numbers and codes, hashes, phone numbers, e-mail and IP addresses, URLs, addresses, masked values, error text, anything over 40 characters, any column named like personal or contact data (`FirstName`, `Family`, `Mobile`, `Phone`, `Address`, `Email`, `NationalCode`, ...) and the `Name` or `Title` of a table of people (`Customer`, `Employee`, `User`, ...). It reads up to 51 distinct values of each such column. The trade-off is documented: fewer words reach retrieval, and no row value reaches the prompt or git. - **Two preflight checks, `Aliases hold no raw data values` and `Entities match schema keys` (`scripts/verify_deployment.py`, `core/config_doctor.py`) (PR #194).** The first fails when more than 5 `synonyms` keys have the shape of a date, a phone number, an IP address, a masked value, a long digit run or an address, and reports the count per shape (`: 20, : 9`), never a key. The second fails when an entity key is not a table key of `schema.yaml`, when two entities stand for one table, or when a `ring_aliases` key names a table by something other than its key; it prints the key to use. Both read files only, so the admin panel's preflight card runs them. The preflight now has twenty-four checks with one data source (twenty-two before). - Documentation (PR #194): runbook §2.2 (the option, the steps, the one-entity-per-table and no-row-values rules and the trade-off), §2.3 and §3 and §3.1 (the two checks and the widened curated check); the same in both setup guides (sections 0.3.1 and 0.4); `docs/design/TABLE-NAMES.md`; and the upgrade checklists, a step in runbook §17 and in part 4 of both setup guides. ### Changed - **The alias prompt no longer shows the model raw values (`setup_project.py`, `core/value_shapes.py`) (PR #194).** The sample values put in the per-table alias prompt skip every column named like personal or contact data and every value shaped like a date, a number or code, a phone number, an address, a masked value or error text; short statuses and kinds stay, because they are what makes an alias useful. - **A re-run drops the value-shaped synonyms of an earlier `aliases.yaml` (`setup_project.py`) (PR #194).** New option `--keep-existing-synonyms` keeps them. - **The shipped `project_config.example/entities.yaml` names real keys of the template's `schema.yaml` (PR #194)** (`Customer`, `Currency`, `OrderStatus`), so a fresh install that copies the template does not fail `Entities match schema keys`. - **Table keys are spelled by `schema_data.registry.table_key_for` everywhere (PR #194).** The single-database inspector's drafts used to bracket only the parts that needed it (`[ref].[a.b]`, `Price List`); they now write what the sync engine writes (`ref.[a.b]`, `[Price List]`), as `docs/design/TABLE-NAMES.md` describes. - **Step 5 makes one model call per data source (PR #194)**, not one for the whole snapshot; a single database is still one call. ### Upgrading - **If you ran the wizard of 6.13 or 6.14, check what it wrote before you rely on it (PR #194).** Run `python -m scripts.verify_deployment` on the folder you deployed (or the drafts, with `PROJECT_CONFIG_DIR` pointing at them). `Entities match schema keys` lists the entities that name no table of `schema.yaml` and the tables that have two; `Aliases hold no raw data values` counts the synonyms copied from rows; `project_config/ is curated, not a raw draft` fails on a file that still has the wizard's header line, or on a `schema.yaml` of `sync_schema.py` descriptions and ` column` notes. Fix `entities.yaml` and `aliases.yaml`: one entity per table under the key `schema.yaml` uses (the aliases of both united), and the same keys in `ring_aliases`. Running the new wizard again over the same output folder does the merge for you, and prints what it merged. - **Remove the value synonyms: run the new wizard over the same output folder, or delete them by hand (PR #194).** The merge now drops every `synonyms` key of an earlier `aliases.yaml` that is a date, a time, a phone number, an IP address, a masked value, a long digit string, an address, a hash or an error message, and counts them per shape in the summary and in `.setup_log.json`, never listing a key (`--keep-existing-synonyms` keeps them). A key that is a person's name is not recognisable by its shape: read the file too, and delete such keys. The values were in the prompt, and in git if the file was committed; treat a committed copy as having published them. Copy the cleaned `aliases.yaml` into `project_config/` yourself; the wizard never writes there on its own. - **A preflight that passed on 6.14.1 can fail on this version (PR #194).** It is the same check telling you a file is a draft, now able to see it without the header line: restore the curated files from a backup or version control and add new tables with `python scripts/sync_schema.py --add-tables 'Schema.*'`. A file made mostly of tables added with `--add-tables`, none of them described, now fails too; describe them. The preflight has twenty-four checks with one data source (twenty-two in 6.14.1); the two new ones read files only, so the admin panel's preflight card runs them too. - **No setting is renamed or added, and `requirements.lock` has no new pin (PR #194).** `core/value_shapes.py` is a new module, and `setup_project.py` has two new options, `--value-synonyms` and `--keep-existing-synonyms`. One template changed: `project_config.example/entities.yaml` now names real keys of the template's own `schema.yaml` (`Customer`, `Currency`, `OrderStatus`) instead of `Entity1` to `Entity3`. A copy of the old template that was never filled in now fails `Entities match schema keys`, because `Entity1` is not a table of `schema.yaml`; a file you wrote yourself is not touched. The summary of a wizard run gains lines for the rules left out, the duplicates merged, the examples and the synonyms. ## [6.14.1] — 2026-10-19 A patch release for one confusing failure: a browser page that cannot use the API while every check passes. In 6.14.0 the preflight, the CLI and `curl` all passed and the web UI still showed three red status lights, because the page had been opened at an address (`http://localhost:8077`) that `CORS_ALLOWED_ORIGINS` did not list (it held `http://172.16.101.42:8077`), and the server logged only `OPTIONS ... 400`. The server now names the refused origin in its log, once per origin, with the `.env` line that allows it; a new preflight check, `Web UI origin is allowed by CORS`, tests an address before anyone opens the page; and the web UI and the admin panel say at the top of the page, once and in Persian, when the API address they were given is not the API (typically the static file server's own address) or cannot be reached or was refused. The CORS policy itself is unchanged: no wildcard, no new default, and a refused preflight is still answered `400`. ### Fixed - **A refused CORS origin is named in the server log (`api/cors_diagnostics.py`) (PR #192).** In 6.14.0 `verify_deployment` passed fully yet the web UI showed all three status lights red, and the log held only `OPTIONS /health`, `OPTIONS /v2/sessions` and `OPTIONS /v2/access-requests`, each `400 Bad Request`: the allowlist held `http://172.16.101.42:8077` and the page had been opened as `http://localhost:8077`. Nothing said so. A small ASGI layer registered outside `CORSMiddleware` now logs one `WARNING` per distinct rejected origin for the life of the process, for a preflight and for an ordinary request alike: `CORS: origin '' is not allowed (allowed: ). Browsers show this as 'Failed to fetch'. To allow it add this line to .env and restart: CORS_ALLOWED_ORIGINS=, -- or open the page with exactly an allowed address (scheme, host and port). Logged once per origin.` At most 64 origins are remembered, then one line says further ones are not logged; the origin is stripped of control characters, cut at about 200 characters and never put into a response. The CORS policy is unchanged (no wildcard, no new default; the preflight is still answered `400`), and a request to the server's own address (`Origin` equal to `Host`) is same-origin and not reported. - **The web UI says what is wrong with the API address, once, in Persian (`web/js/diagnosis.js`) (PR #192).** The status page showed red lights and a tooltip nobody reads; every admin card showed its own 404 when the address saved in the browser was the static file server's. A failed call to the configured address is now classified as a wrong address (it is the page's own origin, or `GET
/health` answers a 404, an HTML page or JSON that is not the API's health response) or as refused-or-unreachable (the call got no response; a browser cannot tell the two apart, so the message names both with their remedies and the page's own origin as the value to add to `CORS_ALLOWED_ORIGINS`). The analyst status view and the admin panel show it as one banner at the top; each admin card then says only «به API نرسید؛ پیام بالای صفحه را ببینید.» instead of repeating it. A 401, 403, 429 or 5xx is the API answering and keeps its own message, and the capability-specific 403 messages are unchanged. The analyst status view also no longer treats a 200 whose body is not the API's health response as a healthy API. ### Added - **A preflight check, `Web UI origin is allowed by CORS` (`scripts/verify_deployment.py`, `core/cors_origins.py`) (PR #192).** With `VERIFY_UI_ORIGIN` set to the address the UI is opened with (for example `http://172.16.101.42:8077`) it passes when that origin is in `CORS_ALLOWED_ORIGINS` and fails otherwise, printing the `.env` line to add. Without it nothing can be proved, so it only advises (a `[PASS]` line starting `advice:`): when every allowed origin is loopback while `API_HOST` is not, or when exactly one non-loopback origin is allowed, it says to list every address the page is opened with (IP address, host name, `localhost`) and to set `VERIFY_UI_ORIGIN` to check one. It reads settings only (no network, no database), so the admin panel runs it too. The preflight now has twenty-two checks with one data source. - Documentation (PR #192): runbook §2 (the origin list must hold every address), §3 and §3.1 (the check and `VERIFY_UI_ORIGIN`), §4 and §5 (the new log line), and a new §5.1 table of the two symptoms with the log line and the fix; the same additions in section 2.3.1, section 2.8 and the preflight section of both setup guides (the check count is now twenty-two); `web/README.md`; the CORS block of `.env.example`; and the upgrade checklists, a step in runbook §17 and in part 4 of both setup guides. ### Upgrading - **No file format and no dependency changes (PR #192).** No setting is renamed or added to `config.py` or `.env`, no file's template changed, and `requirements.lock` has no new pin (`git diff v6.14.0 -- requirements.lock` is empty): run `pip install -r requirements.lock` as for every upgrade, but nothing new is installed. The audit log, the session store, the API's responses and the CORS policy are as in 6.14.0; the server change is one log line, from a layer around `CORSMiddleware` that never answers a request and changes no request or response. - **A new `WARNING` appears in the server log when a browser origin is refused (PR #192).** One line per distinct origin per process, for a preflight and an ordinary request alike, naming the origin, the allowed list and the `.env` line to add (`CORS: origin '...' is not allowed (allowed: ...). Browsers show this as 'Failed to fetch'. ...`). It means a browser was opened at an address the list does not hold, not that the server is unwell. At most 64 distinct origins are remembered per process; after that one line says further ones are not logged. - **A new preflight check, `Web UI origin is allowed by CORS`, and the optional `VERIFY_UI_ORIGIN` (PR #192).** The preflight now has twenty-two checks with one data source (twenty-one before). It reads settings only, so it needs no network and no database, and the admin panel's preflight card runs it too. Without `VERIFY_UI_ORIGIN` it never fails: it passes with an `advice:` line when every allowed origin is loopback while `API_HOST` is not, or when exactly one non-loopback origin is allowed. Set `VERIFY_UI_ORIGIN` to the address the page is opened with, as the browser's address bar shows it (`http://172.16.101.42:8077`), to make the check pass or fail on that address; a value that is not `scheme://host[:port]` fails. It is read from the environment of the process that runs the preflight, so on the admin panel it has to be set in the server's own environment to be more than advice. - **List in `CORS_ALLOWED_ORIGINS` every address the page is opened with; the API's own address is not needed there (PR #192).** The IP address, the host name and `localhost` are three different origins, and so are two ports on one machine. The variable replaces the default list, so write all of them, comma-separated, and restart (for example `CORS_ALLOWED_ORIGINS=http://172.16.101.42:8077,http://my-host:8077,http://localhost:8077`). A request whose `Origin` is the server's own address (a page served by the API itself) is same-origin, needs no entry and is not reported. Run the new check once per address people use. - **An admin panel whose saved API address points at the static server now says so (PR #192).** When the address saved in the browser (it takes precedence over `web/js/config.js`, and the top bar saves it) is the static file server's, every card used to show its own 404; now a banner at the top says that the address is not the API and each card points to it. Fix it in the address bar at the top of the admin panel (enter `http://:8000` and press «اتصال»), or load the page with `?base=http://:8000`, which takes precedence over the saved value for that load. The analyst page shows the same banner and names `web/js/config.js` (`DEFAULT_BASE_URL`) instead, since it has no address bar. ## [6.14.0] — 2026-10-18 A benchmark track for accuracy, beside the private golden set, and confidence intervals on every figure. `python -m eval.cli bench-import bird` loads the public BIRD mini-dev benchmark (500 questions over 11 SQLite databases, CC BY-SA 4.0) into a database of the operator's own, one schema per database, and writes a golden file in the existing format whose reference results come from the original SQLite files; `bench-run bird` runs it through the production pipeline with the operator's model, once per database with that database's configuration, and merges the reports. It gives development decisions a large, fully visible sample whose full report can be shared because the data is public, and it is never the deployment's accuracy: the report says so in its first line. Separately, every report now prints a Wilson 95% interval and the number of cases for its overall figure and each stratum (a stratum under 20 cases is "not interpretable"), `compare` adds the paired statistics (fixed, regressed, an exact McNemar p-value, a 95% interval of the difference), and a determinism probe can report outcome consistency and mean accuracy over its repeats. ### Added - **A benchmark track: `python -m eval.cli bench-import bird` (`eval/benchmarks/bird.py`, `bird_import.py`, `bird_config.py`, `sqlite_load.py`) (PR #190).** From the unpacked BIRD mini-dev package it copies each selected SQLite database into one schema of a SQL Server database named after the `db_id` (SQLAlchemy reflection; types mapped from what the data holds, `BIGINT` / `FLOAT(53)` / `NVARCHAR(n)` / `VARBINARY(MAX)`, dates stay text; names kept and quoted; chunked inserts with pyodbc `fast_executemany`; primary and foreign keys added after the data, a key SQL Server refuses is a warning), checks that every table has the source's row count, and writes `golden.jsonl` (and with `--with-evidence`, `golden.with_evidence.jsonl`), overall and per database, an `import_report.json` / `.txt` and a `NOTICE.txt` with the licence attribution. The load takes its own administrator login (`--target-url`, or `env:NAME`); the application then reads the database with its read-only login. A non-empty schema is refused, before anything is written, unless `--replace` is given; `--skip-load` re-checks schemas already loaded. `--dbs a,b` selects databases. - **The reference result comes from the original SQLite file, and the copy is shown to be faithful by its data (PR #190).** Each case's expected rows are the result of the dataset's own SQL on its own `.sqlite` (opened read-only) and are compared by execution accuracy, so no translation can make a question wrong. After the load every table is compared with its source by content, not only by row count: rows are reduced to a canonical form (integers as integers, floats to 12 significant digits, text exactly with case and trailing spaces, blobs by hash) and the sum of the row hashes is an order-independent digest computed in Python on both sides in chunks. If every table matches, no question is excluded for a doubt about the load; a table that differs is reported with its columns, and only the questions whose reference SQL reads it are excluded, with the reason `load_fidelity`. Other exclusions, all kept in the file and listed one by one in the import report: `reference_failed`, `reference_empty`, `reference_too_large`. Cases are tagged `bench:bird`, `db:` and `difficulty:`; the evidence hint is off by default and, with `--with-evidence`, appended to the question under a marker for an ablation. - **The target database must be case- and accent-sensitive (PR #190).** SQLite compares text case-sensitively; in a default SQL Server database `WHERE name = 'abc'` also matches `'ABC'`, so a correct answer could differ from the reference. The import reads `DATABASEPROPERTYEX(DB_NAME(), 'Collation')` and refuses a database that is not (a `CS` and `AS` collation, or `BIN` / `BIN2`) before writing anything, naming `CREATE DATABASE ... COLLATE Latin1_General_100_CS_AS_SC_UTF8`; `--allow-ci-collation` imports anyway and the import report and every report header then flag it. - **A per-query check is a diagnostic, not a filter (PR #190).** Each reference is also transpiled to T-SQL with sqlglot, run on the copy and compared. A difference on identical data is a dialect-semantics difference (integer division, string comparison, a mixed-type column): the case is tagged `fidelity:semantic_difference`, stays in the count by default because the SQLite answer is still the right answer, and is listed; `--exclude-semantic-difference` removes it (reason `semantic_difference`). A reference that cannot be transpiled or fails on SQL Server is tagged `fidelity:unverified`, information only, and never excluded: excluding the queries that are hard to translate would inflate the accuracy. - **A report gives the accuracy on all scored cases and without the semantic-difference cases (PR #190).** A benchmark report states scored, excluded-by-reason and flagged counts in its header, and the JSON carries both figures under `fidelity`. - **A `project_config/` per database, and one over all schemas (PR #190).** `--out//project_config/` is the 6.13 inspector's draft (`--include-schemas `) read through the application's login, completed with `system_prompt.md` and the policies from `--base-config` and empty `business_rules.yaml`, `examples.yaml`, `metrics.yaml` and `retrieval_hints.yaml`; `--descriptions` adds BIRD's column descriptions to `schema.yaml` (a curated configuration; without it the raw draft). `--out/_combined/project_config/` covers every schema. - **`python -m eval.cli bench-run bird` runs and merges (PR #190).** It starts `eval.cli run --live --reference stored` once per database, each process with `PROJECT_CONFIG_DIR` set to that database's configuration, then pools the reports into `runs/