From 4c9971c1e58fd18c86c59420cf946977b41ed3f4 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 20 Aug 2026 18:13:57 -0400 Subject: [PATCH] feat(setup): attest and promote from the browser, on the axis that turned out to be the right one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two JUDGEMENT steps live in the PAPER stage -- attesting an asset, promoting a rule -- so the rule I wrote two changes ago ("no judgement step may ever be an action") did not make anything safer. It made a terminal-free paper deployment impossible, which is the entire point of the milestone. This fixes the rule, not the goal. THE INVARIANT MOVES TO WHERE IT ALWAYS BELONGED: the capability registry. Not one of the eleven actions in `keel/capabilities.py` may be reachable from `keel/web/`, and that was already scanned and pinned independently. `StepKind` was a poor proxy for it, tried twice -- "MECHANICAL only", then "MECHANICAL or OPERATOR_INPUT" -- and wrong both times. The test now asserts by IDENTITY: every action's step is checked against the eleven invocations, and `withdrawals attest --enabled` is named as the judgement step that consequently has no form. That is the line the code already drew; taste had nothing to do with it. The step-kind rule becomes the secondary policy it should always have been: **a wizard may record what the operator supplies; it may never supply it.** An action over a judgement step must declare inputs, every one required, with nothing pre-filled and no select opening on a valid option. THAT LAST CLAUSE IS NOT PEDANTRY. `pays_yield` as a checkbox would open unticked, and unticked is `no` -- the PERMISSIVE answer, since a yield-bearing asset fails the screen (KB §28.4). A form whose default answer is the compliant one attests on the operator's behalf. So it is a select that opens on a disabled "choose…" and is `required`: it cannot be submitted without someone answering. `backing` is the same shape. Exposing an attestation is safe for a reason its own CLI command states: "an attestation cannot itself place an order or raise a cap, and the screen it feeds only ever ADMITS to a list that `guards.py` rail 1 still enforces per-trade". It is not one of the eleven and the CLI imposes no ceremony on it beyond the mandatory source. PROMOTION HAS NO FORCE FIELD, and that is load-bearing rather than an omission. `attempt_promotion`'s docstring is explicit: force "carries no gate HERE ... the O3 contract is the front-end's to keep, never the service's to assume." The console keeps that contract with a typed terminal confirmation. A browser cannot, so this front-end simply has no force path -- and force-promote is itself one of the eleven. `force=False` is hard-wired and pinned by test. Promotion re-runs a backtest, so it runs as a job for the same reason a fetch does. The backing choices are literals to keep `keel.compliance.screen` off the CLI's import path; that duplication is pinned against `KNOWN_BACKINGS`, because a backing kind added there and missing here would be silently un-attestable from the browser. VERIFIED END TO END against a live server: three assets attested from the browser and recorded with exactly the supplied sector, backing, yield answer, source and attester; a submission with a blank source recorded NOTHING; the checklist then names the assets still outstanding; and `withdrawals attest --enabled` still shows its CLI command rather than a form. 4087 passed, 3 skipped (5 new). ruff clean repo-wide; mypy clean over keel + packages. Stacked on #467 -- it builds directly on that PR's `Action`/`ActionInput` and job runner. Refs #437, #436, #18. Co-Authored-By: Claude Opus 5 (1M context) --- keel/commands/setup.py | 177 ++++++++++++++++++++++++++++++++++++--- keel/web/render.py | 44 +++++++--- tests/web/test_server.py | 109 +++++++++++++++++++----- 3 files changed, 284 insertions(+), 46 deletions(-) diff --git a/keel/commands/setup.py b/keel/commands/setup.py index 856ad3f..8913b52 100644 --- a/keel/commands/setup.py +++ b/keel/commands/setup.py @@ -623,16 +623,26 @@ def _state_as_json(state: DeploymentState) -> dict[str, Any]: # # Everything below WRITES. Read the rules before adding to it: # -# 1. Only `MECHANICAL` and `OPERATOR_INPUT` steps may appear here, and the line between them and -# the rest is the point. A MECHANICAL step has no input: the machine simply does it. An -# OPERATOR_INPUT step is a FACT only the operator has -- an API key -- which a wizard can -# record and could not possibly invent. +# 1. THE INVARIANT IS THE CAPABILITY REGISTRY, not the step kind. Not one of the eleven actions +# in `keel/capabilities.py` may be reachable from `keel/web/`, and a test scans that package +# to prove it. That is what stops the browser arming autonomy, releasing a halt, rebasing the +# high-water mark or replacing the binary. # -# `JUDGEMENT` steps are DECISIONS: a Shariah classification, a promotion. A form that recorded -# one would be making a compliance ruling on the operator's behalf, and no amount of "but they -# clicked it" makes that the same thing as their having decided it. `OFF_VENUE` steps happen -# somewhere keel cannot reach at all. Neither may ever be an action here, and the tests -# enforce it against `STEPS` rather than against a list kept in this file. +# `StepKind` was tried as the rule ("MECHANICAL only", then "MECHANICAL or OPERATOR_INPUT") +# and it was the wrong axis. Two JUDGEMENT steps -- attesting an asset, promoting a rule -- +# live in the PAPER stage, so forbidding them did not buy safety (the registry already +# protects the dangerous ones); it only made a terminal-free paper deployment impossible, +# which is the whole point of the milestone. +# +# So the step-kind rule is now the SECONDARY policy it should always have been: **a wizard may +# record what the operator supplies; it may never supply it.** An action over a JUDGEMENT step +# must therefore declare inputs, every one of them required, with no defaults and nothing +# pre-selected -- and `OFF_VENUE` steps stay unreachable, because there is nothing there to +# record that would be true. +# +# What remains CLI-only is decided by the registry, not by taste: `withdrawals attest +# --enabled` is one of the eleven and stays behind the TTY gate. `confirm_cycle` is not an +# action because it cannot be observed at all. # # 2. An action with `inputs` records ONLY what was submitted. It has no defaults, no fallbacks and # no "sensible guess" -- an action that could fill in a field the operator left blank is one @@ -687,6 +697,11 @@ class ActionInput: name: str label: str secret: bool = False + #: A closed set of answers. Rendered as a select with NOTHING pre-selected -- see + #: `keel/web/render.py`. Empty means free text. + choices: tuple[str, ...] = () + #: Shown under the field. For a judgement, this is where the question actually gets asked. + hint: str = "" @dataclass(frozen=True) @@ -861,9 +876,9 @@ def _run(echo: Callable[[str], None]) -> None: from keel.commands._products import parse_products_option from keel.commands.fetch import run_fetch from keel.config import load_config + from keel.data import freshness as freshness_mod from keel.data.db import connect from keel.data.repository import Repository - from keel.data import freshness as freshness_mod config = load_config(str(config_path)) products, _warnings = parse_products_option(None, config) @@ -897,6 +912,106 @@ def _run(echo: Callable[[str], None]) -> None: ) +#: The classifications `keel assets attest` accepts. Read from the screen's own vocabulary so a +#: new backing kind cannot appear there and be missing here. +def _backing_choices() -> tuple[str, ...]: + from keel.compliance import screen as screen_mod + + return tuple(sorted(screen_mod.KNOWN_BACKINGS)) + + +def attest_asset(config_path: Path, db_path: Path, values: dict[str, str]) -> ActionResult: + """Record one asset's Shariah classification -- EXACTLY as supplied, or not at all. + + Its CLI counterpart states the reason this is safe to expose: "an attestation cannot itself + place an order or raise a cap, and the screen it feeds only ever ADMITS to a list that + `guards.py` rail 1 still enforces per-trade". It is not one of the eleven, and the CLI imposes + no ceremony on it beyond the mandatory `source`. + + Every field is required and NOTHING is defaulted. `pays_yield` in particular is a choice with + no pre-selection rather than a checkbox: an unticked box would default to `no`, which is the + PERMISSIVE answer (a yield-bearing asset fails KB §28.4), and a form whose default answer is + the compliant one is a form that attests on the operator's behalf. + """ + from keel.data.db import connect + from keel.data.repository import Repository + + required = ("asset", "sector", "backing", "pays_yield", "source", "attested_by") + missing = [name for name in required if not values.get(name, "").strip()] + if missing: + return ActionResult( + "assets_attested", False, f"nothing recorded -- {', '.join(missing)} was blank" + ) + + backing = values["backing"].strip() + if backing not in _backing_choices(): + return ActionResult("assets_attested", False, f"unknown backing {backing!r}") + if values["pays_yield"].strip() not in ("yes", "no"): + return ActionResult("assets_attested", False, "answer the yield question") + + asset = values["asset"].strip().upper() + conn = connect(str(db_path)) + try: + Repository(conn).upsert_asset_attestation( + asset=asset, + sector=values["sector"].strip(), + backing=backing, + pays_yield=values["pays_yield"].strip() == "yes", + source=values["source"].strip(), + attested_by=values["attested_by"].strip(), + attested_at=int(time.time()), + ) + conn.commit() + finally: + conn.close() + return ActionResult("assets_attested", True, f"attested {asset}") + + +def promote_rule(config_path: Path, db_path: Path, values: dict[str, str]) -> ActionResult: + """Re-run a rule's backtest and advance it IF it clears the gate -- in the background. + + **`force` is hard-wired False and is not a field.** `attempt_promotion`'s own docstring is + explicit that force "carries no gate HERE ... the O3 contract is the front-end's to keep, + never the service's to assume" -- so a front-end that offered it would be the thing removing + the gate. The console's force path runs a typed terminal confirmation first; this one simply + does not have a force path, which is the only version of that contract a browser can keep. + + A promotion re-runs a backtest, so it is a job for the same reason a fetch is. + """ + from keel.commands import jobs + + raw = values.get("rule_id", "").strip() + if not raw.isdigit(): + return ActionResult("rule_promoted", False, "nothing done -- give a numeric rule id") + rule_id = int(raw) + + if jobs.is_running(): + return ActionResult("rule_promoted", False, "a job is already running") + + def _run(echo: Callable[[str], None]) -> None: + from keel.commands.rules import attempt_promotion + from keel.config import load_config + from keel.data.db import connect + from keel.data.repository import Repository + + conn = connect(str(db_path)) + try: + attempt_promotion( + Repository(conn), + lambda: load_config(str(config_path)), + rule_id, + force=False, + echo=echo, + echo_err=echo, + ) + conn.commit() + finally: + conn.close() + + jobs.start("rule_promoted", _run) + return ActionResult("rule_promoted", True, "running the backtest and gate -- progress is above") + + #: THE closed set of steps a machine may perform on the operator's behalf. ACTIONS: tuple[Action, ...] = ( Action( @@ -937,6 +1052,48 @@ def _run(echo: Callable[[str], None]) -> None: ActionInput("CDP_API_SECRET", "CDP API secret", secret=True), ), ), + Action( + key="assets_attested", + title="Record this attestation", + detail=( + "Your classification of one asset, recorded exactly as you give it. keel does not " + "check it and cannot: it is a statement about the world, and the source is what makes " + "it one. Nothing here is filled in for you." + ), + run=attest_asset, + inputs=( + ActionInput("asset", "Asset code", hint="e.g. BTC"), + ActionInput("sector", "Core business line or purpose", hint="what the token is for"), + ActionInput( + "backing", + "Backing", + choices=("ayn", "dayn", "native"), + hint=( + "'ayn (an owned thing), dayn (a claim on an issuer), native (a base-layer coin)" + ), + ), + ActionInput( + "pays_yield", + "Does holding it earn a return?", + choices=("no", "yes"), + hint="a yield-bearing asset is refused by the screen (KB §28.4)", + ), + ActionInput( + "source", "Source", hint="URL, standard or ruling this was established from" + ), + ActionInput("attested_by", "Attested by", hint="who established it"), + ), + ), + Action( + key="rule_promoted", + title="Promote this rule", + detail=( + "Re-runs the rule's backtest and advances it only if it clears the gate. There is no " + "force option here: bypassing the gate needs a terminal." + ), + run=promote_rule, + inputs=(ActionInput("rule_id", "Rule id", hint="from the Rules page"),), + ), Action( key="market_data", title="Fetch market data", diff --git a/keel/web/render.py b/keel/web/render.py index 4d921fd..5a68238 100644 --- a/keel/web/render.py +++ b/keel/web/render.py @@ -93,8 +93,9 @@ form { margin: 0.5rem 0 0; } .field { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.6rem 0; max-width: 26rem; } .field span { font-size: 0.8rem; color: var(--muted); } -.field input { font: inherit; padding: 0.4rem 0.6rem; border-radius: 7px; +.field input, .field select { font: inherit; padding: 0.4rem 0.6rem; border-radius: 7px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); } +.field em { font-style: normal; font-size: 0.78rem; color: var(--muted); } button { font: inherit; font-weight: 550; padding: 0.35rem 0.9rem; border-radius: 7px; border: 1px solid var(--accent); background: var(--accent); color: var(--card); cursor: pointer; } @@ -745,6 +746,30 @@ def render_setup( return "".join(parts) +def _action_field(field: Any) -> str: + """One input. A `choices` field renders as a select whose FIRST option is an empty, + disabled, selected placeholder -- so the form opens with no valid answer chosen. + + That placeholder is the whole point for a judgement. A checkbox for "does it pay yield?" + would open unticked, and unticked is `no`, which is the PERMISSIVE answer -- a form whose + default is the compliant one attests on the operator's behalf. A select that starts on + "choose…" and is `required` cannot be submitted without someone answering. + """ + label = f"{esc(field.label)}" + hint = f"{esc(field.hint)}" if getattr(field, "hint", "") else "" + choices = getattr(field, "choices", ()) + if choices: + options = '' + "".join( + f"" for choice in choices + ) + control = f'' + else: + kind = "password" if field.secret else "text" + extra = ' autocomplete="off" spellcheck="false"' if field.secret else "" + control = f'' + return f'' + + def _action_form(action: Any, csrf: str) -> str: """One action key, one write token, and only the fields the action itself declares. @@ -753,18 +778,11 @@ def _action_form(action: Any, csrf: str) -> str: where it survives a screenshot, a "view source", and anything that saves the page. The cost is that a failed submission must be retyped; that is the correct cost. - `autocomplete="off"` on the secret fields keeps a browser password manager from offering to - store an exchange API key as though it were a website login.""" - fields = "" - for field in getattr(action, "inputs", ()): - kind = "password" if field.secret else "text" - extra = ' autocomplete="off" spellcheck="false"' if field.secret else "" - fields += ( - '" - ) + NO field is ever given a `value`, secret or not, and no select opens on a valid option. An + action over a judgement step records what the operator supplied and nothing else, and a + pre-filled form is the shortest route to recording something they did not. + """ + fields = "".join(_action_field(field) for field in getattr(action, "inputs", ())) return ( f'
' f'' diff --git a/tests/web/test_server.py b/tests/web/test_server.py index eb66692..1aae28a 100644 --- a/tests/web/test_server.py +++ b/tests/web/test_server.py @@ -150,33 +150,96 @@ def test_post_is_refused_everywhere_except_the_setup_actions( # -- the guarantee that replaced "no POST at all" --------------------------------------------- -def test_the_write_surface_never_covers_a_judgement_or_an_off_venue_step() -> None: - """The line that matters, and it is not "mechanical only" any more. - - A MECHANICAL step has no input: the machine does it. An OPERATOR_INPUT step is a FACT only the - operator has -- an API key -- which a wizard can record and could not possibly invent. Both - are safe to offer as a form. - - A JUDGEMENT is a DECISION: a Shariah classification, a promotion. A form that recorded one - would be making a compliance ruling on the operator's behalf, and no amount of "but they - clicked it" makes that the same thing as their having decided it. An OFF_VENUE step happens - somewhere keel cannot reach at all. Neither may ever be an action. - """ +def test_the_write_surface_reaches_no_capability_increasing_action() -> None: + """THE invariant, and the one the step-kind rule kept being a poor proxy for. + + `StepKind` was tried as the rule twice -- "MECHANICAL only", then "MECHANICAL or + OPERATOR_INPUT" -- and both were the wrong axis. Two JUDGEMENT steps (attesting an asset, + promoting a rule) live in the PAPER stage, so forbidding them bought no safety, because the + capability registry already protects the dangerous ones. It only made a terminal-free paper + deployment impossible, which is the entire point of the milestone. + + What must never be reachable is the eleven. `withdrawals attest --enabled` is among them and + is therefore not an action, and this asserts that by IDENTITY rather than by taste.""" + from keel.capabilities import CAPABILITIES + from keel.commands.setup import ACTIONS, STEPS + + gated_invocations = {cap.invocation for cap in CAPABILITIES} + by_key = {step.key: step for step in STEPS} + for action in ACTIONS: + step = by_key[action.key] + for invocation in gated_invocations: + assert invocation not in step.how, ( + f"the {action.key} action runs {invocation!r}, which is behind the TTY gate" + ) + + # The one judgement step that IS among the eleven must have no action. + assert "withdrawals_attested" in by_key + assert "withdrawals_attested" not in {action.key for action in ACTIONS} + + +def test_no_off_venue_step_is_ever_an_action() -> None: + """There is nothing there to record that would be true. keel cannot see whether USDC Rewards + is off, and a form that recorded "I turned it off" would record what was asserted, not what + is -- which the operator runbook names as worse than an honest manual step.""" from keel.commands.setup import ACTIONS, STEPS, StepKind + off_venue = {s.key for s in STEPS if s.kind is StepKind.OFF_VENUE} + assert off_venue, "no off-venue steps exist, so this proves nothing" + assert not (off_venue & {a.key for a in ACTIONS}) + + +def test_a_judgement_action_asks_for_everything_and_assumes_nothing() -> None: + """The secondary policy, and the one that makes exposing a judgement acceptable: a wizard may + RECORD what the operator supplies; it may never SUPPLY it. + + So an action over a judgement step must declare inputs, and no field may open on a valid + answer. A `pays_yield` checkbox would open unticked, and unticked is `no` -- the permissive + answer, since a yield-bearing asset fails the screen. A form whose default is the compliant + one attests on the operator's behalf.""" + from keel.commands.setup import ACTIONS, STEPS, StepKind + from keel.web import render + by_key = {step.key: step for step in STEPS} - declared = {action.key for action in ACTIONS} - assert declared, "an empty write surface would make every test below vacuous" + judgements = [a for a in ACTIONS if by_key[a.key].kind is StepKind.JUDGEMENT] + assert judgements, "no judgement actions exist, so this proves nothing" + + for action in judgements: + assert action.inputs, action.key + html = render._action_form(action, "tok") + assert html.count("required") == len(action.inputs), action.key + # Nothing pre-filled and nothing pre-chosen: every select opens on the disabled + # placeholder, and no input carries a value. + assert 'value="" disabled selected' in html or " None: + """The form lists them as literals to keep `keel.compliance.screen` off the CLI's import + path. That is a duplication, so it is pinned: a backing kind added to the screen and missing + from the form would be un-attestable from the browser, silently.""" + from keel.commands.setup import _backing_choices, action_for + + field = next(f for f in action_for("assets_attested").inputs if f.name == "backing") + assert tuple(sorted(field.choices)) == _backing_choices() + + +def test_promotion_offers_no_force_field() -> None: + """`attempt_promotion`'s own docstring: force "carries no gate HERE ... the O3 contract is the + front-end's to keep, never the service's to assume". The console keeps it with a typed + terminal confirmation. A browser cannot keep it that way, so this front-end simply has no + force path -- and force-promote is itself one of the eleven.""" + import inspect as inspect_mod - for key in declared: - assert key in by_key, f"{key} is an action over no declared step" - assert by_key[key].kind in (StepKind.MECHANICAL, StepKind.OPERATOR_INPUT), key + from keel.commands import setup as setup_mod - forbidden = { - step.key for step in STEPS if step.kind in (StepKind.JUDGEMENT, StepKind.OFF_VENUE) - } - assert forbidden, "no judgement or off-venue steps exist, so this proves nothing" - assert not (declared & forbidden), sorted(declared & forbidden) + action = setup_mod.action_for("rule_promoted") + assert {f.name for f in action.inputs} == {"rule_id"} + source = inspect_mod.getsource(setup_mod.promote_rule) + assert "force=False" in source + assert "force=True" not in source def test_an_action_declares_inputs_exactly_when_its_step_needs_them() -> None: @@ -187,7 +250,7 @@ def test_an_action_declares_inputs_exactly_when_its_step_needs_them() -> None: by_key = {step.key: step for step in STEPS} for action in ACTIONS: - needs = by_key[action.key].kind is StepKind.OPERATOR_INPUT + needs = by_key[action.key].kind in (StepKind.OPERATOR_INPUT, StepKind.JUDGEMENT) assert action.needs_input is needs, action.key