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 inbroker.json;configured_venue()is the one reader, cached on the file's mtime; the legacy desk still readsBAI_BROKER. Choosing disarms, pauses a campaign, never flattens, and leaves abroker_changedaudit row. Schwab'sclient_id/client_secretareENV_KEYSentries and fields on its descriptor. The desk state carriesbroker_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.plainandVendorsProvider.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.mdis the sharpened brief for what comes next. - Campaign phase and story (
api/bai/campaign.py, columnsphase,phase_note,phase_atoncampaigns, migrated on start). Every tick stamps what the campaign is doing (PHASES), andcampaign_statsaddsday_number,days_totaland a one-linestory. 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
/statuspage that checks the service from the visitor's browser; an/accountpage (devices, sign out everywhere, two-step, password, export, deletion) against the API with tokens held in the tab;/docson one page; and/changelogrendered from this file (synced byscripts/sync-legal.mjs, checked in CI). The API allowlist admits DELETE for the account page. - Tests:
FixtureProvider.reanchor()and a call fromtmp_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 inprofiles.json, applied through the same_apply_settingsdoor 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_filehonoursBAI_STATE_DIR, whichtmp_envsets, 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_KEYSpairs, typed in the Alerts card and kept in the credential store like a broker key; the desk state'snotify_email_ready/telegram_kill_readylight up at once. The owner's own desk keeps them in.envand 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 isas_utcon both ends. CI:pip-auditon 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.
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.pycarries the real API's quirks (form bodies, a list of one as a bare object,"null"for nothing, the client id in thetag). 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 kindtradieron 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 withoutCP_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
MailErroron 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 throughCP_WEB_ORIGINS. The staging API answers atapi-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 drainsPOST /local/telemetry/drainon its lease loop and forwards toPOST /v1/telemetry/crashonly when the owner switched crash reports on in Settings (off by default,GET/POST /api/telemetry). The control plane scrubs again, keeps rows intelemetry_eventsforCP_TELEMETRY_RETENTION_DAYS(90), lists them in the admin console, and forwards to Sentry whenCP_SENTRY_DSNis set. Product analytics on our own surfaces (app/analytics.py: signup, email_verified, login, billing_event, desktop.*) go to the log, or to PostHog withCP_ANALYTICS_BACKEND= posthogandCP_POSTHOG_KEY; nothing about trading is ever in either. Tests keep the outbox out of the live desk's folder throughBAI_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.logand 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.nsiis 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.pyspawns 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_deskcommand). The heartbeat watcher stops a previous watcher only when that pid's command line namesheartbeat_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_ticketstashes the inputs asbrain_setup;submit_ticketrecords the decision only once an order is accepted; thedecision_idridesOrderIntent → Order → Position → PlaybookTradeandrecord_closegrades 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-onlybrainnotification. The lab still cannot promote. - Wider
BrainPolicywith edges. Payoff, strength, chop, regime, crowding and size-curve knobs, each inBOUNDS;violations()names every breach;test_brain_bounds.pyfails 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.pyis the only module allowed to spell a state path. WithBAI_DATA_DIRset, 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.envthat does not exist.test_paths.pypins both layouts and fails if any module reaches forROOT / "data"again. - The shell signs the UI in.
bai/local_boot.py: the engine takesBAI_BOOT_CODEout of its environment at start-up, andGET /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/loginanswers 404 when packaged, the local user carries a random password nobody knows, and/local/statustells 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.pymints a per-install key in Windows Credential Manager on first run and hands it to the engine throughBAI_SECRET_KEYbefore settings are read, soschwab,notifyandsecuritykeep 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.pyruns 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.