Skip to content

feat(cli+web): the cancel asymmetry — entries ask once, protection is typed, and the console never cancels (#707) - #741

Merged
eaitbrahim merged 3 commits into
mainfrom
feat-707-orders-cancel
Sep 6, 2026
Merged

feat(cli+web): the cancel asymmetry — entries ask once, protection is typed, and the console never cancels (#707)#741
eaitbrahim merged 3 commits into
mainfrom
feat-707-orders-cancel

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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 required keel serve to 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 orders is now a group — invoke_without_command=True keeps keel orders --scope 7d answering exactly as it did — plus keel orders list and keel 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_id is 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 a y.

The partial fill inverts the bracket rule. executor.execute places 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_cancel is 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 read Field.value, which render.js may not do.

One query per page, not per row: open_bracket_order_ids() answers every protective link at once, and classify_cancel takes the set when it has one.

Security pass

New write surface on the web None. API_ROUTES carries no cancel path; the console's only POST remains setup.ACTIONS
Credentials in keel serve None, before or after. A source scan asserts keel/web/*.py names no _build_broker, cancel_order, _cancel_at_exchange or load_secret
Blast radius of an HTTP-layer bug Unchanged: read of a local SQLite file
CSRF / clickjacking on the new control Not applicable — the button opens a dialog; a scan asserts the modal reaches for no fetch, XMLHttpRequest, location, submit or POST
Idempotency A second cancel is a named refusal (order N is canceled, and only a resting order can be cancelled), never a silent success
Off-TTY Refused. keel orders cancel fails closed without a terminal
Venue confirmation executor._cancel_at_exchange owns the rule and is called rather than restated: only a CONFIRMED cancel lets the local row be marked

Acceptance

  • Removing the side guard fails the suite; removing the bracket-link guard fails the suite; removing the bracket-clearing fails the suite
  • CLI typed-phrase path pinned by test
  • 403 payload contract — superseded: there is no route to return one. The refusal is structural, pinned by test_there_is_no_cancel_route_at_all
  • Security pass above; sign-off comment to follow on the issue

Gates

  • 6,211 passed / 3 skipped; mypy clean on 444 files; ruff clean (the one E501 is pre-existing in docs/experiments/)
  • 12 mutants killed: the bracket-link guard removed · 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 · 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 · the console composing the command itself

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

eaitbrahim and others added 3 commits September 6, 2026 17:26
…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
@eaitbrahim
eaitbrahim merged commit b7652c5 into main Sep 6, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-707-orders-cancel branch September 6, 2026 23:50
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+cli] keel orders cancel: the cancel asymmetry — entries one-click, exits typed

1 participant