Skip to content

feat(web): session identity on every view, and a paper banner with nothing to click (#704) - #735

Merged
eaitbrahim merged 2 commits into
mainfrom
feat-704-session-identity
Sep 5, 2026
Merged

feat(web): session identity on every view, and a paper banner with nothing to click (#704)#735
eaitbrahim merged 2 commits into
mainfrom
feat-704-session-identity

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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 state

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; 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_state is a Field, 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. 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, which 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 15 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, 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

mode banner
paper PAPER — no real money is involved (fixed wording, asserted verbatim)
confirm the mode and the equity state — the pairing the operator is about to check against the venue's own UI
unreadable nothing. A banner is a claim about whether real money is involved, and there is no safe default for that claim

Both 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_CLASS table the badge uses.

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.

Acceptance

  • Chip present on all views — it lives outside #view, pinned by a test, so navigation cannot repaint it away
  • Correct pairing of auto_trade.mode × equity_state_mode
  • Banner text exact
  • Zero buttons inside either
  • The mode-badge tooltip keeps naming db + config paths (untouched)

Gates

  • 6,090 passed / 3 skipped; mypy clean on 438 files; ruff clean (the one E501 is pre-existing in docs/experiments/)
  • 9 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 #view · the banner growing a button · the chip growing a listener · main.js demanding an id the shell does not have

🤖 Generated with Claude Code

https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6

eaitbrahim and others added 2 commits September 5, 2026 15:37
…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
@eaitbrahim
eaitbrahim merged commit 7c29f7a into main Sep 5, 2026
3 checks passed
@eaitbrahim
eaitbrahim deleted the feat-704-session-identity branch September 5, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[web] Session identity: profile · mode · equity-state switcher chip and a no-CTA paper banner

1 participant