feat(cli+web): the cancel asymmetry — entries ask once, protection is typed, and the console never cancels (#707) - #741
Merged
Conversation
…otection is typed (#707) Cancelling an open ENTRY is refusing risk, and the constitution makes refusing risk frictionless: `y/N`, then it is gone. Cancelling an open EXIT or a protective bracket is REMOVING PROTECTION -- the same class of action as disabling a stop -- and it takes the typed friction every capability-increasing step in this program takes. No venue draws this distinction; it falls out of keel's own rails. A PROTECTIVE LEG IS CLASSIFIED BY WHAT DEPENDS ON IT, NOT BY ITS SIDE LABEL `positions.bracket_order_id` is the link, and the check runs BEFORE the side test. A row wearing the BUY side while a live tranche points at it for protection would pass a side-only rule and be cancelled on a `y` -- stripping a stop from a position that still holds inventory. THE PARTIAL FILL IS THE CASE THE ISSUE'S RULE DOES NOT COVER `executor.execute` places the bracket as soon as the entry is PLACED rather than once it fills, so a resting entry can already have a protective leg. Cancel a ZERO-FILLED entry and that leg commits base inventory which was never acquired -- an orphan, cleared with the entry by the #519 protocol. Cancel a PARTIALLY FILLED entry and the opposite is true: the operator holds real inventory and the bracket is what protects it. Clearing it "because we cancelled an entry" would strip a stop from a live tranche -- the exit-side hazard reappearing inside an entry-side action, which is exactly what this asymmetry exists to prevent. The remainder is cancelled; the protection stays. And when the orphaned bracket cannot be cleared, the command FAILS LOUDLY rather than reporting the cancel it did manage: an entry gone with a protective leg still working over inventory that does not exist is a state an operator has to be told about rather than discover from a fill. `keel orders` STILL LISTS It became a group and `invoke_without_command=True` is what keeps `keel orders --scope 7d` answering exactly as it did. `keel orders list` is the same thing under its own name. The typed phrase NAMES THE ORDER, so a phrase copied from one prompt cannot answer a different one -- otherwise the friction is a ritual rather than a check on which protection is being removed. A refusal is always a named sentence: "cancelled" reported over an order still live at the venue is worse than being told why it could not be. `CANCELLABLE_STATUSES` is read from `executor.RESTING_STATUSES` rather than restated, so this surface cannot come to disagree with the engine about what is still live. The web half of #707 is not in this commit: it needs a decision about whether `keel serve` may hold a trading credential, which no route in that package does today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
… — it never cancels (#707) #707 asked for `POST /api/orders/cancel`. Building it would have required `keel serve` to hold a trading credential, and that is the boundary this deployment does not cross: the console holds no venue credential and no broker handle, which is what keeps the worst case of a bug in the HTTP layer at "reads a local SQLite file" rather than "exfiltrates live trading keys". Unlocking the keychain and signing a request to a venue happens inside a terminal invocation the operator started, never from an ambient loopback daemon. So there is NO cancel route. Not a guarded one -- none. A guard is a thing that can be got wrong; an absent endpoint cannot be, and a test asserts no route carries the word. WHAT THE CONSOLE DOES INSTEAD IS CLASSIFY, WHICH IS A READ Every order row carries what cancelling it would be and the exact command that would do it. Clicking Cancel opens a dialog naming the order, saying what cancelling it means, and offering the command to copy. ONE CLASSIFICATION, TWO FRONT-ENDS. `classify_cancel` is what the CLI gates on and what the payload renders. If each decided for itself, the console could describe an order as a frictionless entry while `keel orders cancel` demanded the typed phrase for it -- and the operator would learn which was right by typing. THE INVOCATION IS COMPOSED IN PYTHON A client concatenating `keel orders cancel ` and an id could print a command that does not exist, and it would have to read `Field.value` to find the id -- which `render.js` may not do. The headline is composed there too: "Entry order #42" and "Protective bracket #43 — live protection" are two different warnings, and choosing between them is a judgement (Rule 2). An order that cannot be cancelled gets the reason instead of a button. Offering a command that would be refused is worse than offering none: the operator runs it, it fails, and they learn the console does not know what it is looking at. ONE QUERY FOR THE PAGE, NOT ONE PER ROW `get_position_for_bracket` per row is a query per row against a page capped at 2,000. `open_bracket_order_ids` answers every protective link at once, and `classify_cancel` takes the set when it has one and falls back to the single lookup when it does not -- the same rule applied efficiently, not a fast implementation beside a careful one. AND A TOKEN #704 GOT WRONG The mode banner asked for `var(--surface)`, which this stylesheet does not define -- the site calls it that and `keel.css` calls it `--card`. An undefined custom property falls back to transparent, so the banner has been painting no background since it shipped. Found because the modal reached for the same wrong name. Twelve mutants killed, including: the bracket-link guard removed so a protective leg reads as an entry, the side guard removed, an exit no longer needing the typed phrase, the orphaned bracket never cleared, a PARTIALLY FILLED entry having its live protection cleared too, a filled order becoming cancellable, the phrase no longer naming the order, cancel no longer needing a terminal, a failed bracket clear reported as success, the web package gaining a broker import, the classification going back to per-row, and the console composing the command itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
… a live tranche's stop An independent security review found one HIGH and it was a one-`y` path to a naked position, on the branch deliberately built to be frictionless. THE ENTRY CANCEL WAS CALLING A PRODUCT-WIDE SWEEP `executor._clear_resting_bracket` cancels EVERY resting SELL for a product. That is right where the executor calls it: the caller is about to place a replacement sell over the same inventory, so clearing the old bracket is the point. It is catastrophic here. Nothing replaces anything -- the entry is going away -- so cancelling an entry on a product that already held an open bracketed tranche cancelled that tranche's stop too. Reproduced end to end: one `y`, and the broker was asked for `['venue-entry', 'venue-bracket']`. Cancelling that bracket DIRECTLY demands the typed phrase; reaching it sideways through an entry was a shortcut past the whole asymmetry. And the aftermath was silent. The tranche kept pointing at a cancelled order, and `reconcile_unbracketed_positions` skips a tranche with no `unbracketed:` record by design, so nothing healed it and nothing said anything. The position would have stayed open with no stop indefinitely. `_clear_orphaned_brackets` replaces it: the same read, filtered by `open_bracket_order_ids()` -- the same set `classify_cancel` calls `protective`. What is left is a resting sell no open tranche depends on, which is what "orphan" means. Failures are collected rather than raised, so one uncancellable orphan does not hide the others. THE DECISION IS RE-DERIVED AFTER THE PROMPT A typed phrase is 34 characters and a resting order can fill while it is being typed. The first cut classified once, before the prompt, so an entry that had become `filled` still ran the orphan sweep on a stale `clears_bracket`. The row is re-read and re-classified now, and a status change REFUSES: the operator answered a question about a different order from the one in front of them. THE FILL IS BOOKED BEFORE THE TERMINAL WRITE `execution.reconcile` states the rule this missed -- "A CANCELLED/EXPIRED order can still have SOLD something" -- and `canceled` is terminal, so `_polled_rows` never revisits the row and the fill would be dropped for good. `CANCELLABLE_STATUSES` deliberately includes `partially_filled`, which is exactly the row that carries one. Reconcile's own recorder is reused rather than restated, and wrapped: a read-back failure must not strip the cancel it follows. AND A VENUE REFUSAL IS A SENTENCE, NOT A TRACEBACK `CancelUnavailable` is a `RuntimeError` and `cli.main` re-raises everything, so the likely outcome of the window above -- the order filled while the operator typed -- was a Python traceback. Local state was correct either way; `_cancel_at_exchange` marks nothing on failure. THE BOUNDARY TEST WAS PASSING EITHER WAY It scanned `keel/web/*.py` for `_build_broker`, and this PR had put a broker-building, venue-cancelling function into `keel.commands.orders` -- the module `read_orders` imports on every request. Nothing routes to it, and "nothing routes to it" is the part that needed asserting. A real `/api/orders` call now runs in a subprocess and the test fails if `keel_core.secrets` reached `sys.modules`. Also: `CANCEL_KINDS` was declared and never read, so the headline and note tables could drift from it silently -- checked at import now. The disclaimer printed twice (the group callback carries it and click runs that first), and above the usage text on `--help`. The group docstring still said "read-only" while the group had grown a write. And an open modal survived the 15-second repaint, offering a command for an order that may have filled. Eight mutants killed: the sweep going back to product-wide, a protected bracket treated as an orphan, the decision not re-derived after the prompt, the fill not booked before the terminal status, a venue refusal becoming a traceback, a credential import creeping into the request path, a kind losing its headline, and the fill read-back aborting the cancel. 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 #707.
Cancelling an open entry is refusing risk — frictionless by constitution. Cancelling an open exit or protective bracket is removing protection — never one click. No venue draws this distinction; it falls out of keel's own rails.
The scope change, and why
The issue asked for
POST /api/orders/cancel. Building it would have requiredkeel serveto hold a trading credential — and that is the boundary this project does not cross. The console holds no venue credential and no broker handle, which is what keeps the worst case of a bug in the HTTP layer at "reads a local SQLite file" rather than "exfiltrates live trading keys." Unlocking the keychain and signing a request to a venue happens inside a terminal invocation the operator started, never from an ambient loopback daemon.So there is no cancel route — not a guarded one, none. A guard is a thing that can be got wrong; an absent endpoint cannot be. The console classifies (a read) and hands over the exact command.
The terminal
keel ordersis now a group —invoke_without_command=Truekeepskeel orders --scope 7danswering exactly as it did — pluskeel orders listandkeel orders cancel <id>.Two things the code said that the issue didn't:
A protective leg is classified by what depends on it, not by its side.
positions.bracket_order_idis the link, and that check runs before the side test. A row wearing the BUY side while a live tranche points at it for protection would pass a side-only rule and be cancelled on ay.The partial fill inverts the bracket rule.
executor.executeplaces the bracket as soon as the entry is placed, not once it fills — so a resting entry can already have a protective leg. Cancel a zero-filled entry and that leg commits inventory never acquired: an orphan, cleared with the entry by the #519 protocol. Cancel a partially filled entry and the operator holds real inventory that the bracket protects; clearing it would strip a stop from a live tranche — the exit-side hazard reappearing inside an entry-side action. The remainder is cancelled; the protection stays.And when the orphan cannot be cleared, the command fails loudly rather than reporting the cancel it managed: an entry gone with a sell still working over inventory that does not exist is a state the operator has to be told about, not discover from a fill.
The typed phrase names the order, so a phrase copied from one prompt cannot answer another — otherwise the friction is a ritual rather than a check on which protection is being removed.
The console
Every row carries the classification and the command. Clicking Cancel opens a
<dialog>naming the order, saying what cancelling means, and offering the command to copy. An order that can't be cancelled gets the reason instead of a button — a command that would be refused is worse than none.One classification, two front-ends.
classify_cancelis what the CLI gates on and what the payload renders; if each decided for itself the console could call an order a frictionless entry while the terminal demanded the phrase. The invocation and the headline are composed in Python — a client concatenating them could print a command that doesn't exist, and it would have to readField.value, whichrender.jsmay not do.One query per page, not per row:
open_bracket_order_ids()answers every protective link at once, andclassify_canceltakes the set when it has one.Security pass
API_ROUTEScarries no cancel path; the console's only POST remainssetup.ACTIONSkeel servekeel/web/*.pynames no_build_broker,cancel_order,_cancel_at_exchangeorload_secretfetch,XMLHttpRequest,location,submitorPOSTorder N is canceled, and only a resting order can be cancelled), never a silent successkeel orders cancelfails closed without a terminalexecutor._cancel_at_exchangeowns the rule and is called rather than restated: only a CONFIRMED cancel lets the local row be markedAcceptance
403 payload contract— superseded: there is no route to return one. The refusal is structural, pinned bytest_there_is_no_cancel_route_at_allGates
E501is pre-existing indocs/experiments/)Also fixes a token from #704: the mode banner asked for
var(--surface), which this stylesheet doesn't define (it's--card), so it has been painting no background since it shipped.🤖 Generated with Claude Code
https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6