# Changelog All notable changes to FlintTrade will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/). Versioning: [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Changed - **Mode, broker, and decision-model docs.** The Mode chip is taken from the JWT session claim (`explore`, `practice`, or `live`) and shows **Example**, **Practice**, **Live**, or **Unavailable**. An unconfirmed claim shows Unavailable, never Example. Connected (read) is a broker status, not a Mode and not a menu item. Choosing Practice as Guest opens Setup and does not call `POST /v1/auth/mode`. The Status row is labelled Laya. Its value can be Downloading. `GET /api/v1/operator/status` reports that check as the decision model, under `laya`. The market chip reads Transition only from 7 December 2026. The Practice sandbox is no longer described as paper trading. A broker session still cannot be started from the terminal. Bug reports and the security policy list Practice and Live as Modes. The product modes page is published at `/docs/product-modes`, and `/product-modes` opens that same page. - **Entry admission and the runner session are separate answers.** `accepts_new_entries` is true only when a new risk-increasing order would be admitted, and it matches order admission and market timings. `session_active` stays true through the runner's exit window, including MCX until 23:55 and NCDEX at the 17:00 gate. The practice strategy tick and the autonomous trader keep running through the exit window. A new risk-increasing order needs both the runner session and admission. An exit or square-off needs only the runner session, so cash auction entry does not open a new order after 15:30. Square-off times, NCDEX standard hours, and the timings payload are unchanged. The old scheduler open check remains as a deprecated alias of the runner session. - **One session answer.** Order admission, the scheduler, the strategy runner, and market timings read one session decision. Timings publish that live decision, including weekends, exchange holidays, and special sessions. A closed day does not advertise the ordinary close. NCDEX timings use the 17:00 gate close. MCX timings use the 23:30 gate close on an ordinary day, and the Muhurat window on that session. The MCX runner still treats 23:55 as the session end. Cash phase helpers take the session date. The Order Pad session badge follows the dated cash phase, or the selected exchange. - **NSE session clock from 7 December 2026.** Cash continuous trading runs until 15:30. The closing auction is 15:30–15:40. Order matching is 15:40–15:45, then a transition until 15:50, and the post-close session stays 15:50–16:00. Cash auction order entry stops from 15:38, when that entry can already have closed at random. Equity derivatives are not in the auction. NSE and BSE equity derivatives run until 15:45. An index contract stops at 15:30 on the expiry written on its symbol. A supplied expiry calendar confirms that date. A new order stays closed when the expiry cannot be read, or when a supplied calendar does not list it. Stock derivatives stay open until 15:45, including on expiry day. Order admission, including a live order and a modify, reads the expiry on the contract symbol and confirms it with the expiry calendar when one is supplied. A new order is refused after that expiry date, and on a Saturday, Sunday, or exchange holiday. A reduce-only exit is not blocked by a missing calendar on a day that symbol is not expiring, and it stays closed when the exchange itself is closed. NSE and BSE index names, including BANKEX, use the index close. The safety check uses the Muhurat session hours and stays closed outside them. The scheduler and the timings API use this same close. Dates before 7 December 2026 keep the current clock, and the Muhurat session still overrides it. Follows SEBI circular HO/47/11/11(3)2025-MRD-POD2/I/23843/2026 (15 October 2026), sections 4.2.4 and 4.2.5, and NSE circulars NSE/CMTR/77355 and NSE/FAOP/77356 (6 November 2026). - **Electron 45.2.3 desktop shell.** The shell bundles Node 24.22.2. The supported Node floor is `>=22.23.4`; the development target remains Node 24. - **Unified application shell.** The app bar lists Terminal, Testing, Portfolio, Automation, Reports, Learn, Brokers, Settings, and System. Older addresses open the matching app and keep the query string, hash, and navigation state: `/home` and `/trade` open Terminal, `/invest` opens Portfolio, `/lab` opens Testing, `/automate` and `/ai` open Automation, `/ditto` opens Brokers, `/admin` opens System, and `/login` opens `/welcome`. The public site permanently redirects `/explore` (HTTP 308) to the Demo at `/demo-app/welcome`. The public demo bundle replaces `/explore` with Terminal. An installed build replaces `/explore` with `/welcome`; a signed-in Welcome then continues through `/trade` to Terminal. A signed-out installed visit stays on `/welcome`. - **Execution sessions, protection grants, diagnostics, incidents, datasets, and knowledge.** Loopback session routes now cover `/api/v1/execution-sessions` (a Testing run keeps the Practice account for that run), `/api/v1/protection/grants`, `GET /api/v1/system/diagnostics`, `/api/v1/system/incidents`, `GET /api/v1/data/datasets`, and `/v1/knowledge` with `/v1/learning`. - **Mode header check.** When `X-FlintTrade-Mode` is present and differs from the JWT `mode` claim, order routes return HTTP 403 `{"status":"error","message":"X-FlintTrade-Mode does not match the authenticated mode"}` with no `code` field. - **Quantity admission.** Before Laya, a lot multiple or a freeze breach is HTTP 400 `quantity_admission`. The Order Pad shows the lot sentence once, inline: `Quantity must be a multiple of the lot size (N).` An unverified lot is `Not placed. The lot size for isn't verified.` An order over the per-order freeze limit is refused with HTTP 400 and code `quantity_admission`. The message gives the limit, names the instrument, and asks for smaller orders. The order is not split. The figures are the contract's lot and the broker or exchange freeze limit, not published constants. The Practice sandbox returns an unverified lot as HTTP 400 with that message and no `code` field. - **Exit-all fails closed.** Unavailable position evidence is HTTP 503 `Position evidence unavailable; no square-off was sent.` Ambiguous evidence is HTTP 409 `Position evidence is ambiguous; no square-off was sent.` When broker orders cannot be read, a further exit is HTTP 409 `exit_orders_unreadable`: `Not placed. Broker orders for SYMBOL are unavailable. Reconcile them before another exit.` ### Added - **Operator status.** `GET /api/v1/operator/status` is an authenticated read of the session mode, whether Live is unlocked, whether a broker is Connected (read), the decision model, the kill switch, the session, broker account status, dispatch counts, strategy rows, and whether startup is paused. Connected (read) is a broker status, not a mode. Each part is taken from the check that already owns it. A part that cannot be read is unknown, with a fixed reason, and the rest of the document is still returned. The route does not place an order, latch the kill switch, start or stop a strategy, or refresh a broker token. - **Mode honesty bar.** One line under the TopBar for Example, and for Practice and Live. Widgets also identify illustrative and fallback figures with Sample/Example source labels. An incident, when one is showing, sits above the mode line and does not replace it. - **Service connections and in-process `BrokerReadPort`.** The backend now ships a rights-aware service-provider catalogue, static LLM/data profiles, and an inert persisted service-connection control plane. Listing or saving a connection does not resolve, probe, authenticate to, or start that provider. Exact broker reads in this change are the in-process `BrokerReadPort` contract (quotes, depth, history, balances, books, and related methods) — not a restored terminal Brokers screen or a new public HTTP read family. - **Native broker account transaction foundation.** Strict mutation contracts, encrypted durable claims, witnessed workspace recovery and retained worker custody underpin the synthetic account coordinator. Read generations verify durable enrolment and workspace coherence. Terminal receipts retain redacted audit events with stable identities and verified retryable delivery. Native account HTTP mutations remain `503` and native reads remain `409` until their separate production cutovers. Refs #328. - **Estimated charges on Practice fills.** Every Practice fill runs through the shared Indian statutory calculator and stores the breakdown and the day's charges total. Fills show `Charges ₹X (estimated)`. Positions and Performance show net P&L, with gross and charges in the tooltip. Home and Invest subtract those charges from net worth. The Practice source line is `Practice account, after estimated charges`. ### Fixed - **Practice stop wakes waiting observers.** Stopping a Practice run records the stop, releases its lock, and only then signals observers. An observer that already holds the publication signal and needs that lock is not left waiting for the quote-read allowance. - **Mode chip follows the session.** After a session is restored, the mode chip shows the mode in that session before the desk paints. A saved mode in the browser is only a cache and is written after the session mode is assigned. It does not override the session. Unlocking with a PIN keeps the restored mode and does not change the server mode. A session whose mode is missing or not one of Example, Practice, or Live shows Unavailable, and no order is sent. Connected (read) stays a broker status. - **Unavailable is not Example.** When the session mode cannot be confirmed, the Scalper and the line above the widgets say Unavailable and ask the operator to sign in again or reload. That copy does not say Example or Sample. Example appears only for an example or demo session. - **Connected (read) is a broker status.** The mode chip shows Practice, Live, Example, or Unavailable. The mode menu lists Practice and Live. A read-only broker connection is reported on the broker status, including the Status menu. - **Workspace log teardown.** Building the backend again in the same process closes the previous workspace log only while that file is still the one the handler opened. A descriptor that is already gone does not abort startup. A descriptor number the kernel has given to another file stays open. - **Uncertain order outcomes.** A submit whose response was dropped, timed out, or came back without a definitive broker or engine result says "Order status unknown. Check Orders before trying again." The order pad, positions, and other submit surfaces do not show that as a rejection and do not offer a retry. A cancel that was only acknowledged says "Cancellation requested". "Order cancelled" appears only after the order status is confirmed cancelled. Practice and Live use the same sentences. The order API, the practice agent, and the MCP order-intent result add `order_outcome` and `reason` beside the existing status. A retry still cannot create a second order while the dispatch ledger holds an unknown write. - **Uncertain Forever orders.** A dropped or timed-out GTT place or modify says "Order status unknown. Check Forever Orders before trying again." It does not offer a retry and it is not sent again on its own. A lost GTT cancel says "Cancellation requested" and is remembered, so a later trigger read can settle it. "Order cancelled" appears only after that read says the trigger is cancelled. A later triggered or filled row does not keep the pending-cancel line and does not say the order was cancelled. The forever-order cancel and modify results carry the same `order_outcome` and `reason` values as the other order surfaces. - **Forever cancel after reload.** A dropped, lost, or timed-out GTT cancel stays pending for the signed-in tab session, and only for the account and mode that requested it. Reloading the terminal still shows "Cancellation requested". Signing out removes that pending cancel, so signing in again on the same tab does not bring it back. A complete Forever list that no longer contains the trigger also removes it. A failed, partial, or stale list does not. "Order cancelled" appears only after a later trigger read says the trigger is cancelled. A later triggered or filled row clears the pending line and does not say the order was cancelled. The dispatch ledger's duplicate-order protection is unchanged. - **Strategy Monitor unknown process.** When an uploaded strategy's process tree cannot be read, the monitor shows Unknown and keeps the runner's ownership flag on the row itself. A missing or unrecognised state is not shown as Stopped. Confirmed running and stopped reports stay on those labels. A strategy list that fails to load says so, and an empty list still says no strategies are configured. Start, pause, and stop controls stay unavailable on this preview. - **Strategy status reasons.** Uploaded strategy status adds a stable reason: idle, running, leader_exited, tree_unreadable, exit_nonzero, exit_clean, exit_missing, or cleanup_unproven. When the runner holds a strategy, the status also includes that lifecycle: ACTIVE, PAUSED, STOPPED, or ERROR. The Strategy Monitor shows Paused for a paused lifecycle. Unknown stays Unknown. A runner whose process tree cannot be read, or that still holds ownership, is not shown as stopped and is not treated as free. - **Illegal strategy lifecycle moves.** Starting, pausing, resuming, or stopping a strategy from a state that does not allow that move raises an error. A second start of a runner that is already running still returns without raising. - **NSE F&O Muhurat session.** Sunday 8 Nov 2026 opens for F&O 14:20–15:40 IST. Equity and indexes use the normal-day close: non-CAS cash to 15:30, CAS names 14:20–15:15, closing auction 15:20–15:35. Currency and commodity run 14:20–15:30. Equity after-market orders are collected until 14:03 IST. A missing special-session record stays closed. - **Broker sessions are not available in the terminal.** Broker-account mutations — `/v1` account and auth writes, native connect / login / set-primary / delete, OAuth start and callback, and the other guarded account-authority routes — return a stable `503` with `{error: broker_account_cutover_unavailable}`. Native HTTP account and market-data reads return `409` with zero provider calls. The terminal still calls those routes, so Setup → Brokers and Settings → Brokers show that response rather than a working native session. Practice mode and example data work without a broker. Live order placement stays disabled unless it is explicitly unlocked and every safety layer passes. Service connections remain inert only. - **Desk routes, one Practice place path, and desk polling.** `/positions`, `/holdings`, `/monitoring`, `/schedules`, and `/glossary` open the screen that owns that book. `/login` opens sign-in. Practice places go through the admitted order route only; Settings does not place. A missing price says so plainly, or fills at the last stored close. The Trade book starts open. Desk reads pause while the tab is hidden and back off after HTTP 429. - **Laya chip, runtime key, and snapshot launch.** The desk polls `GET /api/v1/ping` every 1.5 seconds. That ping reconciles the pid file, the key file, and the runtime record the same way an order does, so the chip and the order gate read the same state. A command-line stop or start shows on the chip by the next 1.5-second check. After Start Laya, until that ping confirms the new state, the chip says Checking in the neutral colour and the popover says Checking Laya…. It does not show a stale Ready during that wait. An admitted place while the chip is not Ready or Degraded also shows Checking until the next ping. A confirmed first load still says Still loading. A place refused with exactly "Laya is Down. New orders are paused until it's Ready. You can still close positions." sets the chip to Down on that response. The refusal line is unchanged. When `LAYA_API_KEY_FILE` names `/runtime/laya/api.key`, `start` replaces that file and does not treat it as missing. A different path that is not there still refuses with "The Laya API key file is missing." A model already in the standard Hugging Face cache is accepted. When that file is the cache symlink `snapshots//model.safetensors`, the launch path is the snapshot file, not the blob. A blob path is still refused. An admitted Practice place with an empty note skips the model and writes one decision-log line, `effect=clamp` with `failure=note_absent` and no proof. When this run's record stood in, each model allow keeps its own `effect=allow` `proof=runtime` line. ### Added - **Opt-in Laya decision sidecar for place admission.** After the hard rules, the sidecar may deny or clamp a place only. It cannot raise a quantity or overturn a rule refusal, and it does not place the order. Laya starts Down. A ping does not invent Ready. Live stays fail-closed until a qualification record uses LIVE_DECISION evidence for the exact model revision, weight digest, and policy version. When Laya is actually Down, every mode is refused with "Laya is Down. New orders are paused until it's Ready. You can still close positions." A Practice refusal never says Live. A Live place without that qualification, while Ready or Degraded, says "Laya isn't qualified for Live yet. Practice orders are available." Opt in with `python -m flinttrade_core.laya_runtime install` or `start`. The CPU install pins `torch==2.14.0+cpu`, `laya==0.3.21`, `huggingface_hub==1.33.0`, and `tqdm==4.70.1` in the Laya install constraints file (no extras; the install requirement is still `laya[serve]==0.3.21`). The download progress class subclasses that pinned `tqdm.auto.tqdm`. Torch is installed first from the CPU index, and the managed Laya process can share the base interpreter with FlintTrade. `LAYA_PORT` defaults to 8000. The host stays `127.0.0.1`. It is not Ready by default. A first start with the checkpoint missing or incomplete downloads the commit named by `[checkpoint] revision` (not the model repository's default branch) into `runtime/laya/staging`. That download sets `HF_HOME` to `/runtime/laya/hf-home` and `HF_HUB_DISABLE_XET=1`, so transfer logs stay out of the shared cache. FlintTrade hashes the files there against the `[checkpoint]` and `[checkpoint.manifest]` pins, and on a full match moves that directory onto `runtime/laya/checkpoint` before the offline launch. When a new pin replaces a checkpoint already on disk, the next start downloads it the same way and the current copy stays in place until the new files match. On a full match that checkpoint is renamed aside to `checkpoint.old-` in the same runtime directory, staging is renamed onto `checkpoint`, and the old copy is deleted. If that second rename fails, the old checkpoint is renamed back, the chip is `download_failed`, and the managed Laya process does not start. While the download runs, including a pin change, the status word is Downloading (neutral colour), not Still loading or Down, and the chip reads "Downloading the model · 1.2 of 3.4 GB". The model is about 2.37 GB, and that size is reported once. There is no separate Updating label. Orders are refused with "Laya is Down. New orders are paused until it's Ready. You can still close positions." A dropped connection, a partial download, or a failed swap is `download_failed` ("Can't download the model"; tooltip "Check your connection, then Start Laya again.") and deletes only the staging directory. That chip stays `download_failed` even when an older snapshot is still on disk, whether that snapshot would be `wrong_revision` or `unverified`. `unverified` stays when this start did not download. `wrong_revision` is only a complete download whose files do not match the pin, a snapshot already on disk that this start is not replacing, or a running managed Laya process that reports another revision or digest. Leftover staging directories and `checkpoint.old-*` copies are removed at the start of `start` once a checkpoint is in place, with no chip change and no message. If `checkpoint` is missing after a crash between the two renames, the last `checkpoint.old-*` name is restored and checked against the pin. The managed Laya process does not start on files that do not match the pin, including that restored copy. The managed Laya process always runs offline. When its health document leaves the revision empty, FlintTrade fills the pinned revision from the verified manifest, so the chip leaves Still loading. - **Shared symbol bus from Watchlist.** Selecting a symbol in `/trade` Watchlist retargets Chart, Option Chain, and Scalper to that symbol — no retype. Keyboard retarget is optional later. Example retarget is allowed. Disclosure stays the Mode honesty line, not a widget Sample chip. An empty watchlist never silently retargets. - **Option Chain OI profile + PCR strip.** The Option Chain strip shows OI profile + PCR for the selected expiry/symbol. Example does not invent live OI. The Mode honesty line is the disclosure — there is no Sample chip on the chain strip. An empty expiry is an honest empty, not zeros-as-data. ### Changed - **Home and Invest net worth, greeting, benchmark legend, and Example markers.** Home and Invest share one total: ledger cash, including blocked margin, plus holdings at market value, plus open positions. Opening an F&O position does not reduce the total by its margin. A Practice round trip at an unchanged price leaves it at the starting cash, for example ₹10,00,000. Options add signed market value. Futures add unrealised P&L. Dhan marks from the mark-to-market average, or from `costPrice` when that average is absent. Kotak Neo marks an open future from the open-leg average. Practice marks a future from the entry price. An estimated futures mark shows `≈`. Dhan does this for `costPrice`. Kotak Neo does this for an open future. Practice never does. The mark clears when that position is flat or the average arrives. The tooltip and `≈` sit on the Home Net Worth amount, the Known Total amount, and the Open Positions value. The Invest Dashboard label `Net Worth (Cash + Holdings + Positions)` carries the tooltip, and `≈` sits on the amount under it. Available Funds shows that `≈` with no tooltip. Dhan's tooltip, when the average was missing, is `Approximate. Your broker didn't send an average price for NIFTY-JUN2026-FUT, so profit or loss from earlier days may be counted twice.` Neo's is `Approximate. The price for NIFTY25JUNFUT is estimated from the open position's average, so profit or loss from earlier days may be counted twice.` Several positions of one kind say `N futures positions` instead of the symbol. The screen-reader name is `Net Worth, approximately …`. Allocation percentages are not marked. Home allocation stays on the Example split until funds, holdings, and positions have all loaded. Home and Invest both wait for the position book before they publish the total. The sample book does not. While that book is pending or has failed, Home shows `—` and does not draw cash alone. A negative total is the number, for example `-₹50,000`, or `≈ -₹50,000` when the mark is approximate. Home derives each open position's P&L percent from cost, and shows `—` when cost is missing or not above zero. The greeting uses the saved display name, then the username, and stays plain `Good morning` (or afternoon or evening) until a name is known. It never uses `Trader`. On Benchmark, real holdings use `Your holdings (unrealised)` instead of `Your Portfolio (since first buy)`. An empty book stays `Your Portfolio`. Example holdings stay `Your Portfolio` with an Example label, and the hard-coded index returns keep the Example chip. Dashboard Net Worth, Available Funds, Invested Value, and Day P&L show the final formatted value on the first frame in every mode, with no count-up from zero, including `≈`, `-₹50,000`, and `—`. The sample Dashboard XIRR is the inline `XIRR` figure plus one Example chip. Portfolio Allocation on that sample dashboard omits its broker sentence and does not carry its own Example chip. There is no Portfolio XIRR card. On sample figures, Net Worth reads `Example equity and cash. Connect a broker to see yours.`; the allocation label is `Allocation` with the Example chip. Equity Holdings and Cash leave their notes blank, and those rows do not carry their own Example chip. In Example, Baskets show one Example chip while quotes are loading and after they have loaded. ETFs show one Example chip and `Example prices. Connect a broker for live quotes.`; Practice and Live keep the live quote wording. Sector's header reads `Example sector split. Connect a broker to see yours.` and the footer reads `Example data. Not from your holdings.` Social shows exactly one Example chip. The sample XIRR chip and the Net Worth allocation chip paint only on example data. The Benchmark chip stays in Practice and Live. A connected book keeps the live equity sentence, `Allocation (live assets only)`, and `Live from broker`. Mutual Funds on example data reads `Example NAVs · as of 10-Sep-2026`. Example order review confirms with **Continue**. Practice review keeps **Confirm simulation**. - **Desk Laya chip follows the current mode.** Practice shows sidecar Ready, Degraded, Down, Still loading, or Checking. Live shows Live-facing status. "Not qualified for Live" is the tooltip and the popover line when Live lacks qualification. Ping publishes Live-facing `laya`, sidecar `laya_practice`, and `laya_live_qualified`, plus a reason code and port. During the first load the chip says Still loading and does not read Down. Orders stay refused with the Down sentence. A port clash says which port is in use. The Live Blocked strip follows Live-facing Down only. It mutes Live place and Position Mirror start. Practice is not muted by that strip. Clicking the chip opens a popover with the plain-words reason, the link "How to start Laya" (`docs/USER_GUIDE.md#start-laya`), and an operator-only "Start Laya" button. The chip tooltip carries `python -m flinttrade_core.laya_runtime start`. Reason codes are `not_started`, `stopped`, `port_in_use`, `still_loading`, `downloading` (Downloading the model · 1.2 of 3.4 GB), `download_failed` (Can't download the model), `unreachable` (Unreachable), `wrong_revision` (Wrong model version), `unverified` (Can't verify the model), `key_rejected` (Can't reach Laya), and `key_missing` (The Laya API key file is missing.). A health check does not replace `key_missing` with Not started. When `LAYA_API_KEY_FILE` names `/runtime/laya/api.key`, `start` replaces that file and does not treat it as missing. A different path that is not there still refuses with that same sentence. `downloading` has no Next line. The `download_failed` tooltip is "Check your connection, then Start Laya again." A signed-in operator can start the managed Laya process from the popover (`POST /api/v1/laya/start`); the chip then says Checking in the neutral colour, and the popover says Checking Laya…, until the ping confirms Ready, Degraded, or a reason other than `not_started`. A confirmed first load still says Still loading. A dead managed Laya process is reaped and reported Stopped. A clamp says "Not placed. Laya allows up to N." with Place N and Cancel, and never auto-places. Place N sends that quantity, and the Practice review panel shows it. A Down refusal is "Laya is Down. New orders are paused until it's Ready. You can still close positions." and does not show a quantity ceiling. A Live place that is not qualified says "Laya isn't qualified for Live yet. Practice orders are available." The Order Pad note is the collapsed line "Add a reason (optional)" under Quantity. Once open, the field's accessible name is the same. Under a denial, the server reason is named "Laya decision" inside one alert (`role="alert"`). That alert is the only live region. The reason line is not its own status. A place with no note still gets Laya's policy decision: Practice clamps and Live denies. It is not a hard reject. Desk place surfaces go through this admission. - **GTT is refused on every submit route.** `"variety": "gtt"`, in any case or separator spelling, is HTTP 422 `gtt_unsupported` on place, routed place, exit-all, and a bracket, before Laya, SafetySystem, and any broker call. The message is `Not placed. GTT orders aren't supported right now.` No submit route reaches a broker forever or super-order endpoint. The Kotak Neo adapter refuses a `gtt` place. Order Pad keeps GTT visible and disabled, with the tooltip `GTT orders aren't supported right now.` `POST /api/v1/orders/forever` does not place: a valid body is HTTP 501 `Orders are placed through /api/v1/orders/place.` A Live bracket with exactly one stop-loss or one target submits on `POST /api/v1/orders/bracket` after admission. Practice on that route is HTTP 403 `practice_unsupported`. A broker-held variety, a stop-loss and a target together, and a trailing stop are refused before admission. A second exit on the same broker account, while this desk's exit on that contract is unfilled, is HTTP 409 `exit_pending`: `Not placed. An exit for is already pending. Wait for it to fill, or cancel it and try again.` The row keeps **Exit pending**. The Live hold is for that broker account. When the broker order book cannot be read, that refusal is HTTP 409 `exit_orders_unreadable`: `Not placed. Broker orders for are unavailable. Reconcile them before another exit.` A signed-out reset with no authenticator enrolled reads `Sign in to reset this account. You'll need your password.` With an authenticator enrolled it reads `Sign in to reset this account. You'll need your password and authenticator code.` Recovery asks for an authenticator code only once one is enrolled. - **Session auth and probes.** A session JWT on `Authorization: Bearer` or `X-FlintTrade-Token` passes the global check. An API key on `X-FlintTrade-Token` does not. A reduce-only close can be admitted while new orders are paused. PIN unlock replaces the session token. Reset of a finished account needs a session and the password. A wipe ends other sessions. Practice restore marks fills and leaves them out of the Laya, strategy, benchmark, and training readers. `GET /healthz` and `GET /readyz` are public and return status only. - **First-run Setup resume, Start over, and a fixed vault step count.** Reloading `/setup` mid-flow resumes the unfinished setup session. **Start over (deletes this unfinished operator)** deletes that unfinished operator and restarts at step 1. A workspace data wipe is not required for either path. When the vault is already secured on this machine, the vault step is skipped and the count is fixed from the start: **Step 1 of 2 - Create operator**, then **Step 2 of 2 - Practice desk**. That path never shows "of 3". On that Practice step only, **Your vault is set up and secured on this machine.** appears above **Open Practice desk**. When the vault is not yet secured, Setup still shows **Step 1 of 3 - Create operator**, **Step 2 of 3 - Vault**, and **Step 3 of 3 - Practice desk**. After Setup completes, `/setup` does not restart step 1. A signed-in operator is sent to `/trade`. A signed-out operator sees **Setup is complete. Sign in to open the desk.** with **Sign in** as the primary button. Refs #297. - **AI Chat Practice + native live-read context.** When an LLM is configured, AI Chat may use Practice SandboxEngine fills and native live-read feeds for analysis. Suggest stays labelled illustrative. Chat never shows green **Connected** without a real LLM. AI does not place Live orders — Live place stays fail-closed. Measure later: profitable alphas are not a release criterion. This does not lift the native broker HTTP freeze. Refs #254. - **Desk chrome: one TopBar + one ticker + flex shell.** Dual TopBar index slots are replaced by one dedicated scrolling TickerStrip under TopBar. TopBar keeps Mode, status, and overflow — not a second quote rail. Three Settings/Tools entries collapse into one Tools overflow menu plus at most one primary Settings entry. App chrome is a flex column: TopBar, then the operator status strip when one is showing, then the Mode honesty line, then TickerStrip, then the route body. Trade ships first; the same shell then rolls to Invest, Automate, Learn, and Ditto. Not a silent widen of Compact-only-on-Trade, and not a big-bang rewrite. - **Mode vocabulary and Trade desk density.** Example is sample data. Practice and Live are the Modes. Example Order Pad uses Example Buy / Example Sell; Practice keeps Practice Buy / Sell; Live uses Place BUY/SELL Order. TopBar session chips (Continuous · CAS · Matching · Post-close · Closed) stay session status. Comfortable is the new-install default. Compact Trade at ~1280 and wider keeps chart, order pad, and positions primary, with ticker, tool ribbon, and watchlist / indices collapsed behind one desk-tools toggle. Selecting Compact on Trade at ~1280 and wider always starts with that disclosure collapsed. At most one primary banner (Example sample > Practice sample > Live risk > feed disconnected). External-action gates stay fail-closed for Example. Phone product and the marketing site are unchanged. ### Fixed - **Ticker venue badges match the marquee.** Pinned badges follow the venues that feed the tape: NSE, BSE, and MCX on the default tape, and NFO when an F&O symbol feeds. A venue with no feeding symbol is omitted. Symbols that do not resolve to a venue show **Unavailable**. An empty tape omits the badge strip. The marquee runs continuously when motion is allowed. With `prefers-reduced-motion: reduce`, the tape freezes and shows **Reduced motion**. The Sample freshness chip may stay; it must not hide venue honesty. - **Tools Quick Settings on the Trade desk.** Tools → Quick Settings opens density, theme, and similar controls without leaving the desk. Tools → Settings still opens the full Settings route for deep pages (Monitoring, brokers, auth). Compact Trade keeps Quick Settings on the TopBar when the tool ribbon is collapsed. Dropping Quick Settings so only full Settings remains fails desk-first. Refs #279. - **Docs: correct GTT proxy, Practice walkthrough, and Live safety path.** `USER_GUIDE` Practice walkthrough no longer treats Example Buy as a sandbox Positions/Orders fill. `API.md` no longer claims `/orders/gtt-*` is gated like regular Live place — those verbs return HTTP 501 after unlock and do not place. `POST /api/v1/orders/forever` does not place. `API.md` documents `GET /api/v1/advisor/status` `source` (`env` / `stored` / `default`). `ARCHITECTURE.md` mode-guards Practice to `SandboxEngine` and runs L1–L5 only on Live. `DEVELOPER_GUIDE.md` no longer says every order is checked by L1–L5: those layers are Live-only. Practice goes to `SandboxEngine`. Example placement is `mode_blocked`; Example Buy is a local fill. - **Restore Connected honesty on `/ai`.** LLM readiness is global config truth (stored Settings `#llm` plus explicit `advisor/status`), not Mode-derived. Example and Practice share one source. A blank stored provider plus the empty→ollama default is **Not configured** / **Not installed** — never green **Connected**. An explicit `LLM_PROVIDER` env-only setup stays **Connected** when `advisor/status` reports it usable. Composer stays gated until a provider is actually configured. - **Connected badge matches LLM install state.** `/ai` Chat (AI Hub) follows the real LLM status from Settings → AI / `#llm`, including Managed Ollama install state — not a green **Connected** while Settings shows **Not installed**. Managed Ollama **Not installed** shows **Not installed** (warning badge, not green) with a primary **Open Settings → AI** CTA to `/settings#llm`. Unconfigured stays **Not configured**. Composer input and Send stay disabled until the runtime is installed and configured — the same bar as Settings. A provider string of ollama is not Connected while the managed runtime is absent. A configured-but-broken probe still shows **Error** / **Disconnected** with Retry. Signals **Live** / **Polling** stay separate from Chat LLM readiness. - **Authed `/home` skips password Welcome Back.** `/home` is the canonical Home / Welcome dashboard. A signed-in operator who opens it (address bar, refresh, or same-tab bookmark) sees the same Home as SPA nav (sidebar, Alt+H, TopBar) — not the password Welcome Back gate. That gate stays on `/welcome` for unauthenticated visitors only. - **Leverage tab content or honest empty.** `/settings#leverage` shows real leverage content when the broker snapshot is available. When leverage cannot be shown — unsupported broker, missing snapshot, or load failure — the pane shows the honest empty `Leverage settings unavailable.` plus **Retry**. Selecting the Leverage tab never leaves a highlighted tab over a blank content pane. - **Kill All fail-closed when risk runtime unavailable.** Whenever the Ditto risk runtime is unavailable — including Example `/ditto` Risk — **Kill All Positions** stays muted and disabled. Helper: `Risk runtime unavailable — Kill All disabled.` The control is never the armed red emergency CTA in that state. The backend rejects a Kill All if the UI slips. Live and Practice with a live runtime and managed accounts still keep the armed control (empty-account disarm is ). - **Log refs no longer create a missing workspace.** A cold `log_ref` salt cache no longer calls `workspace_dir()` in a way that mkdir's the default workspace. Isolated callers (explicit-path TOTP stores) stay off the default pair. - **Example Schedules Pause gated for sample jobs.** Example `/automate` → Schedules seeded jobs: the Mode honesty line owns disclosure — no extra Sample chip once Pause is gated. Seeded Example jobs show status Sample/Demo (or muted), not a production-looking Active badge. Pause on those jobs is disabled, with title helper `Example schedule — control unavailable`. Practice/Live jobs keep Pause/Resume. The backend rejects Example pause/resume with `mode_blocked`. - **Example Stock Baskets disable demo Edit/Delete.** Example `/invest#basket` seeded cards (NIFTY IT, Banking, and any other sample set): the Mode honesty line owns disclosure — no card-level Sample chip. Bare ₹ / P&L under that banner is acceptable once actions cannot look live. Edit and Delete on seeded Example baskets are disabled, with title helper `Example basket — editing unavailable`. User-created Practice/Live baskets keep full Edit/Delete. Empty Example is an honest empty or a clearly labelled sample set. - **Feed-freshness honesty.** Example disclosure is the Mode honesty line. Widgets also label Sample/Example fallback figures. Per-symbol ticker Sample chips are optional. The Market Clock freshness chip appears only when that widget is mounted. Practice / Live still need TopBar or the ticker: Live · Delayed · Sample (and muted Stale / Unknown plus age when known). Silent-stale is a fail. Feed provenance stays separate from Example and from the Practice and Live Modes (Mode honesty). - **Resource Hub User Guide first-open honesty.** Example `/learn` → Resource Hub → User Guide shows `Loading document…` while the local backend/doc settles — never a red backend error on a cold race. A timeout or race is a muted soft fail: `Document isn’t ready yet.` plus a primary **Retry** (one automatic retry is allowed). Hard fail copy `Couldn’t load this document from the local backend.` plus **Retry** appears only after retry is exhausted. Order Safety Notes already loading in the same session does not mark User Guide permanently broken. - **Holdings badge matches the visible table.** Practice/Example `/invest` → Holdings with no broker shows `N holdings` for the rows currently in the table — never `0 holdings` over a populated sample table. Dashboard and "N stocks" use the same N. Practice waits until the holdings query has settled empty before the sample fallback, so a cold load does not flash the wrong N. Dashboard `Net Worth (Cash + Holdings + Positions)` uses the same shared demo book as Holdings. There is no Sample chip on the Holdings table or header. When the sample book is shown — Example always, and Practice after that empty settle — Holdings and Dashboard keep `DemoBanner` (`Showing sample data — connect a broker for live data`) in Example as well as Practice. Example also has the Mode honesty line (`Example data. No broker is connected and no orders are sent.`). The Practice Mode line (`Practice — simulated fills, no real money.`) does not call that book sample; `DemoBanner` is the required Practice disclosure for it. A broker read failure shows muted `Failed to load holdings` plus `Refresh` — never `0 holdings`, `No holdings`, or a sample table under a failed load. A connected broker with no positions shows `0 holdings` and an honest empty state (no sample table under a zero badge). Connected positions use the live count only. - **Example Execution Logs mode honesty.** Example `/automate` → Execution Logs shows the muted empty state `No execution logs for Example. Switch to Practice or Live to see real run history.` and never the outage line on a healthy sample session. Practice/Live with a 200 OK and 0 rows use `No execution logs for this date.` — not an outage. The red `Failed to load logs. Backend may be offline.` copy (with Retry) is reserved for a real load failure. Loading shows a spinner / `Loading logs…` and never flashes the outage line. - **SEBI CAS session clock / TopBar phases.** TopBar market-status chips are Continuous · CAS · Matching · Post-close · Closed (one active). Tooltip/title is the window, e.g. `CAS · 15:15–15:35 (as of Aug 2026)`. When equity F&O still runs after cash continuous ends, a secondary `F&O open · till 15:40` chip appears. Market Clock follows Continuous (~09:15–15:15) → CAS 15:15–15:35 → Matching → Post-close 15:50–16:00. Non-CAS cash still continuous to 15:30. Cash is never green "open" after 15:15; CAS is not Closed. Flat "Market open until 15:30" and "VWAP last 30 min" closing-price copy are gone. The September 2026 consultation stays out of the UI. - **Position Mirror Start fail-closed gate.** Example `/ditto` → Position Mirror Start stays muted and disabled when unavailable — never the primary green armed CTA. Example is always disarmed (sample-only). Helper: `Mirroring is blocked for Example. Switch to Practice or Live with broker accounts connected.` Practice stays disarmed — the backend is Live-only. Helper: `Mirroring requires Live with broker accounts connected.` Live enables Start only with a source selected, at least one target, and broker accounts ready. Missing source/targets: `Select a source account and at least one target to start mirroring.` A successfully empty account list: `Connect a source and at least one target account to start mirroring.` Pending and failed account fetches stay muted with `Loading accounts...` / `Could not load accounts.` and never look like an empty connect state. The backend rejects Example, Practice, or incomplete starts (`mode_blocked` or equivalent). - **Example Scalper fail-closed order path.** Example `/trade` → Scalper is disarmed — Buy CE / Sell / 1-CLICK never open Confirm Order and never place. Buy/Sell stay disabled. Helper: `Orders are blocked for Example. Switch to Practice or Live with a broker connected to trade.` 1-CLICK stays OFF / disabled; title `One-click is unavailable for Example`. Sample quote preview is allowed; Confirm BUY / Confirm SELL chrome is not. Practice and Live use Confirm only when the mode allows it and a gateway is configured. The backend rejects Example orders with `code: mode_blocked` if the UI slips. - **OI Chart shares Option Chain expiries.** Example `/trade` Analysis layout → maximize OI Chart now shares Option Chain expiries for the symbol/exchange. A non-empty list shows the expiry control; charts and statistics cover only the selected expiry (the chain’s selection when both widgets are open; otherwise the nearest listed). Example sample expiries stay listed. The Mode honesty line is the disclosure — expiries are not badged Sample. No expiries or no OI is an honest empty — `No expiries for this symbol` or `No OI for this expiry` — with no bars and no PCR/max-pain stats, never “No expiries” over fake charts. - **Watchlist LTP / % change use ticker sample quotes.** Example `/trade` → Watchlist with Sparkline + LTP + % change checked now paints those column headers and cells. LTP and % change read the same sample quote source as the ticker tape (Jotai tick atoms from the Example demo feed) for NIFTY, BANKNIFTY, SBIN, RELIANCE, HDFCBANK, and any other symbol on that feed. The Mode honesty line is the disclosure. Missing quotes show `—` (or a brief `…` while the first fetch is in flight), never silent blank chrome. Unchecking a column hides it; checking it again shows the sample value or the honest empty. - **Options Builder Net Debit and Max Loss share a position ₹ basis.** Example `/lab` Options Builder now shows Net Debit/Credit, Max Loss, and Max Profit on a shared position ₹ basis. Net Debit/Credit is the signed premium × lots × lot size. Max Loss and Max Profit are expiry-payoff results (intrinsic at the strikes, strike width, or unlimited) scaled to that same position basis — not premium × lots × lot size on every card. For a long call, Max Loss equals the Net Debit (the premium paid for the position). A muted sublabel `₹X per lot · N lots · lot size L` sits under those figures and is never the only number. Header and Legs chips use the same position basis as Payoff. When lots differ and a single per-lot breakdown cannot be formed, the primary figure is tagged `position` — never two unlabelled ₹ on mixed bases. Blank premiums still show `—`. - **Backtest headline P&L vs trade-log.** Example `/lab` backtest results now show labelled dual metrics. **Total Return (%)** is initial capital → final equity (including a forced last-bar close), with subtitle `Initial capital → final equity`. Live `total_return` percentage points are not passed through `fmtPct` a second time. **Net trade P&L (₹)** sums Trade Log `net_pnl` when present; if `net_pnl` is missing the card is labelled **Trade log P&L** with `Gross — net P&L not in result` rather than calling a gross sum net. When that sum matches the equity change, a quiet `Reconciles with trade log` note appears; when they diverge (fees, open marks, partial fills) both numbers stay visible with `Trade log sum ≠ equity change — fees / open marks`. Monthly P&L stays trade-based so the chart matches the log. - **Learn Glossary Lot Size freshness.** Example `/learn` → Glossary → Lot Size now teaches the Jan 2026 NSE-cycle index lots — NIFTY 65, BANKNIFTY 30, FINNIFTY 60, MIDCPNIFTY 120 — with an explicit “as of” date and a Verify on NSE link to circular NSE/FAOP/70616 (3 Oct 2025). Stale `NIFTY=25` / `BANKNIFTY=15` copy is gone. Learn market facts that exchanges revise must ship dated, not as forever hardcodes. Independent of #213. - **Performance follows the Review date range.** Trade Review **Performance** uses the same date range as Log and the other Review tabs. Changing Review dates updates Performance. A visible **Review range** | **YTD** control keeps YTD as an explicit choice; the active chip always shows the effective window (for example `YTD · 01 Jan–11 Sep 2026`). Opening Performance no longer auto-jumps to YTD. An empty range or no fills is an honest empty for that window, not a quiet YTD fallback. Metrics cover the labelled window up to the journal's 1,000-fill analytics page; a larger window is disclosed rather than silently sliced. - **Heatmap Group by Exchange shows labelled group bands.** Example `/trade` Positions → Heat → Group by Exchange (and Group by Sector) now draws a labelled band per group: a name chip (`NSE`, `NFO`, …) plus an optional exposure sublabel, with a stronger gutter than the leaf-tile borders. A single group still carries its label. Positions with no exchange metadata show `No exchange groups in these positions` instead of an undifferentiated treemap. Flat stays leaf-only, with no group chrome. - **Example Mutual Fund NAVs no longer claim a daily AMFI feed.** Example data on `/invest#mutual-funds` labels the fixture `Example NAVs · as of 10-Sep-2026` and drops “Updated daily after market close.” The sample date was refreshed once so the as-of is not months stale; it does not auto-update. Practice and Live keep the daily-update sentence when the live AMFI feed is in use. - **Options Builder Example Long Call no longer looks zero-risk.** Example `/lab` Options Builder now treats a blank premium as unknown: Payoff summary cards show `—` and `Enter premium to model payoff` instead of modelling ₹0. The Long Call template seeds the Example sample-chain ATM CE LTP, labelled `Sample premium — edit to model`, so Max Loss and breakeven follow maths on a non-zero cost. Typing an explicit ₹0 still uses that maths and warns `Premium is ₹0 — payoff treats cost as free`. - **Security PIN requires exactly six digits.** Example `/settings#security` keeps New and Confirm as digits-only fields with `maxLength` 6. Set/Change PIN stays disabled until the account password is present, both fields are exactly six digits, and they match. Blurring a field with 1–5 digits shows `PIN must be exactly 6 digits`; blurring Confirm when both are filled and different shows `PINs do not match`. A five-digit value no longer looks valid. `POST /v1/auth/pin/set` still rejects anything that is not `^\d{6}$`. - **Progressive TopBar collapse at ~390px.** The terminal chrome no longer clips workspace, status, or ticker behind a horizontal TopBar scroll at about 390px. Logo mark, the chip (Example, Practice, or Live), and compact session/status stay visible and tappable. The ticker strip hides first (default off under ~480px). Workspace, account, Tools, search, fullscreen, and clock move into a More overflow menu with hit targets of at least 44px. Workspace and ticker stay reachable via More; no control is clipped and unreachable. - **Suggest recommendations refresh when mood changes.** Example `/ai` Suggest treats market mood as a filter, not a draft. Changing mood (chip or **Next mood**) immediately replaces the recommendation list and the selected mood chip from one mood state. A previously focused strategy card is cleared, so a prior mood's card (for example Iron Condor after leaving Sideways) cannot remain. An empty mood + risk match shows an honest empty state with **Try another mood**. - **Honest unconfigured LLM state on `/ai`.** `/ai` Chat probes `advisor/status` (including Example / `demo-user`) and aligns the badge and composer with Settings `#llm` hydration — not a stale local store, and not an env-default `configured: true` while Settings looks empty. Unconfigured shows a warning **Not configured** badge, empty **LLM not configured**, a primary **Open Settings → AI** CTA to `/settings#llm`, and an outline **Retry**. When a leftover transcript hides that empty state, the header still offers **Retry** and **Open Settings → AI**. Returning to Chat after saving Settings → AI re-checks readiness. The composer input and Send stay disabled, so there is no send-then-`no reply` path. A configured but broken probe shows **Error** / **Disconnected** with Retry — never a green Connected. Signals **Live** / **Polling** stay separate from Chat LLM readiness. Do not add a fake Connected sample advisor in Example; any later demo replies must be labelled **Sample replies**. - **Telegram Send Test stays enabled in Example.** Example `/automate#settings` Telegram Alerts now disables Send Test and keeps the prefilled message as a preview-only sample. Helper: `Telegram tests are blocked for Example. Switch to Practice or Live with Telegram configured to send a real test.` Practice and Live enable Send Test only when Telegram is configured; otherwise the control stays disarmed with "Configure Telegram first". The backend rejects Example test sends with `mode_blocked`. - **P&L columns unusable at ~390px.** Example `/trade` Positions and Invest Holdings switch to stacked cards below 480px, so each row shows symbol, quantity, LTP, P&L and P&L% on one screen. The wide nowrap table no longer clips those figures off-screen behind a tiny scrollbar. - **Invest deep-link hash tabs ignored on load.** Opening `/invest#holdings` (and the other Invest tab hashes) now selects the matching tab on load. A direct `#holdings` URL no longer falls back to Dashboard. - **Duplicate Watchlist widgets from Add widget picker.** Example `/home` Add widget no longer adds a second Watchlist when one is already on the dashboard. Already-present widget types are disabled or hidden in the picker; choosing Watchlist focuses the existing card instead of duplicating it. - **Home greeting uses local evening at noon IST.** Example `/home` greets from the Asia/Kolkata clock, so ~12:01 IST is Good afternoon (or Good morning before noon), not Good evening from a non-IST browser clock. - **Chart stays stale when timeframe selector changes.** Example `/trade` timeframe buttons now refresh the chart series and visible range to match the selected interval. Switching 5m → 1D no longer leaves candles and intraday timestamps on the prior range, or a brief blank that stays stale. - **Empty Monitors has no Strategy Builder/Lab CTA.** Example `/automate` Monitors empty state now includes an "Open Strategy Builder" link to `/lab`. Operators no longer have to find Strategy Lab independently from copy-only text. - **Settings `#llm` load failure with no recovery.** Example `/settings#llm` no longer treats a demo or unconfigured session as a broken load. It shows an empty LLM state with Retry and guidance that Example cannot persist LLM secrets. Live still fail-closes on a real load error to protect a saved configuration, and now offers Retry. - **Kill All Positions armed on empty Example Ditto dashboard.** Example `/ditto` Risk Dashboard with ₹0 totals and no accounts listed now disables Kill All Positions and shows an empty state. The control is no longer a bright red armed emergency CTA on an empty dashboard. Live and Practice with managed accounts still keep the armed control. - **Practice orders in Example without a live broker.** Example `/trade` Order Pad Practice Buy opens the Practice review and records a sample fill — no live broker is required. Live still uses the gated `placeOrder` path and still requires a broker connection. Native broker freeze is excluded. - **Daily Sign In 2FA field while authenticator is deferred.** Login Sign In probes `/auth/status` every time it is shown and hides 2FA unless `totp_enabled` is explicitly true. A stale Welcome `totpRequired={true}` after Sign Out can no longer keep the field. Enrolment still requires TOTP; Live still needs enrolment plus PIN. - **Duplicate zero placeholders on backtest metrics.** Example `/lab` backtest headline metrics (Sharpe ratio, max drawdown, win rate, profit factor) now show a single formatted value. The leftover count-up `0.00` / `0.00%` beside the real figure is gone. - **Zero-premium long-call payoff (unbounded max profit + breakeven).** Options Builder Payoff now summarises expiry P&L from strike kinks and the right-hand slope, not the ±15% chart sample. A zero-premium long call shows Unlimited max profit, max loss equal to the premium (₹0), and breakeven at the strike. Paid-premium long calls and other unbounded legs (short calls, straddles) use the same rule. - **Trade Review date filter and mangled timestamps in Example.** Example `/trade` Trade Review now clips the Log to the committed IST date range (the same predicate as the sample-journal badge) and renders fill timestamps as `D Mon YYYY HH:MM:SS` with a literal space, so `13 Apr 26` can no longer glue onto `14:55:42`. Live and Practice still use the journal/tradebook path; only the shared IST format and range helpers changed there. - **Example /ai chat produces no assistant reply.** `/ai` and the floating tutor now share one advisor chat path: a short status probe (including Example/sample-data), SSE streaming, then the non-streaming fallback. A missing LLM, an unreachable backend, an empty completion, or an SSE error becomes a visible assistant error instead of a blank bubble. Empty assistant placeholders are no longer persisted, so a reload cannot restore the silent blank. The 45-second stream budget is first-token only: once a token arrives, a longer healthy completion is not aborted mid-reply. Native broker freeze is excluded. MF Optimizer and AI suggestions + deploy are unchanged. - **Unfinished Setup recovery without the authenticator.** **Try with example data** marks a durable Example session so `/home` survives a refresh. **Start over** wipes the unfinished account via the account-create setup JWT, so a lost QR seed is recoverable without the TOTP secret. Daily-login session tokens cannot wipe the account. Daily login is password-only until an authenticator is enrolled. Live still needs that authenticator and the PIN. - **Strategy Lab stays empty after AI Deploy.** Deploying a suggestion from `/demo-app/ai` (for example “Trend EMA Crossover”) opens `/demo-app/lab?strategy=TrendEMACrossover`. Strategy Lab now hydrates the Backtest selector from `?strategy=`, keeps a linked registry key in the catalogue when the loaded list does not include it, and enables Run Backtest once a strategy is selected. Sample demo backtests are unchanged; a Lab opened without the query is unchanged. - **Invest dashboard holdings count vs listed sample stocks.** On `/demo-app/invest` the sample dashboard header now uses the example-data holdings book (`getDemoHoldings`) as its count source, so the badge matches the listed sample stocks and the Holdings tab. Live and Practice still read the live book — an empty funded account stays at 0. - **Remaining homepage nav overflow at ~390px.** After #180, the primary nav wrapped without clipping Contribute, but at about 390px four chips still packed onto the first row and left “Demo (example data)” cramped against Docs/API. Below 480px the marketing nav now uses two-across chips with slightly smaller type and tighter padding, so every primary label stays on one line with slack. The 900px wrap from #180 is unchanged. - **Docs page summary duplicated as first body paragraph.** On `/docs*` routes the page summary no longer appears twice. The generator still lifts the first body paragraph into frontmatter for SEO, but marks `hideDescription` when that extract matches the opening body paragraph. The docs page skips `DocsDescription` unless the page opts in with a distinct subtitle (fail-closed if the flag is missing). - **Primary nav overflow clips Contribute.** The marketing header no longer uses a shrinking `overflow-x: auto` row below 900px. Primary links wrap without shrinking, so Contribute and the other destinations stay fully visible on phones around 390px. - **Dependabot qs and fflate (medium).** `qs` 6.15.2 (via `http-server` → `union`) is overridden to 6.16.0, clearing GHSA-4mjr / GHSA-px8p. `fflate` 0.6.10 (via `three-stdlib`) is overridden to 0.6.11, clearing GHSA-x5fp. No new allowlist entries. - **Node audit blockers on main.** Newly disclosed HIGH/CRITICAL advisories against the existing lock (next Windows/AVIF RCE, maplibre-gl XSS, `@xmldom/xmldom` name-injection/ReDoS, sharp libheif, js-yaml merge-key DoS, and four fast-uri host-confusion/SSRF issues) are cleared by real version bumps: next 16.3.4, and overrides for fast-uri 4.1.4, js-yaml 4.3.2, sharp 0.35.4, maplibre-gl 6.8.0 and `@xmldom/xmldom` 0.8.15. No new allowlist entries. - **Production systemd install.** `infra/scripts/setup-production.sh` hardcodes `/opt/flinttrade` (the prefix `flinttrade.service` already uses), refuses `FLINTTRADE_DIR`, symlink targets and non-git trees, and requires Python >= 3.12 before creating `$INSTALL_DIR/.venv`. The unit exports `FLINTTRADE_WORKSPACE_DIR=/opt/flinttrade/.flinttrade` so Workspace writes stay inside `ReadWritePaths`, `FLINTTRADE_BACKEND_PORT`, and starts `python -m flinttrade_core.app` with every workspace package on `PYTHONPATH`. First-time setup (and later deploys) build `packages/apps/terminal/dist` with the pinned pnpm and run `python -m flinttrade_core.cli init --provision-master-password` as `www-data`, so the non-interactive backend can start and serve the UI. Checkout-mode normalisation skips `.flinttrade` and `data` so hardened `0600` secrets stay owner-only. Code and `.venv` stay root-owned; only runtime workspace/data/log paths are `www-data`. `infra/scripts/deploy.sh` updates that tree with `sudo git` and does not take ownership. - **Workspace path unification.** Nineteen modules resolved their own storage as the literal `~/.flinttrade` instead of asking `flinttrade_core.workspace`. On Linux that happens to be the workspace, so it never failed in CI; on macOS (`~/Library/Application Support/flinttrade`) and Windows (`%APPDATA%\flinttrade`) every one of them wrote to a second, invisible directory that the rest of the app did not read and the uninstaller could not find. Affected state included the TOTP secret store and its install key, the trade journal and its screenshots, saved presets, keyboard shortcuts, quantity-freeze limits, the pending-order approval queue, the watchlist, expiry and FII/DII stores, and the operator's own FlowBuilder flows, trained signal models and strategy files. Every module now resolves its path inside a function body at call time, so `FLINTTRADE_WORKSPACE_DIR` and `FLINTTRADE_HOME` are honoured on every construction rather than frozen at import. On a default install each artefact is **copied** into the platform workspace once, under a cross-process lock; the pre-workspace original is left untouched, so the upgrade is reversible. Where a workspace copy already exists it wins and no merge is attempted — an approval-queue merge could dispatch the same order twice. The TOTP store and its install key move as one unit, verified by a decrypt round-trip before the legacy pair is trusted, and the trade journal moves with its screenshot directory or not at all. Migration probes are skipped entirely when a workspace environment override is in force. - Both uninstallers now enumerate every pre-workspace dropping written directly at `~/.flinttrade/` — flows, models, strategies, journal screenshots, presets, the TOTP pair and the remaining stores — as named `--purge`/`-Purge` candidates. They were deleted before, but only as part of the managed root, so the confirmation list never mentioned the operator's own strategy code. - `FlowBuilder` and the trade journal no longer fall back to a home-directory path when `flinttrade_core` cannot be imported. A broken install now fails loudly and the affected routes degrade to 503, instead of silently opening an empty shadow store. ### Changed - Vulnerable ChromaDB persistence is replaced by FlintTrade's local SQLite/NumPy vector store. Existing vector directories are deliberately not auto-migrated because Chroma's on-disk index and embedding space are not compatible with the replacement. If `chroma.sqlite3` is present, FlintTrade refuses to create `flinttrade_vectors.sqlite` beside it: the database and vector-segment files are left untouched, RAG stays disabled, and agent learning uses its logged in-process fallback. To recover existing lessons or custom documents, export them with the previous release. To intentionally start empty, move the complete legacy directory aside as a backup before restarting; do not delete individual segment files. Each collection now persists one embedding dimension and refuses mixed-width writes after the first vector, inner-product distance stays unnormalised, and shutdown joins the optional background RAG indexer before closing the store. - The traffic and latency observability logs (`traffic_log.duckdb`, `latency_log.duckdb`) are not migrated: they are disposable, and both were already workspace-routed in production. On macOS and Windows their history restarts from empty. ### Security - **Clear-text secret logging in service-connection tests.** CodeQL `py/clear-text-logging-sensitive-data` alerts [#420](https://github.com/navaneeshnagarajan/FlintTrade/security/code-scanning/420) and [#421](https://github.com/navaneeshnagarajan/FlintTrade/security/code-scanning/421) flagged `packages/core/core/tests/test_service_connections.py` for passing a `secret_version` object to `logger.debug`. The redaction contract still asserts that binding identity and supplied credential material never appear in `str`/`repr`, public DTOs, exception text, or captured logs; the logger now receives only the masked `ServiceSecretVersion()` fixture. ## [0.0.1] — 2026-07-23 Clean-slate baseline. Pre-1.0, pre-usable, and marked as a pre-release: anything may change without notice until the project reaches a stable 1.0.0.