店舗のPOSに「売上取消」ボタンがある。店長だけが押すべきものだ。
普通はこうする。ログインした人がアルバイトなら、そのボタンを描かない。
そしてそれを権限処理と呼ぶ。違う。
描かないことでは何も止められない
ボタンを描かないことが止めるのは、そのボタンを押してリクエストが出る経路ひとつだけだ。リクエストは他からも出せる。画面定義を直すこともできるし、そもそも画面なしで道具を呼ぶこともできる。画面はクライアントであり、クライアントは他人のものだ。
だからこのサンプルは逆に作った。ボタンを二人ともに見せる。

アルバイトの画面だ。「Void last sale」も「Open drawer」もそのままある。押せる。
押すとこうなる。
[staff] sale.ring -> "rang 3000" (sales=4 voided=0 refused=0)
[staff] sale.void -> "sale.void is not yours to press" (sales=4 voided=0 refused=1)
[staff] drawer.open -> "drawer.open is not yours to press"(sales=4 voided=0 refused=2)
売るのはできて、取消はできなかった。 画面が止めたのではなくサーバーがやらなかったのだ。
店長が同じ画面を開く

[manager] sale.ring -> "rang 3000" (sales=4 voided=0 refused=0)
[manager] sale.void -> "voided 3000" (sales=3 voided=1 refused=0)
[manager] drawer.open -> "drawer opened" (sales=3 voided=1 refused=0)
同じボタン、同じ順序、違う結果。
そして画面ファイルは同じファイルだ。 これがこの編の主張なので、ハーネスがハッシュを焼く。
screen ui/pages/register.json sha256:72288a2c655a — one file, both roles
the screen offers 3 buttons and shows all of them to everyone
役割は引数ではない
ここがこのサンプル唯一の設計上の決定だ。
/// Who this session is. Fixed for its lifetime, never taken from a call.
final String role;
sale.void が role を引数で受け取るとしよう。
sale.void { "role": "manager" }
そうなると呼べる人は誰でも店長だと言える。 その瞬間、本当に止めているのは画面がその値を送らないということだけであり、我々はまた「ボタンを描かない」に戻る。
だから役割はセッションが開くときに決まる。このサンプルでは実行引数で、実際の配備ならその接続が認証したもので。
final role = args
.firstWhere((a) => a.startsWith('--role='), orElse: () => '--role=staff')
.substring('--role='.length);
画面は役割を送らない。送りたくても送れない。
検証がそれを守る — 画面定義に役割名が現れれば失敗する。
# The screen may *display* the role it was told. It must never name one — the
# moment "manager" or "staff" appears in a screen definition, some branch is
# being decided in the wrong place.
if grep -qiE '"[^"]*(manager|staff)' register.mbd/ui/pages/register.json; then
echo " the screen definition names a role — the rule has leaked out of the tool"
exit 1
fi
画面は {{role}} を表示する。それはサーバーが言ってくれたものを描く仕事であり、何も決定しない。