fix(admission): close the non-UTF-8 shortlist hole and pin the claims #176 made - #177
Merged
Conversation
…176 made An adversarial review of #176 confirmed the network gating and read-only guarantees, and found ten things to fix. This is all ten. The two real bugs: - `build_propose_view` guarded `source.read_text()` with `except OSError`, but `UnicodeDecodeError` subclasses **ValueError**. A UTF-16LE+BOM shortlist -- valid JSON, and what a scout run on Windows writes -- escaped both handlers and broke the function's own "Never raises. Every failure mode is FAIL-SOFT" contract: the TUI overlay repainted `propose read failed: 'utf-8' codec can't decode byte 0xff...` every poll forever, naming no file and no next step, and `keel assets propose --from` exited on a raw traceback. Both now take the existing `unreadable` fail-soft path, which names the file. The two docstrings that claimed an exception "can only come from `open_state()`, never from the shortlist read itself" said something that was false then and is still not the whole truth now (`build_propose_view` screens every parsed candidate, so a locked DB surfaces there too) -- both corrected. - `assets propose --json` emitted `sc.result.failures` raw, so at zero cached bars the payload carried `history: 0 daily bars < 1460 required` unflagged while every human surface suppressed that exact line. Same report, two surfaces, two different answers to "is this asset too young?". `--json` now applies the same `split_failures` the renderers do, and says so explicitly: `failures` / `not_assessable` / `missing_history`. The single-admission-path property (design constraint 4) was convention-only exactly where it is wired: replacing `_screen_product` with an always-ADMIT stub in `_do_screen_report` OR `_do_propose_view` left all 2062 tests green, because the overlay tests only asserted that a title paints and Esc closes. Two `run_live` tests now seed an unattested asset with ample history and liquidity and assert the overlay paints REJECT plus `attestation: MISSING` -- a verdict only the real gate produces. Both stubs now die. Honesty fixes to comments that asserted more than the code can know: - `split_failures` claimed a shallow-but-non-empty cache means the asset "really is too young". `MarketFacts` carries no first-bar timestamp, so it cannot tell that from `keel fetch --years 2`, an aborted fetch, or a venue not serving the full window. Reworded to say so, and to tell the operator to check the fetch window first. - `missing_history_lines` said a zero-bar asset "is not too young" -- same overclaim, opposite direction. It now refuses to rule either way. - "the SECOND deliberate network exception" appeared in the module docstring, the help screen's Safety notes and the operator-facing ARMED overlay, forgetting the ~30s live-balance refresh that has been firing since v3. It is the THIRD of exactly three, as `tui_cmd`'s docstring already said. The help's "Live balance" section now also says the refresh is itself a live venue call. - `run_live` said "Seven modes"; there are six. Coverage for things that were correct but untested, each verified to die under a mutation: `config.proposals_dir` resolution and `~` expansion (the default is `~/keel/proposals`, inside the live deployment root, so a silent regression means reading a right-looking wrong place); `_DISCOVER_TIMEOUT_SEC`'s wiring, pinned per-call so collapsing it into the balance timeout is caught; `missing_history_lines`' semantic sentence and its promise never to restate a suppressed failure verbatim. `test_run_live_discover_closing_discards_the_held_result` is renamed to `..._reopening_after_a_run_is_armed_not_stale`. Deleting the close-branch clear alone leaves it green -- verified -- because the normal-mode `d` branch clears too, and nothing observable from outside `run_live` can separate them, since `mode` only becomes `discover` via that branch. The name now matches what it pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Aug 7, 2026
Patch: no schema change, no rail change, nothing that alters what the agent trades or when. Ships #176, #177 and #178. The reason to cut this rather than wait is #176's fix to `DATA_DERIVED_FAILURES`, which is a reporting bug live on the deployment right now: `history` was missing from the tag set that marks a failure as downstream of an empty cache, so `keel assets propose` and `keel assets holdings --screen` printed `x history: 0 daily bars < 1460 required` directly beneath their own note saying the verdict was about missing data rather than about the asset. An asset nobody had ever fetched was reported as too young to trade. Also in: the TUI can now reach the admission gate (`screen`/`propose`/`discover`, read-only, `discover` network-gated behind an explicit keypress like `f`); the non-UTF-8 shortlist hole that broke `build_propose_view`'s documented never-raises contract; and the header now shows the patch segment, so 0.5.1 and 0.5.3 no longer render identically as `v0.5`. uv.lock relocked in the SAME commit, per 0.5.0-0.5.2. Verified with `uv sync --frozen`, which accepted the lock and rewrote nothing. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
An independent adversarial review of #176 returned SHIP WITH CHANGES: network gating and the read-only guarantees held up (34 socket-disabled checks, DB bytes identical,
attestabsent), with ten findings to fix. This is all ten, TDD — every test below was seen failing for the right reason before the fix, and every coverage-only test was verified to die under a deliberate mutation.The two real bugs
F1 —
build_propose_viewbroke its own "Never raises" contract.source.read_text()was guarded byexcept OSError, butUnicodeDecodeErrorsubclasses ValueError (issubclass(UnicodeDecodeError, OSError)isFalse). A UTF-16LE+BOM shortlist — valid JSON, and what a scout run on Windows writes — escaped both handlers and propagated out of a function whose docstring promises "Never raises. Every failure mode is FAIL-SOFT". Observed: the propose overlay repaintingpropose read failed: 'utf-8' codec can't decode byte 0xff…every poll forever, naming no file and no next step, surviving only onrun_live's broadexcept Exception; andkeel assets propose --fromexiting on a raw traceback on the same input. Both paths now take the existingunreadablefail-soft branch, which names the file.The two comments this falsified —
_do_propose_view's docstring and the identical inline comment inrun_live's propose branch, both claiming an exception "can only come fromopen_state()… never from the shortlist read itself" — are corrected. Note they were not merely stale:build_propose_viewscreens every parsed candidate after parsing, so a locked DB surfaces there too. The handler is load-bearing for both, and now says which.F9 —
assets propose --jsontold a different story from every human surface. It emittedsc.result.failuresraw, so at zero cached bars the payload carriedhistory: 0 daily bars < 1460 requiredunflagged, whilerender_proposal_report,assets holdings --screen,assets screenand the TUI overlays all suppress that exact line and print the MISSING-DATA explanation. That is precisely the driftsplit_failureswas created to end, surviving in the one surface a script reads and cannot argue with.--jsonnow applies the same split and makes it explicit:failures(verdicts about the asset),not_assessable(suppressed, never dropped),missing_history.F2 — the single-admission-path property, tested at the wiring point
Design constraint 4 ("every candidate routes through
_screen_product, so nothing drifts onto a laxer gate") was convention-only exactly where it is wired. Replacing_screen_productwith an always-ADMIT stub in_do_screen_reportor_do_propose_viewleft all 2062 tests green — the existing overlay tests only assert a title paints and Esc closes, never a verdict.Two
run_livetests now seed an asset with ample cached history and liquidity but no attestation, so the only thing that can reject it isscreen_assetfailing closed onattestation=None— something only the real gate does. Both assert the overlay paintsREJECTplusattestation: MISSING, and assert the absence of✗ history/✗ liquidityso the REJECT provably comes from the shariah criterion rather than an incidental data shortfall any stub would also produce.Both mutations verified dead. The
_do_screen_reportone was also run against the full suite pre-fix: it failed nothing.Honesty fixes — comments asserting more than the code can know
split_failuresclaimed a shallow-but-non-empty cache means the asset "really is too young".MarketFactscarries a bar count and no first-bar timestamp, so it cannot distinguish that fromkeel fetch --years 2, an aborted fetch, or a venue not serving the full window (fetchprints a note about exactly this). Reworded to say plainly that the two are indistinguishable here, why the gate still fails closed on the ambiguity, and what the operator should check first (keel fetch --products <id> --years 5, then re-screen). Not re-engineered.missing_history_linessaid a zero-bar asset "is not too young, we have simply never fetched candles for it" — the same overclaim in the opposite direction, since at zero bars a three-day-old listing and a three-year-old one are literally the same input. It now refuses to rule either way.get_accountscall since v3. It is the THIRD of exactly three, astui_cmd's docstring already said correctly. All three corrected and pinned by a parametrized test across all four surfaces. The help's "Live balance" section never said the refresh is a live venue call at all — the omission that made "second" read as plausible three sections later — so it now says so, and says it is a read that places no orders.run_livesaid "Seven modes"; there are six.Coverage for correct-but-untested code
config.proposals_dirresolution was never executed by the suite (every test passeddirectory=), so hardcoding a path left 2062 tests green — and the default is~/keel/proposals, inside the live deployment root. Two tests withHOMEredirected attmp_path: one pins that a non-defaultproposals_diris honoured, one that the default resolves to<home>/keel/proposalsexpanded. Both die under a hardcoded path; both die under a dropped.expanduser()._DISCOVER_TIMEOUT_SEChad no test. Pinned per-call, not globally, so collapsing it into_BALANCE_TIMEOUT_SECis caught too. Dies under both mutations."✗ history" not in textwould miss a leak, since a leak carries no✗).F6 — a test renamed to what it actually tests
test_run_live_discover_closing_discards_the_held_resultis nowtest_run_live_discover_reopening_after_a_run_is_armed_not_stale.I took the rename rather than the "pin the close branch specifically" option, and verified the reason: deleting the close-branch clear alone leaves the test green; deleting both clears kills it. Nothing observable from outside
run_livecan separate them, becausemodeonly ever becomesdiscovervia the normal-modedbranch that also clears — there is no route into the overlay that bypasses it. Keeping both clears is deliberate defence in depth, so the honest fix is the name plus a docstring that states the disjunction and names both lines.One thing I'd flag back
F5 was written as a coverage finding, but the sentence it points at is the same overclaim F3 flags, one step further along: "it is not too young" asserts a fact about the asset at exactly the bar count where we have zero evidence about the asset. I reworded it rather than only pinning it. Called out here in case the intent was to pin the existing wording as-is.
Gates
ruff check keel tests packagesclean ·mypy keel packagesclean (95 files) ·pytest -q2080 passed (2062 baseline + 18 new).