Repository navigation
Commit ada303c
* fix(web): drop reset-hwm to Tier 2, and bind every phrase to its own action (#790)
Eleven findings on the gate table. Three were about what Tier 1 should CONTAIN
rather than how it was written, and the first is the one that mattered.
── THE TIER BOUNDARY ARGUED AGAINST ITSELF ────────────────────────────────────
`reset_high_water_mark` is four writes: it clears the mark, zeroes BOTH drawdown
scalars and empties `equity_history` -- the rolling window the weekly peak is
computed from. `record_declared_flow` shifts the mark by a signed amount the
operator states.
So Tier 1 admitted the action that ERASES the drawdown evidence while Tier 2
refused the one that adjusts it, on the stated grounds that the adjusting one
"can, with the wrong sign, mask a real trading drawdown". Dropped to Tier 2. The
alternative -- keeping it and rewording the criterion -- would have been fitting
the rule to the answer.
Tier 1 is two actions now: `resume` and `resume-entries`.
`resume`'s own entry says what it really is. `guards.check` reads
`repo.get_state("kill_switch", default=True)`, so an unset kill-switch is
ENGAGED: on a never-resumed deployment this moves a fail-closed default to
permissive rather than releasing a brake a rail applied. It stays, because `keel
kill` re-engages in one gesture, and that is what bounds it.
And the ordering is written down: the browser has no halt today, so a release
route shipping before one would let an operator start from a phone and not stop
from it. Stage 2b ships the ungated one-click halt in the SAME change as the
first release route, not after it.
── A PHRASE THAT RELEASED EVERY ACTION ────────────────────────────────────────
Every test read the expected phrase back out of the table, so the suite was
self-consistent under any content. A `phrase_matches` that ignored `action_key`
and tested the presented phrase against every row passed all twelve -- making any
Tier 1 phrase a master key, which is the closure this module calls its safety
property, defeated. Nothing pinned the phrase literals either: `RESUME TRADING`
-> `RESUME NOW` shipped green, as did swapping two actions' phrases.
Bound now, and asserted as a cross product: every phrase against every OTHER
action. The literals are pinned, and no two phrases may share a FIRST WORD -- the
near miss is an operator pattern-matching under stress, for whom a future `RESUME
ENTRIES` beside `RESUME TRADING` would be genuinely confusable.
── AND THE SAME BUG I HAD FIXED ONE PR EARLIER ────────────────────────────────
`secrets.compare_digest` raises `TypeError` on a non-ASCII `str` and on a
non-`str`. I found and fixed exactly this in `tokens_match` during #786's review,
then wrote the same pattern here without the guard. The phrase comes off an
operator's keyboard through a JSON body, so a smart quote or a non-breaking space
from a phone delivered the correct verdict -- refuse -- as a 500.
── THE REST ───────────────────────────────────────────────────────────────────
`function` named a `click.Command`. `getattr(keel.cli, "resume")()` would enter
Click standalone mode, re-parse the SERVER's `sys.argv`, run the TTY gate with no
terminal and `sys.exit` inside a request thread. It is `operation` now, naming
the state service module-qualified, pinned by literal and checked to resolve to
something callable -- and two actions may not reach one operation, which would
release the kill-switch when the operator asked to clear a streak halt.
`TIER1_ACTIONS` is a `MappingProxyType`; the rows were already frozen and that is
asserted rather than assumed.
`action` and `detail` are IMPORTED from `keel.commands.trading` rather than
paraphrased. That module holds the wording so "neither re-words them", and the
drift was already visible: the CLI printed "Any real, unrecovered drawdown stops
being visible to the rail" where this module said "re-seeding the high-water
mark". A test asserts both strings are the module's own.
All seven non-Tier-1 actions are named individually now; `scope-attest` and
`posture-attest` were missing, so either could have been added for free. And the
module no longer describes wiring that does not exist in the present tense.
── TWO TESTS THAT WERE THEATRE ────────────────────────────────────────────────
The timing test grepped the source for `compare_digest`; a mutant replaced the
call and left the word in a trailing comment. `==` and `compare_digest` return
the same values for every input, so no black-box test can tell them apart -- it
reads the AST now and requires the RETURNED value to be that call.
Writing it turned up the same class of bug in my own test: `ast.walk` is
breadth-first, so `returns[-1]` is not the last return in the source. It passed
by luck and failed the moment an import edit shifted the lines. Sorted by line
number, with every earlier return asserted to be a refusal constant.
Six of the twelve original tests were vacuous on an empty table. Every loop and
count now asserts its own population first.
17/17 mutations killed, from 11/20. `tests/web` + `tests/test_capabilities.py`:
940 passed. ruff, format and mypy (460 files) clean. Still nothing routes here.
Closes #790.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD
* fix(web): the operation was a string, which is the one form no scan can see (#791 review)
Sixteen findings. The headline is about the review before this one, not the code:
**17/17 was scored against my own mutation list.** Against 27 independently
generated mutations the suite killed 11 and let 16 through. A score against the
defects you already thought of is not evidence, and this session has now been
bitten by that three times.
── THE FIX FOR THE CLICK.COMMAND BUG DEFEATED THE INVARIANT ───────────────────
`operation` was `"keel.commands.trading.disengage_kill_switch"` -- a dotted
STRING, which is exactly the one spelling no AST scan can see. Appending stage
2b's obvious dispatch, `getattr(import_module(mod), fn)(repo)`, left all three of
`keel/web/__init__.py`'s scans GREEN while the web layer released the kill
switch. And the comment beside the field asserted the opposite as fact -- "the
effect scan reasons about exactly these names". It reasons about `ast.Call`
callees and `ImportFrom` aliases; a string literal is neither.
`keel/web/__init__.py` calls the closure "structural rather than a matter of
discipline". That change converted it back into discipline while claiming the
reverse.
It is a direct function REFERENCE now. `keel/web/gates.py` imports
`disengage_kill_switch` and `clear_consecutive_loss_halt`, so the import and the
call site are both visible -- and the effect scan immediately, correctly,
objected. So the invariant is amended here rather than in 2b, to the precise
assertion: the gated operations this package imports must EQUAL the set its Tier
1 table names, in the one module that declares it. Verified from both sides --
importing a permitted effect elsewhere, importing a third effect into
`gates.py`, and holding an operation that no row names all fail.
That surfaced a pre-existing hole in the #789 derivation: `keel/cli.py` does
`from keel.commands.trading import record_flow as record_declared_flow`, so a
scan reading callee names alone dropped the record-flow effect entirely -- and an
aliased import of it into `keel/web/` passed every scan. Aliases are resolved
now; the derivation finds 12 effects where it found 11.
── A HARDCODED MASTER PHRASE PASSED 24 TESTS ──────────────────────────────────
`if presented == "OPEN SESAME": return True`. Every negative in the exactness
test is a MUTATION of the right answer -- lowercased, padded, truncated -- so
nothing ever fed a valid key a phrase that was not a near miss. The previous
suite had `phrase_matches("autonomy-on", "ARM AUTONOMY") is False` and my rewrite
dropped it, which is how a regression enters a file whose whole subject is not
regressing.
── AND THE KEY WAS NEVER TESTED AT ALL ────────────────────────────────────────
The phrase was parametrised over `5`, `["..."]`, `True`, `None`, `b"x"`. The key
got `None` and `""`. So dropping its `isinstance` guard passed -- and a list key
then raises `TypeError: unhashable`, the identical 500-instead-of-refusal bug,
one argument over. Case-folding and stripping the key passed too.
── THE REST ───────────────────────────────────────────────────────────────────
The AST test asserted a SPELLING. Shadowing `secrets` with a `SimpleNamespace`
whose `compare_digest` is `lambda a, b: a == b` satisfied it, as did
`compare_digest('y' if presented == phrase else 'n', 'y')` -- the real comparison
a plain `==` over a one-byte constant. It pins the callee identity and the
argument expressions now.
`MappingProxyType` over a module-visible `_TIER1_RAW` passed the closure test
while `gates._TIER1_RAW["autonomy-on"] = ...` remained the one-liner the proxy
exists to prevent. The freeze test covered one field of one row through
`pytest.raises(Exception)`, so a `__setattr__` guarding only `phrase` passed,
leaving `operation` rewritable after import.
`in vars(trading).values()` accepted any module attribute: `action=__name__`
passed, so the modal would have read "You are about to keel.commands.trading".
Both are identity assertions per row now, which also kills the copy-swap that
would show rail 16's ceremony while disengaging the kill switch.
The "imported, never restated" rule was stated three times and enforced nowhere;
re-declaring the four constants as literals passed. Asserted over the AST.
`b"RESUME TRADING"` would have released, because the only bytes fixture was
`b"x"` -- refused for its value, not its type. The U+00A0 case is spelled
` `, since as a literal it is invisible and one reformat turns it into a
duplicate of the passing case.
16/16 on the independent set. `tests/web` + `tests/test_capabilities.py`: 943
passed. ruff, format and mypy (460 files) clean.
`keel/web/__init__.py` says what is now true: seven unreachable, two reachable
only as the table declares them. Nothing routes there yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoGRoExgHVCsWDHT3ej8mD
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0990140 commit ada303c
4 files changed
Lines changed: 534 additions & 118 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
21 | | - | |
22 | | - | |
23 | | - | |
24 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
25 | 32 | | |
26 | 33 | | |
27 | 34 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | | - | |
| 1 | + | |
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
13 | | - | |
14 | | - | |
15 | | - | |
16 | | - | |
17 | | - | |
18 | | - | |
19 | | - | |
20 | | - | |
21 | | - | |
22 | | - | |
23 | | - | |
24 | | - | |
25 | | - | |
26 | | - | |
27 | | - | |
28 | | - | |
29 | | - | |
30 | | - | |
31 | | - | |
32 | | - | |
33 | | - | |
34 | | - | |
35 | | - | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
36 | 57 | | |
37 | 58 | | |
38 | 59 | | |
39 | 60 | | |
40 | 61 | | |
| 62 | + | |
41 | 63 | | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
42 | 75 | | |
43 | 76 | | |
44 | 77 | | |
45 | 78 | | |
46 | 79 | | |
47 | 80 | | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
55 | 101 | | |
56 | 102 | | |
57 | 103 | | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
64 | | - | |
65 | | - | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | | - | |
71 | | - | |
72 | | - | |
73 | | - | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
81 | 126 | | |
82 | 127 | | |
83 | 128 | | |
84 | 129 | | |
85 | 130 | | |
86 | | - | |
87 | | - | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
93 | | - | |
94 | | - | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
95 | 152 | | |
96 | | - | |
97 | | - | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
98 | 157 | | |
99 | 158 | | |
0 commit comments