Found while building #536. Shipped, user-facing, and two of the five are binary safety states rather than cosmetic.
Root cause
keel/web/render.py's render_status reads report fields through getattr(obj, "name", default). Where the attribute does not exist, the default is returned silently — a frozen dataclass would have raised AttributeError at the first render, but the default suppresses it. Five attributes it asks for are not on the dataclasses.
1. Autonomy always prints "off" — render.py:351
autonomy_text = "on" if getattr(autonomy, "enabled", False) else "off"
AutonomyStatus (keel/commands/status.py:44-51) has live, autonomous, autonomous_until, updated_ts, profile_readable. There is no enabled.
So the expression is getattr(..., "enabled", False) → False → "off", unconditionally, for every deployment, always.
A deployment placing orders unattended reads autonomy: off in the browser. This repository's own CLI states the stakes in its wording — keel/commands/status.py:481:
autonomy_line = "autonomy: ON -- orders placed WITHOUT asking" if a.live else "autonomy: off"
The CLI reads a.live directly and is correct. The browser is the only wrong surface.
2. An expired rail-17 attestation renders as fresh — render.py:375
expired = bool(getattr(attestation, "expired", False))
WithdrawalAttestationStatus (status.py:93-105) carries state: str # "attested" | "suspended" | "expired" | "unattested", plus enabled and attested_at. There is no expired.
Always False. An expired attestation — the state that halts live entries — displays as fresh, in green. The page reports health while entries are being refused, which is the failure mode most likely to send an operator looking in the wrong place.
3–5. Three columns that are always blank
| line |
asks for |
actual field |
effect |
render.py:392 |
pos.entry_fill |
entry_price |
every open position's entry price renders -- |
render.py:419 |
rule.name |
RuleSummary has id, kind, status, product_id, params — no name |
the live-rule column is always blank |
render.py:~ |
subscription.status, .attested_ts |
stored_status / effective_status; no attested timestamp on the row |
both columns blank |
Cosmetic next to 1 and 2, but the same cause.
Why it survived
Nothing fails. There is no exception, no empty page, no log line. getattr with a default converts a rename or a typo into a plausible-looking value, and every one of these renders as a reasonable reading — "off", "fresh", "--", "". A page reporting a quiet, non-autonomous, compliant deployment is exactly what an operator expects to see, so there is nothing to notice.
The fix, and the part worth arguing about
Reading the attributes directly is the one-line fix and leaves the trap: the next rename re-creates it silently.
Delete the getattr fallbacks entirely. These are frozen dataclasses in the same repository, not external data — a missing attribute is a bug, and AttributeError at render time is the correct, loud outcome. The defaults exist to make rendering total, but a page that renders is not a page that is right, and totality bought at the cost of correctness is a bad trade for a trading console.
Then pin it: a test constructing a fully-populated report and asserting the rendered page shows autonomy ON and an expired attestation as expired. Neither is currently possible to observe.
Relationship to the rewrite
#536's client reads the API (keel/web/api.py), which uses the correct field names, so the new status view does not inherit any of this — its docstring records each divergence from render.py and why.
render.py is deleted at #540, so all five disappear then. That is not a reason to wait. Until the rewrite lands, every operator running keel serve on a live, autonomous deployment reads that autonomy is off — and if you want / correct before #540, this is a small independent fix.
Related: #542 (the same shape — pct() in the browser, correct in the CLI).
Acceptance
Found while building #536. Shipped, user-facing, and two of the five are binary safety states rather than cosmetic.
Root cause
keel/web/render.py'srender_statusreads report fields throughgetattr(obj, "name", default). Where the attribute does not exist, the default is returned silently — a frozen dataclass would have raisedAttributeErrorat the first render, but the default suppresses it. Five attributes it asks for are not on the dataclasses.1. Autonomy always prints "off" —
render.py:351AutonomyStatus(keel/commands/status.py:44-51) haslive,autonomous,autonomous_until,updated_ts,profile_readable. There is noenabled.So the expression is
getattr(..., "enabled", False)→False→"off", unconditionally, for every deployment, always.A deployment placing orders unattended reads
autonomy: offin the browser. This repository's own CLI states the stakes in its wording —keel/commands/status.py:481:The CLI reads
a.livedirectly and is correct. The browser is the only wrong surface.2. An expired rail-17 attestation renders as fresh —
render.py:375WithdrawalAttestationStatus(status.py:93-105) carriesstate: str # "attested" | "suspended" | "expired" | "unattested", plusenabledandattested_at. There is noexpired.Always
False. An expired attestation — the state that halts live entries — displays as fresh, in green. The page reports health while entries are being refused, which is the failure mode most likely to send an operator looking in the wrong place.3–5. Three columns that are always blank
render.py:392pos.entry_fillentry_price--render.py:419rule.nameRuleSummaryhasid,kind,status,product_id,params— nonamerender.py:~subscription.status,.attested_tsstored_status/effective_status; no attested timestamp on the rowCosmetic next to 1 and 2, but the same cause.
Why it survived
Nothing fails. There is no exception, no empty page, no log line.
getattrwith a default converts a rename or a typo into a plausible-looking value, and every one of these renders as a reasonable reading — "off", "fresh", "--", "". A page reporting a quiet, non-autonomous, compliant deployment is exactly what an operator expects to see, so there is nothing to notice.The fix, and the part worth arguing about
Reading the attributes directly is the one-line fix and leaves the trap: the next rename re-creates it silently.
Delete the
getattrfallbacks entirely. These are frozen dataclasses in the same repository, not external data — a missing attribute is a bug, andAttributeErrorat render time is the correct, loud outcome. The defaults exist to make rendering total, but a page that renders is not a page that is right, and totality bought at the cost of correctness is a bad trade for a trading console.Then pin it: a test constructing a fully-populated report and asserting the rendered page shows autonomy ON and an expired attestation as expired. Neither is currently possible to observe.
Relationship to the rewrite
#536's client reads the API (
keel/web/api.py), which uses the correct field names, so the new status view does not inherit any of this — its docstring records each divergence fromrender.pyand why.render.pyis deleted at #540, so all five disappear then. That is not a reason to wait. Until the rewrite lands, every operator runningkeel serveon a live, autonomous deployment reads that autonomy is off — and if you want/correct before #540, this is a small independent fix.Related: #542 (the same shape —
pct()in the browser, correct in the CLI).Acceptance
render_statusreads report attributes directly; nogetattrfallback in the module.liveis true.expiredattestation renders as expired, not fresh.