Skip to content

Commit 1feaab1

Browse files
Toby1009claude
andcommitted
A browser click cannot assert CERTAIN, and the graph takes keyboard input
Three findings from operating the UI. The web form could write CERTAIN with an empty rationale. In the store that sat level with an OFAC designation --- and `docs/data-sources.md` says the ceilings are enforced in code rather than merely documented, and that only a published legal fact may assert CERTAIN. The form even labels the field "why --- required below medium" and then did not require it. Now capped at HIGH, and above MEDIUM needs a reason. The reply says when it lowered a claim and why: a silent downgrade is its own defect, because the writer believes the store holds CERTAIN and it does not. Twenty-seven graph nodes, none in the tab order. Selecting an address is the primary interaction of this tool and it was reachable only by pointing at it. The roster list gave a keyboard path to the same action, but a graph nobody can enter is not an accessible graph --- it is a picture with a workaround beside it. Nodes are now buttons with labels that include the address and, for a frontier, the fact that nobody looked past it. That last part matters: the dashed border carries the difference between "the money stopped" and "we stopped", and a dashed border is invisible to a reader who cannot see it. Focus was invisible everywhere --- the reset removed the browser default and nothing replaced it, so a keyboard user could cross 79 controls without ever seeing where they were. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXWQ25VgFeMuuMhGWjvdof
1 parent 3640e73 commit 1feaab1

5 files changed

Lines changed: 209 additions & 3 deletions

File tree

src/chainscope/server/local.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -580,18 +580,31 @@ def tag(self, body: dict[str, Any]) -> dict[str, Any]:
580580
origin = f"browser:{self.options.agent_name}"
581581
supplied = str(body.get("source", "")).strip()
582582
source = f"{origin} (reported: {supplied})" if supplied else origin
583+
# Inside the guard, not before it. Moving this lookup out of the try
584+
# turned an unknown level from a named error into a bare KeyError ---
585+
# caught by the test that exists to keep that message helpful.
586+
try:
587+
claimed = Confidence[str(body.get("confidence", "medium")).upper()]
588+
except KeyError as exc:
589+
raise ValueError(
590+
f"unknown confidence {body.get('confidence')!r}; use one of: "
591+
+ ", ".join(c.name.lower() for c in Confidence)
592+
) from exc
593+
rationale = str(body.get("rationale", "")).strip()
594+
confidence = _browser_ceiling(claimed, rationale)
595+
583596
try:
584597
attribution = Attribution(
585598
label=label,
586599
category=Category(str(body.get("category", "service"))),
587-
confidence=Confidence[str(body.get("confidence", "medium")).upper()],
600+
confidence=confidence,
588601
# MANUAL: a person clicked this while reading the page. That is
589602
# exactly the judgement the method field is meant to record.
590603
method=Method.MANUAL,
591604
source=source,
592605
address=address,
593606
chain=self._chain(body.get("chain")),
594-
rationale=str(body.get("rationale", "")),
607+
rationale=rationale,
595608
# From the server, never the request --- the same reasoning as
596609
# `source` above. A browser-written claim carried no analyst, so
597610
# `report` filed a label a person had typed alongside bulk
@@ -617,7 +630,22 @@ def tag(self, body: dict[str, Any]) -> dict[str, Any]:
617630
"category": attribution.category.value,
618631
"confidence": attribution.confidence.name,
619632
"source": attribution.source,
620-
}
633+
},
634+
# Said out loud when the claim was written weaker than it was
635+
# asked for. A silent downgrade is its own defect: the writer
636+
# believes the store holds CERTAIN, and it does not.
637+
"downgraded": (
638+
None
639+
if confidence == claimed
640+
else (
641+
f"asked for {claimed.name}, recorded {confidence.name}: "
642+
+ (
643+
"only a published legal fact may assert CERTAIN"
644+
if claimed > Confidence.HIGH
645+
else "above MEDIUM needs a rationale"
646+
)
647+
)
648+
),
621649
}
622650

623651
# ------------------------------------------------------- the browsable UI
@@ -974,6 +1002,31 @@ def _landing_for(options: ServerOptions) -> str:
9741002
return site.landing_page(options.store.exists(), str(options.store), transfers)
9751003

9761004

1005+
def _browser_ceiling(claimed: Confidence, rationale: str) -> Confidence:
1006+
"""What a claim typed into a browser is allowed to assert.
1007+
1008+
`docs/data-sources.md` says the ceilings are enforced in code rather than
1009+
merely documented, and that only a published legal fact --- a sanctions
1010+
designation --- may assert CERTAIN. The web form was outside that rule: a
1011+
click could write CERTAIN with an empty rationale, and in the store that
1012+
then sat level with an OFAC listing.
1013+
1014+
Two limits, both from rules this package already states elsewhere:
1015+
1016+
* CERTAIN is refused outright. A person reading a page is making a
1017+
judgement, however well founded, and judgement is not a legal fact.
1018+
* Above MEDIUM needs a reason. The form already labels that field "why ---
1019+
required below medium" and then did not require it; a HIGH claim whose
1020+
rationale is empty tells a later reader nothing about why to believe it.
1021+
1022+
Lowering rather than raising, always, and the caller reports what it did.
1023+
"""
1024+
capped = min(claimed, Confidence.HIGH)
1025+
if capped > Confidence.MEDIUM and not rationale:
1026+
return Confidence.MEDIUM
1027+
return capped
1028+
1029+
9771030
def _check_address(address: str, chain: ChainId) -> None:
9781031
"""Reject an address this chain cannot possibly hold, before spending a request.
9791032
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""What a claim typed into the web form is allowed to assert.
2+
3+
`docs/data-sources.md` says the confidence ceilings are enforced in code
4+
rather than merely documented, and that only a published legal fact --- a
5+
sanctions designation --- may assert CERTAIN. The web form sat outside that
6+
rule: a click could write CERTAIN with an empty rationale, and in the store
7+
it then sat level with an OFAC listing.
8+
9+
The form even labels the field "why --- required below medium" and then did
10+
not require it.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import pytest
16+
17+
from chainscope.core.attribution import Confidence
18+
from chainscope.server.local import _browser_ceiling
19+
20+
21+
def test_certain_is_refused_however_it_is_justified() -> None:
22+
"""A person reading a page is making a judgement, not stating a fact."""
23+
assert _browser_ceiling(Confidence.CERTAIN, "I am completely sure") <= Confidence.HIGH
24+
25+
26+
def test_above_medium_needs_a_reason() -> None:
27+
assert _browser_ceiling(Confidence.HIGH, "") == Confidence.MEDIUM
28+
29+
30+
def test_a_reason_earns_high() -> None:
31+
assert _browser_ceiling(Confidence.HIGH, "named in the bridge contract") == Confidence.HIGH
32+
33+
34+
@pytest.mark.parametrize("level", [Confidence.SPECULATIVE, Confidence.LOW, Confidence.MEDIUM])
35+
def test_weaker_claims_pass_through_untouched(level: Confidence) -> None:
36+
"""The ceiling lowers; it must never raise, and must not demand a reason
37+
for a claim that is already modest."""
38+
assert _browser_ceiling(level, "") == level
39+
40+
41+
def test_it_never_raises() -> None:
42+
for level in Confidence:
43+
for reason in ("", "because"):
44+
assert _browser_ceiling(level, reason) <= level
45+
46+
47+
def test_the_downgrade_is_reported_not_silent() -> None:
48+
"""A silent downgrade is its own defect: the writer believes the store
49+
holds CERTAIN and it does not."""
50+
from pathlib import Path
51+
52+
handler = Path("src/chainscope/server/local.py").read_text()
53+
assert '"downgraded"' in handler
54+
assert "only a published legal fact may assert CERTAIN" in handler
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Selecting an address must not require a mouse.
2+
3+
Measured on the running page: 27 nodes, none in the tab order. Selecting an
4+
address is the primary interaction of this tool, and it was reachable only by
5+
pointing at it. The roster list gave a keyboard path to the same action, but a
6+
graph nobody can enter is not an accessible graph --- it is a picture with a
7+
workaround beside it.
8+
9+
Focus was also invisible: the CSS reset removed the browser's default ring and
10+
nothing replaced it, so a keyboard user could move through 79 controls without
11+
ever seeing where they were.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from pathlib import Path
17+
18+
import pytest
19+
20+
GRAPH = Path("web/src/components/graph.tsx")
21+
CSS = Path("web/src/app/globals.css")
22+
23+
24+
@pytest.fixture(scope="module")
25+
def graph() -> str:
26+
return GRAPH.read_text()
27+
28+
29+
def test_nodes_are_in_the_tab_order(graph: str) -> None:
30+
assert "tabIndex={0}" in graph
31+
32+
33+
def test_nodes_announce_themselves(graph: str) -> None:
34+
"""A screen reader saying "button" 27 times is not a graph."""
35+
assert 'role="button"' in graph
36+
assert "aria-label=" in graph
37+
38+
39+
def test_a_frontier_node_says_so_in_its_label(graph: str) -> None:
40+
"""The dashed border is invisible to a reader who cannot see it, and it
41+
carries the difference between "the money stopped" and "nobody looked"."""
42+
# The node's label, not the svg's --- take the one beside role="button".
43+
block = graph.split('role="button"')[1][:500]
44+
assert "aria-label=" in block
45+
assert "frontier" in block, "a dashed border is invisible to a screen reader"
46+
47+
48+
def test_enter_and_space_select(graph: str) -> None:
49+
"""Both, because a role="button" is expected to answer both."""
50+
block = graph.split("onKeyDown=")[1][:220]
51+
assert '"Enter"' in block and '" "' in block
52+
assert "preventDefault" in block, "Space would scroll the page as well"
53+
54+
55+
def test_focus_is_visible() -> None:
56+
css = CSS.read_text()
57+
assert ":focus-visible" in css
58+
assert "outline:" in css.split(":focus-visible")[1][:160]
59+
60+
61+
def test_focused_nodes_are_visible_against_their_own_border() -> None:
62+
"""A card already has a border, so the ring has to differ from it."""
63+
css = CSS.read_text()
64+
block = css.split(".card:focus-visible rect")[1][:200]
65+
assert "stroke-width" in block

web/src/app/globals.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,3 +540,21 @@ label.asset input { accent-color: var(--cs-accent); }
540540
.busy-fill.sliding { animation-duration: 2.4s; }
541541
.spin { animation-duration: 1.6s; }
542542
}
543+
544+
/* Focus, made visible.
545+
The reset stripped the browser default and nothing replaced it, so a
546+
keyboard user could move through 79 controls without ever seeing where
547+
they were. Two-colour ring so it survives on both the pale panels and the
548+
dark stark callout. */
549+
:focus-visible {
550+
outline: 2px solid var(--cs-accent);
551+
outline-offset: 2px;
552+
border-radius: 1px;
553+
}
554+
.card:focus-visible { outline: none; }
555+
.card:focus-visible rect {
556+
stroke: var(--cs-accent);
557+
stroke-width: 3px;
558+
paint-order: stroke;
559+
}
560+
.callout.stark :focus-visible { outline-color: var(--cs-bg); }

web/src/components/graph.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,22 @@ export function Graph({
181181
className={classes.join(" ")}
182182
transform={`translate(${node.x},${node.y})`}
183183
onClick={() => onSelect(node.address)}
184+
// Selecting an address is the primary interaction here, and
185+
// it was reachable only with a mouse: 27 nodes, none in the
186+
// tab order. The roster list gave a keyboard path to the same
187+
// action, but a graph nobody can enter is not an accessible
188+
// graph --- it is a picture with a workaround.
189+
tabIndex={0}
190+
role="button"
191+
aria-label={`${node.label || (node.seed ? "seed" : "unlabelled")}, ${
192+
node.as_written || node.address
193+
}${node.frontier ? ", frontier — its counterparties were never fetched" : ""}`}
194+
onKeyDown={(event) => {
195+
if (event.key === "Enter" || event.key === " ") {
196+
event.preventDefault();
197+
onSelect(node.address);
198+
}
199+
}}
184200
>
185201
<rect width={CARD_W} height={CARD_H} rx={0} />
186202
<text className="name" x={10} y={19}>

0 commit comments

Comments
 (0)