feat(web): session identity on every view, and a paper banner with nothing to click (#704) - #735
Merged
Merged
Conversation
…thing to click (#704) Take the spine, refuse what rides on it. Making paper-vs-live visible on every page rather than buried in settings is the one organizing idea worth borrowing from a retail broker console. What their version attaches -- an "Open Live Account" button on the paper banner -- is a growth funnel wrapped around real money. THE CHIP: profile · mode · equity state Three facts that together answer "which deployment is this browser looking at, and which account drove the numbers on it". It surrounds the existing mode badge rather than replacing it, so the badge keeps owning the mode word and its db/config tooltip, and each fact stays written by the one function that knows it. `profile` NAMES THE DATABASE, and the stem is as far as it goes. ADR 0002 settles what a profile is -- "the database is already one-per-profile", which is also why `equity_points` has a `mode` column and no `profile` one -- so there is nothing stored to read. `keel.db` and `keel-live.db` are the operator's own names for their deployments, and a mapping from those to "paper"/"live" would be this console guessing which is which from a filename, on the one surface built to stop paper and live being confused. The full paths stay in the tooltip. `equity_state` is a Field and not a bare word because it has an UNKNOWN reading that matters: `equity_state_mode` is written on the first mode flip, so a deployment that has never run has none, and "which account drove the drawdown scalars" being unanswered is a different fact from either answer. It reads "not recorded", not an em-dash: `label(None)` is `absent()`, whose display is right in a table cell and wrong in a chip -- "keel · confirm · —" drops the very half the banner asks the operator to verify. ONE DATABASE READ ON AN ENDPOINT THAT MUST SURVIVE HAVING NO DATABASE `equity_state_mode` lives in `agent_state`, and `/api/config` is the only endpoint every view reads -- which is what makes the chip present everywhere rather than only where a status report happens to load. That route is `needs_database=False` because the client boots from it alone, and it still is. The read checks the file exists before connecting, and that is not a micro-optimisation: `sqlite3.connect` CREATES what it cannot find, so an unguarded read would have a read-only view bring a deployment into existence merely by being polled every fifteen seconds -- and every page would then report a healthy empty install rather than offering to set one up. `server.ensure_schema` carries the same guard and the same reasoning, found the same way. Any failure degrades to unknown, so a first run still boots the shell; it just boots without the equity half, which is the honest state. THE BANNER: A STATEMENT, NOT AN ALERT, AND NOTHING TO CLICK Paper gets fixed wording, asserted verbatim, because it is the sentence standing between an operator and mistaking a simulation for their account. Confirm is not a warning -- it restates the mode/equity-state pairing the operator is about to check against the venue's own UI, and it names BOTH halves because they are separately settable and a mismatch between them is exactly what the check is for. An unreadable config gets NO banner. A banner is a claim about whether real money is involved, and there is no safe default for that claim -- the same refusal `modeBadge` already makes. The sentence is chosen in Python (Rule 2) and placed unread; the renderer decides only the emphasis, from the same `MODE_CLASS` table the badge uses, so the two cannot disagree about which mode is the quiet one. THE REFUSAL IS PINNED STRUCTURALLY, AND THE FIRST PIN DID NOT WORK A negative rots silently, so neither `sessionChip` nor `paperBanner` may build an interactive node or bind a handler, and a test sweeps every mode × equity-state pairing for the vocabulary of a funnel. The first version of the clickable scan used `_code_only`, which strips string literals -- and the thing being forbidden is `el("button", ...)`, where `button` IS a string literal. It found nothing, always. Adding a "Go live" button to the banner passed it. There is now a lexer that strips comments and KEEPS strings (comments still go: `paperBanner`'s docstring explains what it refuses using the exact words the scan looks for), plus a smoke test asserting the lexer keeps what the scan depends on, before that scan is allowed to mean anything. Nine mutants killed: an unreadable config still getting the paper banner, the banner dropping the equity half, an unrecorded equity state rendering as paper, the banner gaining a way to go live, the config read connecting to a database that is not there, the banner moving inside the view where navigation repaints it, the banner growing a button, the chip growing a listener, and main.js demanding an id the shell does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
…hat autonomy had switched off
An independent review found nine issues. The first inverts a safety claim about
real money, on every page.
THE CONFIRM BANNER ASSERTED A GUARANTEE AUTONOMY REMOVES
It read "CONFIRM — every order is previewed and waits for your approval"
whenever `auto_trade.mode` was `confirm`. Autonomy is not a config mode:
`_VALID_AUTO_TRADE_MODES` is `("paper", "confirm")`, and
`agent._effective_mode` returns `"autonomous"` when the config is `confirm` AND
`Profile.is_autonomous(now)`. Under `keel autonomy on` -- a supported,
deliberate configuration -- orders place unattended, and the one persistent,
full-bleed statement on every screen said they wait for the operator.
That is worse than the growth funnel this issue exists to refuse. A funnel asks
for something; this promised supervision that had been switched off, about real
money, on the surface built to stop exactly that confusion.
The banner now reads the profile. `_session_state` answers both deployment
questions from ONE connection -- they are shown together, and two opens on the
boot path would be two chances to leak and two answers describing different
instants. `is_autonomous` honours the operator's expiry, so a lapsed `--until`
stops being claimed on the next page load rather than the next restart, and
`get_profile` fails closed, so the banner's mistake, if it makes one,
over-promises supervision rather than under-promising it.
Two statements of one rule now live in two modules for good reasons (Rule 2
keeps the sentence in the payload), which is the shape that drifts -- so a test
sweeps the banner and `_effective_mode`'s own predicate over the same inputs,
and a second test drives the real `_effective_mode` to check the predicate is
still what it restates.
`close_repo` CLOSED NOTHING, AND HAD NOT SINCE IT WAS WRITTEN
It reached for `repo.conn`; `Repository` stores `self._conn` and exposes no
`conn`, so the `getattr` returned `None` and the function was a no-op. Every
reader in this package runs it in a `finally`, and every page load has been
leaving an unclosed connection for CPython's refcounting to reclaim. Harmless in
practice and wrong in the way that matters: the `finally` READ as the cleanup,
so nothing looked missing -- and this PR's own docstring cited it as the guard.
A BLOCKLIST CAN ONLY REFUSE THE WORDINGS SOMEONE THOUGHT OF
The anti-CTA test listed forbidden phrases and claimed a later edit could not
add a call to action quietly. It could: "Start real trading", "Enable live
trading", "Switch to your real account" and four other realistic sentences all
passed it, and a plain-text CTA appended in the renderer passed the JS scan too.
`_session_banner` is a pure function of three values over a small finite domain,
so the pin is now the WHOLE OUTPUT SET. Any new sentence fails and has to be
added deliberately, in a diff a reviewer reads.
THE PARITY SCANNER WAS BLIND TO THE RENAME IT EXISTS TO CATCH
Two new `/api/config` consumers were not registered in `_CONFIG_READERS`, whose
own docstring states the rule ("a new consumer arrives with the check that
covers it"). Registering them exposed something worse: the shared key-extraction
regex was `[a-z_]+`, so `config.profileName` matched as `config.profile` -- a key
the endpoint does send. A camelCase rename passed the check silently while the
element rendered empty forever. The class now covers any identifier JavaScript
allows. That scanner is shared by every view.
And nothing asserted the shell CALLS either function. Deleting both call sites
left the suite green with no chip and no banner.
THE SEPARATORS WERE DESCRIBED IN THREE COMMENTS AND SHIPPED IN NONE
The issue, the HTML, the CSS header and both render.js docstrings all say
`profile · mode · equity state`. Rendered, it read "keel paper not recorded" --
and the CSS `:empty` rule reasoned at length about "a lone separator beside a
name that never arrived", a separator that did not exist. Generated on the parts
now, so a hidden part takes its own separator with it, which is what makes that
reasoning true.
AND SKIP-TO-CONTENT SKIPPED THE BANNER
The skip link targeted `#view`, which sits below the banner, so a keyboard user
taking it never met the one persistent statement about whether real money is
involved. It targets a wrapper that opens above the banner now: skipping the NAV
is what the link is for, and the banner is not nav.
Also: `Path` was imported locally twice where it is already module-scope, the
chip's container is selected by its id rather than a class nothing else used, a
long profile name is bounded so it cannot push the theme toggle off the header,
and one assertion read `x == "" or isinstance(x, str)`, which is true of every
string.
Nine mutants killed, including: an autonomous session claiming orders wait for
you, the api never asking whether autonomy is on, autonomy claimed for a paper
deployment, `close_repo` closing nothing, a plain-text CTA appended to the
sentence, the client renaming the key it reads, the shell not calling either
function, the chip losing its separators, and the skip link jumping past the
banner.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #704.
Take the spine, refuse what rides on it. Making paper-vs-live visible on every page rather than buried in settings is the one organizing idea worth borrowing from a retail broker console. What their version attaches — an "Open Live Account" button on the paper banner — is a growth funnel wrapped around real money.
The chip:
profile · mode · equity stateIt surrounds the existing mode badge rather than replacing it, so the badge keeps owning the mode word and its db/config tooltip, and each fact stays written by the one function that knows it.
profilenames the database, and the stem is as far as it goes. ADR 0002 settles what a profile is — "the database is already one-per-profile", which is also whyequity_pointshas amodecolumn and noprofileone — so there is nothing stored to read.keel.dbandkeel-live.dbare the operator's own names; a mapping from those to "paper"/"live" would be this console guessing which is which from a filename, on the one surface built to stop paper and live being confused.equity_stateis aField, because it has an UNKNOWN reading that matters:equity_state_modeis written on the first mode flip, so a deployment that has never run has none — and "which account drove the drawdown scalars" being unanswered is a different fact from either answer. It readsnot recorded, not an em-dash:label(None)isabsent(), whose display is right in a table cell and wrong in a chip.keel · confirm · —drops the very half the banner asks the operator to verify.One database read on an endpoint that must survive having no database
equity_state_modelives inagent_state, and/api/configis the only endpoint every view reads — which is what makes the chip present everywhere. That route isneeds_database=Falsebecause the client boots from it alone, and it still is.The read checks the file exists before connecting, which is not a micro-optimisation:
sqlite3.connectcreates what it cannot find, so an unguarded read would have a read-only view bring a deployment into existence merely by being polled every 15 seconds — and every page would then report a healthy empty install rather than offering to set one up.server.ensure_schemacarries the same guard, found the same way. Any failure degrades to unknown, so a first run still boots the shell.The banner: a statement, not an alert, and nothing to click
paperPAPER — no real money is involved(fixed wording, asserted verbatim)confirmBoth halves in the confirm case, because mode and equity state are separately settable and a mismatch between them is exactly what the check is for. The sentence is chosen in Python (Rule 2) and placed unread; the renderer decides only the emphasis, from the same
MODE_CLASStable the badge uses.The refusal is pinned structurally — and the first pin did not work
A negative rots silently, so neither
sessionChipnorpaperBannermay build an interactive node or bind a handler, and a test sweeps every mode × equity-state pairing for the vocabulary of a funnel.The first version of the clickable scan used
_code_only, which strips string literals — and the thing being forbidden isel("button", ...), wherebuttonis a string literal. It found nothing, always; adding a "Go live" button to the banner passed it. There is now a lexer that strips comments and keeps strings (comments still go —paperBanner's docstring explains what it refuses using the exact words the scan looks for), plus a smoke test asserting the lexer keeps what the scan depends on, before that scan is allowed to mean anything.Acceptance
#view, pinned by a test, so navigation cannot repaint it awayauto_trade.mode×equity_state_modeGates
E501is pre-existing indocs/experiments/)paper· the banner gaining a way to go live · the config read connecting to a database that is not there · the banner moving inside#view· the banner growing a button · the chip growing a listener ·main.jsdemanding an id the shell does not have🤖 Generated with Claude Code
https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6