Release notes first, one section per version, in plain words. Below them, the engineering log from the repository, most recent first; it names files and functions because it is written for the next engineer as much as for you.
The venue is a choice, market data is three names, and the brain's aim in words (2026-09-06)
- Venue choice (
api/bai/mode.py, POST /api/broker). The installed desk keeps the
chosen venue in broker.json; configured_venue() is the one reader, cached on the
file's mtime; the legacy desk still reads BAI_BROKER. Choosing disarms, pauses a
campaign, never flattens, and leaves a broker_changed audit row. Schwab's
client_id/client_secret are ENV_KEYS entries and fields on its descriptor. The
desk state carries broker_kind, broker_connected, broker_paper.
- Broker card and Start-here guide rewritten around the chosen venue: tiles, fields,
Save, Sign in at Schwab, Test connection; the guide skips Allow-live on paper.
- Market data card: Finnhub, Polygon (Massive), Alpha Vantage as named sources with
sign-up links and a Test (
POST /api/providers/test: one quote, five daily bars,
elapsed ms, or the reason). Chain order behind Advanced. Provider.plain and
VendorsProvider.describe()["vendors"] feed it.
- Copy: the site's hero states the aim as net positive over a year of trades, not
"built to not lose".
docs/ROADMAP-V2.md is the sharpened brief for what comes next.
- Campaign phase and story (
api/bai/campaign.py, columns phase, phase_note,
phase_at on campaigns, migrated on start). Every tick stamps what the campaign is
doing (PHASES), and campaign_stats adds day_number, days_total and a one-line
story. The campaigns page is rebuilt around it: three templates (Careful, Balanced,
Active) off the desk's own sizing, a plain sentence of what is being chosen, daily
limits under Advanced, the record check and replay preview side by side; while it
runs, the day bar, the phase sentence, four numbers, what it holds, and what
happened in words; past campaigns as cards with "Start another like it".
- Website: a security page with the controls by area, what we hold and never hold,
and dated security fixes; a
/status page that checks the service from the visitor's
browser; an /account page (devices, sign out everywhere, two-step, password, export,
deletion) against the API with tokens held in the tab; /docs on one page; and
/changelog rendered from this file (synced by scripts/sync-legal.mjs, checked in
CI). The API allowlist admits DELETE for the account page.
- Tests:
FixtureProvider.reanchor() and a call from tmp_env, so the synthetic
tape's last bar is the pinned day and the chase gate stops refusing fixture entries
as real days pass.
- Cap profiles (
api/bai/profiles.py, /api/profiles*): named sets of the caps
Settings can save, kept in profiles.json, applied through the same _apply_settings
door as typed numbers (so an armed desk disarms), exported and imported as one file.
The Settings page gains the Profiles card, an Appearance card (text size and density,
this browser only), and quiet-hours times beside the switch. The daily recap carries
the running campaign's sentence. paths.state_file honours BAI_STATE_DIR, which
tmp_env sets, so no test writes into the owner's data folder.
- Alert channels on the installed desk. SMTP (host, port, user, password, to) and
the Telegram bot (token, chat id) are
ENV_KEYS pairs, typed in the Alerts card and
kept in the credential store like a broker key; the desk state's
notify_email_ready / telegram_kill_ready light up at once. The owner's own desk
keeps them in .env and the card still says so there.
- Recap and supply chain. The end-of-campaign recap gains "What the brain learned"
(
_brain_learned: graded calls over the window, the running policy, the ratchet
floor, anything scheduled for the veto window). The recap window is as_utc on
both ends. CI: pip-audit on both requirement files, npm audit (website at high,
desk UI at critical), CodeQL weekly and on push, Dependabot grouped weekly. The
website runs Next 16.3.4 / React 19; the desk UI Next 14.2.35, whose remaining
advisories concern server features a static export never runs. The Next 16 move for
the desk UI is planned, not done.
- Diagnostic bundle (
api/bai/support.py, GET /api/help/bundle, Help card in
Settings): one zip with the version, which settings are set (never values), the
desk's state and caps, broker and sources by name and status, the last audit rows,
local crash reports, entitlement status and log tails, every line through the crash
scrubber; never positions, orders, fills, account values or chat.
- Repository moved to
Doyel-Labs-LLC/BAI; a branch ruleset (no deletion, no force
push, the four CI jobs required) and secret scanning with push protection are on.
Releases carry CycloneDX bills of materials and SHA256SUMS. CodeQL runs without the
upload a private repository cannot make and fails on error-level findings.
- Website re-homed after the move: one Pages project (
website-8xx.pages.dev)
built from Doyel-Labs-LLC/BAI, both domain names on it. The control plane's allowed
browser origins moved out of a Fly secret into fly.toml, and one entry may carry a
* so Pages preview builds can reach staging (docs/WEBSITE-AND-DNS.md).
- Past campaigns become templates. "Save as a template" on a finished campaign keeps
its exact numbers under a name in
campaign_templates.json with one line about how
it went; the tiles sit beside Careful, Balanced and Active on the start screen, and
"Start another like it" now carries the loss limit and daily caps too. Saving one
starts nothing, the phrase is still typed, and chat has no tool that reaches them.
- Release notes per version.
RELEASE-NOTES.md at the root, one ## <version> (<when>) section each, in the installer's words. The site copies it in with the
legal documents (CI checks the copy), the download page shows the latest section,
/changelog lists every version above the engineering log, and the release workflow
lifts the tag's section into the GitHub release body and fails if there is none.
- Desk UI on Next 16 and React 19, the same versions as the website.
npm audit
is clean at every level, so CI's gate rises from critical to high. next lint is
gone in Next 16; ESLint 9 runs the same two rule sets from a flat config and CI runs
it. The React Compiler's "no setState in an effect" rule is a warning, not an error:
it objects to how every panel loads the desk, and that is a performance hint, not a
bug. Both shapes of the build (static export for the sidecar, next start for the
legacy desk) pass, and the engine's static-export route tests pass on the new output.
Tradier, the admin console, and mail you can trace (2026-09-06)
- Tradier behind the port (
api/bai/brokers/tradier.py, Phase 10). One access token,
the hosted sandbox as the paper account, a native OTOCO the venue holds, and a stop
moved in place with the same id. The contract suite now runs on Paper, Alpaca and
Tradier; tests/brokers/fake_tradier.py carries the real API's quirks (form bodies, a
list of one as a bare object, "null" for nothing, the client id in the tag). A
configured account number the profile does not list is a misconfiguration: nothing is
read from it, nothing is placed on it. Settings: TRADIER_ACCESS_TOKEN,
TRADIER_ACCOUNT_ID (optional), TRADIER_SANDBOX (default true); credential-store
kind tradier on the packaged desk.
- Admin console (
control-plane/app/admin/console.{py,html}, Phase 7's UI). One page at
/admin/ on the API itself: users, a user's devices and audit trail, disable/enable,
kill switch, sign out everywhere, refund note, audit search, feature flags, and the
retention jobs. It does not exist without CP_ADMIN_TOKEN, it is refused off the
allowlist before a token is typed, and every script and style on it is nonce-pinned
by the CSP. The token is typed into the page and lives in that tab only.
- Mail. The Postmark backend logs one line per send (tag, recipient domain, Postmark's
message id, never the address) and raises
MailError on a provider failure; the API
answers 502 {code: "mail"} and a sign-up that could not send its confirmation leaves
no half-made account behind. Alembic's logging setup no longer disables the app's
loggers when migrations run in-process.
- Website (
website/): doyel-labs.com is live on Cloudflare Pages; the control plane
allows its origins through CP_WEB_ORIGINS. The staging API answers at
api-staging.doyel-labs.com.
- Crash reports and analytics (Phase 7's last piece). The engine writes an unhandled
API or loop error to a local outbox (
api/bai/telemetry.py), scrubbed of keys,
tokens, emails, account numbers and the Windows user name, and never sends it: the
shell drains POST /local/telemetry/drain on its lease loop and forwards to
POST /v1/telemetry/crash only when the owner switched crash reports on in Settings
(off by default, GET/POST /api/telemetry). The control plane scrubs again, keeps
rows in telemetry_events for CP_TELEMETRY_RETENTION_DAYS (90), lists them in the
admin console, and forwards to Sentry when CP_SENTRY_DSN is set. Product analytics
on our own surfaces (app/analytics.py: signup, email_verified, login,
billing_event, desktop.*) go to the log, or to PostHog with CP_ANALYTICS_BACKEND= posthog and CP_POSTHOG_KEY; nothing about trading is ever in either. Tests keep
the outbox out of the live desk's folder through BAI_TELEMETRY_DIR.
- The installed app starts. The updater setting named the development address over
plain http; the updater plugin refuses anything but https at start-up, so the
installed shell died before its window existed, silently. It now names the
production update URL (the running shell swaps in the control plane it was built
for), and
install_crash_reporter() writes %LOCALAPPDATA%\com.doyellabs.bai\shell-crash.log and shows a message box on any start-up panic. The installer
carries the BAI icon, moves on by itself once the files are in, removes the downloaded installer file after a
click-through install (never after a silent one), and its last page says so: desktop/src-tauri/installer.nsi is
Tauri's stock template (CLI 2.11.4, now pinned) with those two edits marked.
- Desk lock on Windows (
api/bai/desklock.py). os.kill(pid, 0) terminates on Windows;
the liveness check now queries the process and the lock carries the holder's start
time, so a stale lock with a reused pid is taken over instead of killing a stranger
and refusing to start. tests/test_desklock.py spawns a live process to prove the
probe is harmless. The sign-in page offers Try again / Sign out if the desk has not
replaced it within twelve seconds (reopen_desk command). The heartbeat watcher stops a previous watcher only when that
pid's command line names heartbeat_watch; a reused pid is left alone.
Market-regime juror + search looks (merged on this tree)
The zip extracted into a nested leftover (api/api/, .cursor/.cursor/) and never
reached the live sources. The regime juror is now in api/bai/ itself. Cluster
measurement and stop_room were already better here and were left alone.
Search can now answer ordinary English from the header box via GET /api/look
(api/bai/look.py): today's movers, today's top pick, top news, is the market up.
A look never Arms, never Allow-lives, and never places. Stay out is a valid answer.
Fixture-tape note from the package still holds: FIXTURE is built on the wall clock
at import while tests pin 2026-08-26. If arming/bracket tests ever fail on chase-gate
reasons with no diff, re-anchor the fixture after set_clock in tmp_env.
The learning loop, closed (2026-09-03)
brain_recorder existed and nothing called it. Four rungs, in .cursor/rules/brain-jury.mdc:
- Every real trade is written down with what the jury saw.
build_chart_ticket stashes
the inputs as brain_setup; submit_ticket records the decision only once an order is
accepted; the decision_id rides OrderIntent → Order → Position → PlaybookTrade and
record_close grades it. brain_score.rejudge() replays the exact call. Three new
columns (decision_id) on orders, positions, playbook_trades — migrated on start.
- Shadow mode. Candidates in
research/candidates/ are judged on every taken decision
and written on the row; the one outcome grades both. Never obeyed.
- Promotion is a slow door (
bai.brain_promote): ratchet floor that never drops, live
shadow agreement, then a 24-hour owner veto window. GET /api/brain,
POST /api/brain/{veto,revert,check}, a Brain card at the bottom of Settings, and an
inbox-only brain notification. The lab still cannot promote.
- Wider
BrainPolicy with edges. Payoff, strength, chop, regime, crowding and size-curve
knobs, each in BOUNDS; violations() names every breach; test_brain_bounds.py fails
if a knob is added without a bound.
Suite note: three test_first_live tests read ./data/bai.db through a bare new_session()
and broke the moment the schema grew. They now take tmp_env + init_db().
The broker port, phase 0a (2026-09-06)
First slice of the product plan: the desk stops hard-coding Schwab. bai/brokers/ holds
the port (BrokerPort, BrokerCapabilities, AuthDescriptor, SimulatedExchange), a
SchwabAdapter and a PaperBroker that subclass today's classes, and a registry that is
the single place an adapter is built. bai/mode.py splits the old is_demo_broker flag
into the three questions it was answering — which adapter, is it a simulator, is real
money at stake — and every one of the 48 read sites now names the one it meant. Zero
behaviour change; desk-owner.mdc § "Broker port" explains the axes and the traps.
Also: one VERSION file that bai.__version__, pyproject.toml and web/package.json
must agree with (test_version.py), and a GitHub Actions workflow that runs the suite on
Windows and builds the web app on every push.
The control plane, phase 0b (2026-09-06)
control-plane/ is the server that decides: accounts, entitlement, billing, chat identity,
admin. Phase 0b lays the skeleton and nothing more. A FastAPI app whose only routes are
/healthz (truthful: 503 when Postgres is unreachable, never a customer name) and
/v1/meta; SQLAlchemy models for users and the append-only audit_events, both with a
region column because data residency is data; Alembic migrations with a test that fails if
a model changes without one and a test that runs the chain down and back up; JSON logging
with a redaction list that a test exercises; a request id and the strict security headers
on every response; a Dockerfile that migrates before it serves; a Fly.io definition for
staging in iad beside the Neon project. Tests run hermetically on a temp SQLite file and,
when CP_TEST_DATABASE_URL is set, on Postgres. CI runs them against a Postgres service
and builds the image. Verified once against a throwaway Neon branch (all green), then the
branch was deleted.
The packaging spike, phase 0c (2026-09-06)
The desk becomes something a customer can install. desktop/ holds a Tauri 2 shell whose
only job is to start the engine sidecar on a free loopback port, wait for /api/health,
and point its window at it; the engine, the UI and a self-contained CPython ship as
resources beside the exe. scripts/build_sidecar.py assembles them: the runtime from
python-build-standalone, the engine's packages minus test-only ones, api/bai, the
watchdog, and the Next static export.
Engine changes are small and inert on the legacy desk. web/next.config.mjs exports a
static site when BAI_STATIC_EXPORT=1 (every route was already static). bai/static_ui.py
serves that export from the engine's own origin when BAI_UI_DIR is set, resolving
/settings to settings.html, stamping pages with their own Content-Security-Policy
(inline scripts and styles from this origin only; nothing remote), leaving /api/* on the
strict default-src 'none' policy, and keeping GET / as the Schwab callback when a code
arrives. test_static_export_routes.py pins all of it; the version test now covers the
shell and the control plane too.
Measured on this machine: sidecar 203 MB unpacked (CPython and packages 200 MB, engine
1 MB, UI 2 MB); NSIS installer 40.5 MB; 196 MB installed. A silent install, launch,
health check, graceful close and uninstall all passed from the installed location. The installer is per-user, so the engine's relative data/ paths land
under %LOCALAPPDATA%\BAI and stay writable; Phase 1 moves state under the app data dir
properly and adds the boot-code handshake the shell already generates.
Engine hardening for the shell, phase 1 (2026-09-06)
The packaged desk now runs the way the plan says it must, and the legacy desk cannot
tell the difference.
- One folder for state.
bai/paths.py is the only module allowed to spell a state
path. With BAI_DATA_DIR set, the database, kill switch, ALLOW_LIVE, desk lock,
heartbeat, Telegram offset, certificates, market cache and research folders all live
under it; without it, everything stays beside the source as before. The watchdog reads
the same variable and, when packaged, takes its configuration from the environment the
shell passes instead of a .env that does not exist. test_paths.py pins both layouts
and fails if any module reaches for ROOT / "data" again.
- The shell signs the UI in.
bai/local_boot.py: the engine takes BAI_BOOT_CODE
out of its environment at start-up, and GET /local/boot?code=… accepts it once,
within sixty seconds, from loopback only, answering with a thirty-day session stored
exactly where a login would store it. /api/auth/login answers 404 when packaged, the
local user carries a random password nobody knows, and /local/status tells the shell
whether the desk is armed and how many positions are open. test_local_boot.py.
- The secret lives in the credential store.
bai/keys.py mints a per-install key
in Windows Credential Manager on first run and hands it to the engine through
BAI_SECRET_KEY before settings are read, so schwab, notify and security keep
one code path. No file fallback: an unavailable store stops the packaged engine. Losing
the entry reproduces the documented "reconnect Schwab" case. test_keys.py.
- The Schwab redirect is answered in-process.
bai/oauth_callback.py runs the HTTPS
listener on a thread inside the engine (same port, same self-signed certificate, same
single-use state) and shows a plain "go back to BAI" page instead of redirecting a
session-less browser into the desk. A taken port is reported, not fatal. Binding is
exclusive, because on Windows a reusable address lets a second process share a port.
test_oauth_callback_thread.py.
- The shell starts the watchdog beside the engine with the same environment, kills
both on exit, and opens the desk through the boot route.
Accounts on the control plane, phase 2 (2026-09-06)
/v1/auth/*, /v1/users/me and /v1/devices. Sign-up with a verification link that
works once and expires in a day; Argon2id passwords with a dummy hash so a missing
account costs the same time as a wrong password; Ed25519 access tokens that live
fifteen minutes and are verified without the database; refresh tokens stored only as
hashes, rotated on every use, in families that die the moment one member is presented
twice (the owner is emailed); a device row per sign-in with a new-device alert;
lockout after five failures in fifteen minutes, cleared by the next good sign-in; a
reset that signs everything out and cannot skip two-step sign-in; TOTP with ten
recovery codes, the seed sealed under the app secret at rest. Sign-up is US-only by
configuration, and an existing address is never confirmed by a response, only by its
own mailbox. Mail goes through one function with a log backend for tests and Postmark
for production. test_accounts.py, twelve scenarios, green on SQLite and on Postgres.
The entitlement lease, phase 3 server and engine (2026-09-06)
The server decides whether a desk may place orders, and says so in a lease.
Control plane: POST /v1/entitlement/lease mints a fifteen-minute Ed25519-signed JWT for
the signed-in device from the entitlement row (beta mode makes everyone active until
billing arrives), carrying active, status, seat_ok, kill, min_app_version and
the rest. One seat per subscription: the first device takes it; a second device is
told who holds it and what that device was doing (armed, open positions, from the
heartbeat every lease request carries) and must call seat/take to move it, after
which the first device's next lease says it lost the seat. Kill and status flags are
admin writes on the row. The verifying key is bundled with the app and also served at
/v1/entitlement/public-key. test_entitlement.py on the control plane.
Engine: bai/entitlement.py verifies the lease offline with the bundled public key,
caches it under the data dir, and honours it for an hour past expiry. allows_orders()
is the one question the four doors ask: arming.arm, campaign.arm_campaign_session
(and create/resume), arming.auto_entry_gate, and a ticket while armed in
desk._submit_ticket. Off means observations, never orders; position management keeps
running. ops.desk_tick reacts every tick: a kill flag runs the one kill path without
flattening, a lapse or a lost seat disarms and pauses the campaign. The shell delivers
leases with its own token (BAI_SHELL_TOKEN, popped from the environment at start-up
like the boot code) to POST /local/entitlement, which reacts at once; /local/kill
and /local/signout (disarm, never flatten, clear the lease, revoke the UI session)
sit beside it. The legacy desk answers "legacy-owner" and a test proves that answer is
impossible when packaged. api/tests/test_entitlement.py.
The shell closes the loop. Its start page is now the sign-in page (email, password,
two-step code when asked, "keep me signed in"); it talks only to the shell through
Tauri commands, and the shell talks to the control plane and the engine. A remembered
refresh token lives in Windows Credential Manager (refresh-token.BAI Desk) and is
rotated on every use; the access token and the shell token live in memory; the device
id is a plain file. Start-up resumes silently when a token is stored, fetches a lease,
delivers it, asks the engine for a fresh one-time boot code (/local/boot/rearm, since
signing in can outlast the sixty-second start-up code) and only then opens the desk.
A second device is shown who holds the seat, whether it is armed and how many positions
are open, and must choose to sign it out. The lease loop repeats every five minutes with
a heartbeat; a dead session sends the desk back to the sign-in page after a disarm.
shell.log in the app data folder records every step and failure, never a token.
control-plane.txt there overrides the built-in service URL for staging and tests.
Verified locally against a control plane on 8080: resume, lease, delivery, handoff.
Terms and disclaimers, phase 4 (2026-09-06)
Three documents, drafted for counsel and marked as drafts in their own text: Terms of
Service, Privacy Policy, Risk Disclosure. They live as markdown with a front matter in
control-plane/app/terms/documents/; a changed file is a new version the moment the
service starts (sync_documents), identified by the hash of its body. GET /v1/terms/current serves them without a token; POST /v1/terms/accept records an
acceptance only for the exact version and hash the page showed, with the surface, the
app version and the address; GET /v1/terms/status says what is outstanding. The lease
carries terms_ok and terms_required, and the engine's allows_orders() refuses on
terms_ok=false: Arm is off until the owner accepts, positions keep being managed.
The shell shows the documents before the desk opens whenever the lease says so, one
checkbox each, and re-leases after acceptance. The installer shows a first-run notice
(desktop/LICENSE-INSTALL.txt). The Arm dialog now says what armed means and shows the
entitlement's reason when Arm is off; Settings gets a "Legal & subscription" card that
reads the entitlement from the desk state and says plainly that it cannot change it.
Billing, phase 5 (2026-09-06)
Stripe is the ledger and its webhooks are the only thing that writes subscription state.
POST /v1/billing/checkout-session opens a Checkout session (subscription mode, the
fourteen-day trial once per customer, Stripe Tax on, the user id in the metadata) and
POST /v1/billing/portal-session opens the Customer Portal; neither changes state.
POST /v1/billing/webhook verifies Stripe's signature (with a replay window), applies
each event once by id, and mirrors checkout completion, subscription created/updated/
deleted, invoice paid/failed, disputes and refunds into one row per user. The
entitlement is then a pure function of that row and the clock: trialing and active
entitle; a failed payment starts a three-day grace that still entitles; canceled and
the rest lapse; a chargeback derives its own disputed status and sets the kill flag,
and only an admin clears a kill or a disable. Beta mode keeps everyone active until
billing is switched on. No SDK: two form posts and one HMAC, with a fake client and
signed test payloads. test_billing.py, seven scenarios.
The shell gains a native Account menu: Subscribe (monthly or annual), Manage billing,
Sign out. Checkout and the portal open in the system browser; the shell never renders a
payment page and never sees a card.
Admin, retention and the user's own data, phase 7 server side (2026-09-06)
/v1/admin/* behind a long random token and an address allowlist (an empty token
removes the surface; an empty allowlist is refused outside local and test): search
users, read one with devices and recent audit, disable and enable, set or clear the kill
flag, force sign-out everywhere (devices revoked, seat freed), note a refund, search the
audit trail, read and write feature flags, run the retention jobs. Every write leaves an
audit row with the admin's address; there is no impersonation. /v1/features hands the
flags to signed-in clients.
GET /v1/users/me/export returns everything we hold, with no hash, token or seed in it.
DELETE /v1/users/me needs the password (and the second factor when on), signs out
everywhere at once, frees the seat, tells the user by mail, and schedules a purge in
thirty days. app/jobs.py runs the purge (anonymise the user, drop credentials, devices,
tokens, acceptances and the entitlement, scrub addresses from the audit trail, keep the
billing mirror for tax), prunes login attempts, expired email tokens and old audit rows;
python -m app.jobs on a schedule or the admin endpoint on demand.
test_admin_and_retention.py, five scenarios; suite 46 green on SQLite and on Postgres.
Not in this slice: an admin web UI, Sentry and OpenTelemetry (vendor keys), the backup
restore drill (a runbook once staging exists), and desktop crash reports.
Alpaca behind the port, phase 9 first half (2026-09-06)
bai/brokers/alpaca.py is the second live venue and the first adapter written against
the port rather than inherited: API key and secret (env on the legacy desk, the
credential store when packaged), a paper environment that is a real account with play
money (so the whole desk runs end to end without ALLOW_LIVE; the real account still
needs it), native bracket orders the venue holds itself, an atomic replace for a leg,
order status and listing in the reconciler's shape, cancel, and price history. Every
failure answers None or a rejected ack; fills reach the book only through
reconcile.book_broker_fill; the adapter never writes a position.
To let a second live venue exist: LIVE_BROKERS in config decides what is a simulator,
mode.venue() returns the broker's name instead of a two-way switch, the registry
selects by setting, lifecycle.already_sent treats any live venue's submitted order as
sent, and desk.broker_holds_exit asks caps.brackets instead of assuming Schwab.
tests/brokers/fake_alpaca.py is a tiny Alpaca behind httpx.MockTransport; the
contract suite now runs on both Paper and Alpaca, and test_alpaca.py covers the adapter
(eight scenarios). Still to do in this phase: the broker picker driven by the auth
descriptor, keys entered from Settings into the credential store, and a nightly run
against the real paper environment.
The data-provider port, phase 8 (2026-09-06)
Market data behind a port, the way brokers are. bai/providers/ holds the port
(Provider, ProviderCaps), four built-in providers and a registry that walks two
chains the owner can reorder: quotes and bars. A provider answers or answers None; a
rate limit, an outage or a missing key is a None, never a number. The shipped chains
reproduce the desk to the day: quotes from the paid vendors then Yahoo, bars from the
broker's own history then Yahoo then the vendors, and the synthetic tape appended last
and only on a simulator (where, as before, vendor and Yahoo bars are never asked for, so
every rehearsal draws the same chart). desk._fetch_quote and ohlcv.get_bars now ask
the registry and nothing else. The order lives in providers.json under the data dir;
an explicit empty list means "the tape only" on a simulator and "nothing" off one.
GET /api/providers shows the chains, what is configured and what is effective, never a
key. POST /api/providers saves an order (the fixture can never be named). On the
packaged desk POST /api/providers/keys puts a vendor or broker key into the credential
store and makes it live at once; the legacy desk answers 404 there, because its keys
live in .env and no route may write one to disk. Yahoo is not in a packaged build.
test_providers.py, seven scenarios.
Not yet: a Settings card for the chain and keys, and Finnhub, Polygon and Alpha Vantage
as three separate providers (the vendor chain is one provider today, in its old order).
Settings knows the venue and the tape, phase 9 second half (2026-09-06)
GET /api/broker describes the venue behind the port in the owner's words: its kind and
venue string, what it can do (from the capability sheet), how it connects (from the auth
descriptor, including the fields a key-based venue needs), whether it is connected, and
its health. Never a key or a token. Two Settings cards read it and the provider chain:
Broker shows the venue, its abilities and, on the packaged desk, one box per field
the descriptor declares (Alpaca declares key id and secret); Data providers shows
the quote and bars chains with reorder and remove, what is configured and what is
effective, and key boxes on the packaged desk. On the owner's own desk both cards say
where the keys live instead of offering a box. The paper adapter answers the port's
two-argument connected. Static export builds; TypeScript is clean.
Updates and releases, phase 6 without the certificate (2026-09-06)
GET /v1/updates/{channel}/latest.json serves the manifest the Tauri updater reads
(version, notes, date, per-platform URL and signature) and answers 204 when the caller
is current; PUT /v1/admin/updates/{channel} publishes a build (https only) and
DELETE /v1/admin/updates/{channel}/{version} pulls one, pointing the channel back at
the previous release. The server never signs anything: the release pipeline signs with
the updater's private key and the shell verifies with the public key baked into it.
The shell checks the stable channel after start-up and every six hours through the
control-plane URL it already knows. It never installs while the desk is armed or holds
a bot position: the check reads /local/status first and defers with a line in
shell.log. When it does install, it stops both sidecars and restarts. .github/ workflows/release.yml builds the sidecar and the installer on a v* tag, signs the
updater artifact with TAURI_SIGNING_PRIVATE_KEY from the repository secrets, and
attaches the installer and its signature to a draft GitHub release. The OV certificate
step is added when the certificate exists; until then SmartScreen will warn.
A development updater keypair was generated outside the repo; its public half is in
tauri.conf.json. Regenerate the pair for production and keep the private key only in
the repository secrets.