diff --git a/CHANGELOG.md b/CHANGELOG.md index 19050620..34987279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -214,6 +214,13 @@ have recovered or retrying work nothing can. ### Documentation +- **The browser page is a playground.** `/try-it` runs one protected refund on the released + wheel in the tab: an amount, a payment id, a *lose the reply* switch, a *Refund* button and + an *Approve* button that is the human. Every line it prints is `ctrlrun`'s own — the page's + Python is a module over the public API, read out of the JavaScript by the same regex the + Node harness uses, and `tests/test_docs_travelling.py` runs it natively through the six + steps the page suggests on every commit. The harness records what it ran in + `docs/assets/browser-demo.verified.json`, and the page's quoted versions are held to it. - **The front door leads somewhere.** The docs home shows `@protect` and a policy before it shows anything else, the capability grid shows the six guarantees and folds the other twenty, the quickstart is titled for what it takes and opens with `pip install`, the cookbook sidebar diff --git a/README.md b/README.md index d25d53ea..33c549d7 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,9 @@ an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says pip install ctrlrun && ctrlrun demo ``` -No Python to hand? [Try it in your browser](https://ctrlrun.dev/try-it) runs the same five -scenarios on the released wheel, in the tab, with nothing sent anywhere. +No Python to hand? [Break a protected action in your browser](https://ctrlrun.dev/try-it): +one refund under one policy on the released wheel, in the tab, with nothing sent anywhere. +Approve €2,000, execute €5,000, lose a reply, retry — and read what refused you. ## What `ctrlrun demo` shows diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md index 62013bbe..555cddda 100644 --- a/docs/CLAIMS.md +++ b/docs/CLAIMS.md @@ -249,6 +249,19 @@ sentence that rots quietly. | `production/operations` | the signals table, and that there is nothing to run | the recovery rows above and `reference/cli`; `test_T177c_the_command_list_is_exactly_the_one_the_spec_froze` asserts the command list these are drawn from | | the badge row | the test-count badge | written by `tools/docs_audit/render_badges.py --write-count` in CI **after** `scripts/check.sh` has passed, published to the `badges` branch only on a push to `main`; `test_ci_publishes_the_test_count_badge_after_the_suite_has_passed` asserts the order | +## The browser playground + +The Try-it page says every line in its box was produced by `ctrlrun` in the tab, and that the +sequence it tells the reader to try ends the way it says. + +| Claim | Code | Proof | +|---|---|---| +| "Every line in the box was produced by `ctrlrun` here: the page owns the controls and nothing else." | `PLAYGROUND` in `docs/try-it.js` is a module over `Control`, `InMemoryStateStore`, `LocalApprovalProvider` and `@protect`; the JavaScript builds a request and prints the JSON `step()` returns | `test_the_playground_runs_the_sequence_the_page_tells_the_reader_to_try` runs that module natively through the six steps and asserts each outcome; `docs/assets/verify-browser-demo.mjs` runs the same module and sequence under Pyodide | +| the six steps: allowed · `ApprovalRequired` then `ApprovalMismatch` then executed · `consumed` · `ActionDenied` with no request · `AMBIGUOUS` then a refused retry with one remote call · `DuplicateEffect` | the same kernel paths the matrix rows above cite | the same test, and `test_the_page_names_every_outcome_the_module_can_return`, which holds the page's vocabulary to the module's | +| an approval is the reader pressing *Approve*, recorded by `grant_approval`; no auto-approve, no dry run | one `grant_approval(` in the module and nothing else that grants | `test_the_playground_has_no_way_to_grant_but_the_human_button` | +| the policy shown is the policy that ran | `POLICY` in the module | `test_the_playground_policy_on_the_page_is_the_policy_in_the_module` | +| the versions and date the page quotes are a run's | `docs/assets/browser-demo.verified.json`, written by the harness after a passing run | `test_the_page_quotes_the_run_the_harness_recorded` | + ## Demo output The README quotes `ctrlrun demo` verbatim. diff --git a/docs/assets/browser-demo.verified.json b/docs/assets/browser-demo.verified.json new file mode 100644 index 00000000..806f832f --- /dev/null +++ b/docs/assets/browser-demo.verified.json @@ -0,0 +1,7 @@ +{ + "date": "2026-09-07", + "pyodide": "314.0.6", + "python": "3.14.2", + "sqlite": "3.39.0", + "ctrlrun": "0.5.0" +} diff --git a/docs/assets/verify-browser-demo.mjs b/docs/assets/verify-browser-demo.mjs index 94370fe1..c574f13b 100644 --- a/docs/assets/verify-browser-demo.mjs +++ b/docs/assets/verify-browser-demo.mjs @@ -1,29 +1,36 @@ -// Does `ctrlrun demo` run in Pyodide? The honest test behind docs/try-it.mdx: +// Does the Try-it page's Python run in Pyodide? The honest test behind docs/try-it.mdx: // // npm install pyodide // node docs/assets/verify-browser-demo.mjs // -// It runs the page's own Python, read out of docs/try-it.js. It did not always: until -// 2026-09-06 this file carried its own copy of the program, so it verified code the page never -// ran, and the copy that shipped ended a line on a trailing `+` — a SyntaxError, which every -// reader got instead of the demo. A harness with its own copy of the artifact verifies the -// copy. `tests/test_docs_travelling.py` compiles the same string on every commit, so that -// class of break fails in CI without Node or the network; this file is what proves it *runs*. +// It runs the page's own Python, read out of docs/try-it.js: the PLAYGROUND module, driven +// through the six steps the page tells the reader to try, and then PROGRAM, which is +// `ctrlrun demo`. It did not always: until 2026-09-06 this file carried its own copy of the +// program, so it verified code the page never ran, and the copy that shipped ended a line on a +// trailing `+` — a SyntaxError, which every reader got instead of the demo. A harness with its +// own copy of the artifact verifies the copy. `tests/test_docs_travelling.py` compiles the demo +// and runs the playground natively on every commit, so that class of break fails in CI without +// Node or the network; this file is what proves both *run under Pyodide*. // -// Last run 2026-09-06: Pyodide 314.0.6, Python 3.14.2, SQLite 3.39.0, ctrlrun 0.5.0 from PyPI, -// all five scenarios. `sqlite3` is bundled into Pyodide 314 and must NOT be passed to -// loadPackage, which is the one thing that failed the first time. -import { readFileSync } from "node:fs"; +// Last run 2026-09-07: Pyodide 314.0.6, Python 3.14.2, SQLite 3.39.0, ctrlrun 0.5.0 from PyPI, +// the playground sequence and all five scenarios. `sqlite3` is bundled into Pyodide 314 and +// must NOT be passed to loadPackage, which is the one thing that failed the first time. +import { readFileSync, writeFileSync } from "node:fs"; import { loadPyodide } from "pyodide"; -// The page's PROGRAM array is JSON by construction, so this is a parse and not an eval: a -// harness that re-implemented the extraction could drift the same way the copy did. +// The page's arrays are JSON by construction, so this is a parse and not an eval: a harness +// that re-implemented the extraction could drift the same way the copy did. const here = new URL(".", import.meta.url).pathname; const script = readFileSync(`${here}../try-it.js`, "utf8"); -const body = /var PROGRAM = \[(.*?)\]\.join/s.exec(script); -if (!body) throw new Error("docs/try-it.js: no PROGRAM array — the page's Python moved"); -const PROGRAM = JSON.parse(`[${body[1]}]`).join("\n"); -console.log("program read from docs/try-it.js:", PROGRAM.split("\n").length, "lines"); + +function pythonIn(name) { + const body = new RegExp(`var ${name} = \\[(.*?)\\]\\.join`, "s").exec(script); + if (!body) throw new Error(`docs/try-it.js: no ${name} array — the page's Python moved`); + return JSON.parse(`[${body[1]}]`).join("\n"); +} +const PLAYGROUND = pythonIn("PLAYGROUND"); +const PROGRAM = pythonIn("PROGRAM"); +console.log("programs read from docs/try-it.js:", PLAYGROUND.split("\n").length, "+", PROGRAM.split("\n").length, "lines"); const pyodide = await loadPyodide(); console.log("pyodide", pyodide.version, "python", pyodide.runPython("import sys; sys.version")); @@ -32,6 +39,46 @@ console.log("sqlite3 bundled:", pyodide.runPython("import sqlite3; sqlite3.sqlit await pyodide.loadPackage(["micropip", "pyyaml"]); const micropip = pyodide.pyimport("micropip"); await micropip.install("ctrlrun"); +const versions = { + date: new Date().toLocaleDateString("en-CA"), // YYYY-MM-DD, local + pyodide: pyodide.version, + python: pyodide.runPython("import platform; platform.python_version()"), + sqlite: pyodide.runPython("import sqlite3; sqlite3.sqlite_version"), + ctrlrun: pyodide.runPython("import importlib.metadata; importlib.metadata.version('ctrlrun')"), +}; +console.log("ctrlrun", versions.ctrlrun); + +// --- the playground: the same calls the page's JavaScript makes, the same sequence the page +// tells the reader to try, and the outcome each step must report. +pyodide.runPython(PLAYGROUND); +function step(request) { + pyodide.globals.set("PLAYGROUND_REQUEST", JSON.stringify(request)); + return JSON.parse(pyodide.runPython("step(PLAYGROUND_REQUEST)")); +} +function expect(label, result, outcome, extra = {}) { + const ok = result.outcome === outcome && Object.entries(extra).every(([k, v]) => result[k] === v); + console.log(`${ok ? "ok " : "FAIL"} ${label}: ${result.outcome}${result.reason ? " (" + result.reason + ")" : ""} remote_calls=${result.remote_calls}`); + if (!ok) throw new Error(`${label}: expected ${outcome} ${JSON.stringify(extra)}, got ${JSON.stringify(result)}`); + return result; +} +expect("1 €500 txn_1", step({ op: "refund", payment_id: "txn_1", amount: 50000, lose_reply: false }), "executed", { remote_calls: 1 }); +const asked = expect("2 €2,000 txn_2", step({ op: "refund", payment_id: "txn_2", amount: 200000, lose_reply: false }), "approval_required", { remote_calls: 0 }); +const granted = step({ op: "approve", request_id: asked.request_id }); +if (granted.approval_id !== asked.request_id) throw new Error("approve did not grant the request"); +console.log("ok 2 human approves", granted.approval_id); +expect("2 €5,000 on that approval", step({ op: "refund", payment_id: "txn_2", amount: 500000, approval_id: asked.request_id }), "approval_mismatch", { reason: "mismatch", remote_calls: 0 }); +expect("2 €2,000 on that approval", step({ op: "refund", payment_id: "txn_2", amount: 200000, approval_id: asked.request_id }), "executed", { remote_calls: 1 }); +expect("3 the approval again", step({ op: "refund", payment_id: "txn_2", amount: 200000, approval_id: asked.request_id }), "approval_mismatch", { reason: "consumed", remote_calls: 1 }); +expect("4 €20,000 txn_3", step({ op: "refund", payment_id: "txn_3", amount: 2000000, lose_reply: false }), "denied", { remote_calls: 0 }); +expect("5 €500 txn_4, reply lost", step({ op: "refund", payment_id: "txn_4", amount: 50000, lose_reply: true }), "reply_lost", { remote_calls: 1 }); +expect("5 retry txn_4", step({ op: "refund", payment_id: "txn_4", amount: 50000, lose_reply: false }), "ambiguous_retry", { remote_calls: 1 }); +expect("6 €500 txn_1 again", step({ op: "refund", payment_id: "txn_1", amount: 50000, lose_reply: false }), "duplicate", { remote_calls: 1 }); +// --- the demo const out = pyodide.runPython(PROGRAM); console.log(out); + +// What ran, for the page to quote and a test to hold it to: the transcript on the page names +// a ctrlrun version and a Python version, and this is the only place those numbers come from. +writeFileSync(`${here}browser-demo.verified.json`, JSON.stringify(versions, null, 2) + "\n"); +console.log("wrote browser-demo.verified.json:", JSON.stringify(versions)); diff --git a/docs/assets/verify-browser-wiring.mjs b/docs/assets/verify-browser-wiring.mjs index 3aa90cca..827c9c04 100644 --- a/docs/assets/verify-browser-wiring.mjs +++ b/docs/assets/verify-browser-wiring.mjs @@ -1,14 +1,16 @@ -// Does docs/try-it.js wire the button when the container appears *after* the script has run? -// That is the single-page-app case the deployed site hit: Mintlify runs a custom script once, -// when the page becomes interactive, which on this site is before React paints the page and -// never again on a client-side navigation. The button did nothing. +// Does docs/try-it.js wire the page's controls when the containers appear *after* the script +// has run? That is the single-page-app case the deployed site hit: Mintlify runs a custom +// script once, when the page becomes interactive, which on this site is before React paints +// the page and never again on a client-side navigation. The button did nothing. // // npm install jsdom // node docs/assets/verify-browser-wiring.mjs // -// Last run 2026-09-06, all five checks true. The first is the one that keeps the script -// harmless: Mintlify injects it on every page of the site, so a page without the container -// must see nothing happen at all. +// Last run 2026-09-07, every check true. The first is the one that keeps the script harmless: +// Mintlify injects it on every page of the site, so a page without a container must see +// nothing happen at all. The playground checks stub Pyodide with a `step` that answers like the +// module does, so what is verified here is the JavaScript's half — which control builds which +// request, which outcome shows the Approve button, which line the reader gets. import { readFileSync } from "node:fs"; import { JSDOM } from "jsdom"; @@ -25,25 +27,40 @@ function fresh() { return dom; } -function mount(dom) { - dom.window.document.querySelector("main").innerHTML = - '
idle
idle
idle
.
+ // Mintlify's theme lays this out as `display: flex`, and in a flex container every
+ // appended child is a flex item in a *row*: the lines ran off to the right instead of
+ // down, and a coloured refusal landed beside its neighbours rather than under them.
+ // `say` never hit it because it sets a single text node, which is one item however the
+ // box is laid out. The page also asks for `display: block` — belt to this brace, since
+ // an inline style loses to an `!important` rule the theme does not have today.
+ function blockIn(output) {
+ output.textContent = "";
+ var body = document.createElement("code");
+ body.style.display = "block";
+ body.style.whiteSpace = "pre";
+ body.style.fontFamily = "inherit";
+ body.style.fontSize = "inherit";
+ output.appendChild(body);
+ return body;
+ }
+
+ function lineNode(line) {
+ var node;
+ if (line.indexOf("BLOCKED") !== -1 || line.indexOf("refused") !== -1 || line.indexOf("denied") !== -1) {
+ node = document.createElement("span");
+ node.style.color = REFUSED;
+ node.style.fontWeight = "700";
+ node.textContent = line;
+ } else if (line.indexOf("approves") !== -1) {
+ node = document.createElement("span");
+ node.style.color = GRANTED;
+ node.textContent = line;
+ } else {
+ node = document.createTextNode(line);
+ }
+ return node;
+ }
+
+ // ---- the playground -------------------------------------------------------------------
+
+ function euros(cents) {
+ var whole = Math.floor(Math.abs(cents) / 100);
+ var rest = Math.abs(cents) % 100;
+ var text = "€" + whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+ if (rest) text += "." + (rest < 10 ? "0" : "") + rest;
+ return cents < 0 ? "-" + text : text;
+ }
+
+ function wirePlayground() {
+ var panel = document.getElementById(PLAYGROUND_ID);
+ if (!panel || panel.dataset.wired === "yes") return;
+
+ var amount = panel.querySelector('input[name="amount"]');
+ var payment = panel.querySelector('input[name="payment_id"]');
+ var lose = panel.querySelector('input[name="lose_reply"]');
+ var run = panel.querySelector('button[name="run"]');
+ var approve = panel.querySelector('button[name="approve"]');
+ var log = panel.querySelector("pre");
+ if (!amount || !payment || !lose || !run || !approve || !log) return;
+ panel.dataset.wired = "yes";
+
+ var pyodide = null;
+ var lines = null; // the block child the transcript lives in
+ var pending = null; // the request a human has not answered yet
+ var granted = null; // the approval the next run of that payment presents
+
+ function say(text) {
+ lines = null;
+ log.textContent = text;
+ }
+
+ function write(line) {
+ if (!lines) lines = blockIn(log);
+ var following = log.scrollHeight - log.scrollTop - log.clientHeight < 40;
+ lines.appendChild(lineNode(line));
+ lines.appendChild(document.createTextNode("\n"));
+ if (following) log.scrollTop = log.scrollHeight;
+ }
+
+ function step(request) {
+ pyodide.globals.set("PLAYGROUND_REQUEST", JSON.stringify(request));
+ return JSON.parse(pyodide.runPython("step(PLAYGROUND_REQUEST)"));
+ }
+
+ async function boot() {
+ var ready = await runtime(say);
+ ready.runPython(PLAYGROUND);
+ say("");
+ return ready;
+ }
+
+ function busy(on) {
+ run.disabled = on;
+ approve.disabled = on;
+ }
+
+ async function refund() {
+ var cents = Math.round(Number(amount.value) * 100);
+ // A number input hands back "" for anything it could not parse, and Number("") is 0:
+ // a €0 refund is not what somebody who typed letters asked for.
+ if (amount.value.trim() === "" || !isFinite(cents)) {
+ write("amount: enter a number of euros");
+ return;
+ }
+ var id = payment.value.trim() || "txn_1";
+ var request = { op: "refund", payment_id: id, amount: cents, lose_reply: lose.checked };
+ if (granted && granted.payment_id === id) request.approval_id = granted.approval_id;
+ var result = step(request);
+ var head = "refund " + id + " " + euros(cents) + (request.approval_id ? " (presenting " + request.approval_id + ")" : "");
+ var receipt = result.receipt || {};
+ switch (result.outcome) {
+ case "executed":
+ write(head + " → " + receipt.decision + " → executed; receipt " + receipt.result + "; remote refund calls: " + result.remote_calls);
+ // The approval stays on the panel so the next run presents it again and the reader
+ // sees `consumed`: single-use is something to watch, not something to be told.
+ break;
+ case "approval_required":
+ write(head + " → approve → ApprovalRequired: a human decides " + result.request_id);
+ write(" press Approve, then run again — or change the amount after approving and watch it refused");
+ pending = { request_id: result.request_id, payment_id: id, amount: cents };
+ approve.hidden = false;
+ break;
+ case "approval_mismatch":
+ if (result.reason === "consumed") {
+ write(head + " → refused: ApprovalMismatch (consumed) — a single-use approval, already spent");
+ granted = null;
+ } else {
+ write(head + " → refused: ApprovalMismatch (" + result.reason + ") — the approval is bound to the action the human saw; remote refund calls: " + result.remote_calls);
+ write(" the approval is still unspent: run the approved amount and it executes");
+ }
+ break;
+ case "denied":
+ write(head + " → deny → refused: ActionDenied (" + result.reason + "); no request created; remote refund calls: " + result.remote_calls);
+ break;
+ case "reply_lost":
+ write(head + " → allow → remote commits → reply lost → effect: AMBIGUOUS; receipt " + receipt.result + "; remote refund calls: " + result.remote_calls);
+ write(" run the same payment again: a blind retry is refused until a human or a reconcile hook says what happened");
+ break;
+ case "ambiguous_retry":
+ write(head + " → refused: AmbiguousEffect — the remote may already have committed; remote refund calls: " + result.remote_calls);
+ write(" only a human moves it on: ctrlrun resolve refund:" + id + " --committed|--failed");
+ break;
+ case "duplicate":
+ write(head + " → refused: DuplicateEffect — refund:" + id + " already happened; remote refund calls: " + result.remote_calls);
+ break;
+ default:
+ write(head + " → " + JSON.stringify(result));
+ }
+ write("");
+ }
+
+ async function grant() {
+ if (!pending) return;
+ var result = step({ op: "approve", request_id: pending.request_id });
+ write("human approves " + result.approval_id + " (bound to " + result.action_hash.slice(0, 19) + "…)");
+ write(" run " + euros(pending.amount) + " for " + pending.payment_id + " and it executes; run any other amount on it and it is refused");
+ write("");
+ granted = { approval_id: result.approval_id, payment_id: pending.payment_id };
+ pending = null;
+ approve.hidden = true;
+ }
+
+ async function click(action) {
+ busy(true);
+ try {
+ if (!pyodide) pyodide = await boot();
+ await action();
+ } catch (error) {
+ say(failure(error, "pip install ctrlrun && ctrlrun demo"));
+ } finally {
+ busy(false);
+ }
+ }
+
+ approve.hidden = true;
+ run.addEventListener("click", function () {
+ click(refund);
+ });
+ approve.addEventListener("click", function () {
+ click(grant);
+ });
+ payment.addEventListener("change", function () {
+ // A new payment id is a new effect; a pending answer belongs to the old one.
+ approve.hidden = !pending || pending.payment_id !== payment.value.trim();
+ });
+ }
+
+ // ---- the demo ---------------------------------------------------------------------------
+
function wire() {
+ wirePlayground();
+
var container = document.getElementById(CONTAINER);
if (!container || container.dataset.wired === "yes") return;
@@ -84,38 +413,16 @@
// recording does, a reader can follow each one. The text is what the demo printed and
// nothing else; the only thing added is time, and the colour on each refusal.
var LINE_MS = 380;
- var REFUSED = "#f28b82";
function reveal(text) {
- // The transcript goes inside one block child rather than straight into the .
- // Mintlify's theme lays this out as `display: flex`, and in a flex container every
- // appended child is a flex item in a *row*: the lines ran off to the right instead of
- // down, and a coloured refusal landed beside its neighbours rather than under them.
- // `say` never hit it because it sets a single text node, which is one item however the
- // box is laid out. The page also asks for `display: block` — belt to this brace, since
- // an inline style loses to an `!important` rule the theme does not have today.
- output.textContent = "";
- var body = document.createElement("code");
- body.style.display = "block";
- body.style.whiteSpace = "pre";
- body.style.fontFamily = "inherit";
- body.style.fontSize = "inherit";
- output.appendChild(body);
+ var body = blockIn(output);
var lines = text.split("\n");
return new Promise(function (resolve) {
var index = 0;
function next() {
if (index === lines.length) return resolve();
var line = lines[index++];
- var node;
- if (line.indexOf("BLOCKED") !== -1) {
- node = document.createElement("span");
- node.style.color = REFUSED;
- node.style.fontWeight = "700";
- node.textContent = line;
- } else {
- node = document.createTextNode(line);
- }
+ var node = lineNode(line);
// Follow the newest line, but only for a reader who is already at the bottom:
// yanking somebody back who has scrolled up to re-read scenario 1 is worse than
// not following at all. The box has a max height, so this is a real scroll — it
@@ -131,33 +438,17 @@
});
}
- async function boot() {
- say("Loading Python in this tab. The first run downloads about 10 MB from the Pyodide\nCDN and the ctrlrun wheel from PyPI; after that the browser caches them.");
- if (!window.loadPyodide) await load(PYODIDE + "pyodide.js");
- var runtime = await window.loadPyodide({ indexURL: PYODIDE });
- say("Installing ctrlrun from PyPI…");
- await runtime.loadPackage(PACKAGES);
- var micropip = runtime.pyimport("micropip");
- await micropip.install("ctrlrun");
- return runtime;
- }
-
async function run() {
button.disabled = true;
var label = button.textContent;
button.textContent = "Running…";
try {
- if (!pyodide) pyodide = await boot();
+ if (!pyodide) pyodide = await runtime(say);
say("Running the five scenarios…");
await reveal(pyodide.runPython(PROGRAM));
button.textContent = "Run it again";
} catch (error) {
- say(
- "The demo did not run in this browser:\n\n " +
- (error && error.message ? error.message : String(error)) +
- "\n\nThat is this page failing, not CTRLRun. Run it locally instead:\n\n" +
- " pip install ctrlrun && ctrlrun demo\n"
- );
+ say(failure(error, "pip install ctrlrun && ctrlrun demo"));
button.textContent = label;
} finally {
button.disabled = false;
diff --git a/docs/try-it.mdx b/docs/try-it.mdx
index 067d7ec3..0bf20f3e 100644
--- a/docs/try-it.mdx
+++ b/docs/try-it.mdx
@@ -1,12 +1,121 @@
---
title: "Try it in your browser"
-description: "Run ctrlrun demo here, with no install: Python and the released ctrlrun wheel load into this tab and the five scenarios run in front of you."
+description: "Break a protected agent action with no install: real Python and the released ctrlrun wheel run in this tab, and every refusal you see is CTRLRun's own."
---
-Press the button and `ctrlrun demo` runs in this tab: real Python, the released `ctrlrun` wheel
-from PyPI, and the same five scenarios you would get from `pip install ctrlrun && ctrlrun demo`.
-Nothing is sent anywhere. The demo is in-process, writes its evidence to an in-memory
-filesystem, and opens no socket.
+Real Python and the released `ctrlrun` wheel from PyPI load into this tab. Below is one
+protected refund under one policy, with a fake Stripe that commits before its reply can go
+missing. Change the amount, lose the reply, approve one amount and execute another, and read
+what CTRLRun did. Every line in the box was produced by `ctrlrun` here: the page owns the
+controls and nothing else. Nothing is sent anywhere; the store is in memory and no socket is
+opened.
+
+The policy the refund runs under, in cents:
+
+```yaml
+schema: ctrlrun.policy/v2
+actions:
+ stripe.refund:
+ effect: "refund:{payment_id}"
+ rules:
+ - when: { amount_gte: 0, amount_lte: 100000 }
+ decision: allow
+ - when: { amount_gte: 0, amount_lte: 1000000 }
+ decision: approve
+ - decision: deny
+```
+
+
+
+
+
+
+
+
+
+
+Press Refund. The first press downloads about 10 MB of Python; after that the browser caches it.
+
+
+
+## Try this, in order
+
+1. **€500** on `txn_1` — allowed. It runs, and the receipt says `committed`.
+2. Payment id **`txn_2`**, **€2,000** — `ApprovalRequired`, with the request id a human would
+ answer. Press *Approve*, change the amount to **€5,000**, press *Refund*:
+ `ApprovalMismatch`. The approval was bound to the action the human saw. Put **€2,000** back
+ and it executes on that same approval.
+3. **€2,000** on `txn_2` once more — `ApprovalMismatch (consumed)`: the approval was single-use.
+4. **`txn_3`**, **€20,000** — `ActionDenied`. No request is created; a human is not asked about
+ a refund the policy refuses at any size.
+5. **`txn_4`**, **€500**, with *lose the reply* ticked — the remote commits, the reply is lost,
+ the effect is `AMBIGUOUS`. Untick and press *Refund* again: `AmbiguousEffect`, the blind
+ retry is refused, and `remote refund calls` is still 1. That is the case this library exists
+ for: [Outcomes and AMBIGUOUS](/concepts/outcomes-and-ambiguous).
+6. Back to **`txn_1`**, **€500** — `DuplicateEffect`. One logical effect,
+ [one reservation](/concepts/effect-keys).
+
+Every refusal above is the exception your own code would catch, with the reason it carries.
+[Approval binding](/concepts/approval-binding) and [Decisions](/concepts/decisions) are the two
+pages behind steps 2 to 4.
+
+## The five scenarios, end to end
+
+Press the button and `ctrlrun demo` runs in this tab: the same five scenarios you would get
+from `pip install ctrlrun && ctrlrun demo`, on the same wheel.
-## What you should see
+
-All five scenarios run in the browser. This is the output from the verification run described
-below, with the generated ids masked:
+This is the output from the verification run described below, with the generated ids masked:
```text
ctrlrun 0.5.0 on Python 3.14.2
@@ -97,6 +205,8 @@ Scenario 1 is the one to read twice: **`remote refund calls: 1`**. The refund co
fake remote, the reply was lost, the agent retried, and the retry was refused. The customer was
refunded once.
+
+
## What is running
| | |
@@ -109,31 +219,35 @@ refunded once.
The version line in the output is the released version, so it can be behind `main`. Everything
else on this site describes the current release.
-## Verified, and how to check
+
The Python this page runs was verified under Node against the same Pyodide build on
-**2026-09-06**: Pyodide 314.0.6, Python 3.14.2, SQLite 3.39.0, `ctrlrun` 0.5.0 from PyPI, all
-five scenarios. Not a copy of it — the harness reads the program out of `try-it.js` and runs
-that. It carried its own copy until that date, which is how a syntax error in the page's copy
-reached this page: both harnesses were green, one having stubbed the Python and the other
-having verified a program the page never ran. Two harnesses are committed, and you can run
-both:
+**2026-09-07**: Pyodide 314.0.6, Python 3.14.2, SQLite 3.39.0, `ctrlrun` 0.5.0 from PyPI, the
+playground's whole sequence and all five demo scenarios. Not a copy of it — the harness reads
+both programs out of `try-it.js` and runs those. It carried its own copy until 2026-09-06, which
+is how a syntax error in the page's copy reached this page: both harnesses were green, one
+having stubbed the Python and the other having verified a program the page never ran. Two
+harnesses are committed, and you can run both:
```bash
npm install pyodide jsdom
-node docs/assets/verify-browser-demo.mjs # Python, the wheel, and the five scenarios
-node docs/assets/verify-browser-wiring.mjs # this page's button, against a real DOM
+node docs/assets/verify-browser-demo.mjs # Python, the wheel, the playground sequence, the five scenarios
+node docs/assets/verify-browser-wiring.mjs # this page's controls, against a real DOM
```
[`verify-browser-demo.mjs`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/assets/verify-browser-demo.mjs)
is the one that proved the demo runs. A third check needs neither Node nor the network:
-`tests/test_docs_travelling.py` lifts the program out of the JavaScript and compiles it on
-every commit, so a syntax error fails CI rather than this page.
+`tests/test_docs_travelling.py` lifts both programs out of the JavaScript on every commit,
+compiles the demo's, and **runs the playground's** through the six steps above against the
+checkout's own `ctrlrun`, asserting each outcome. A refusal that stopped happening fails CI
+rather than this page.
[`verify-browser-wiring.mjs`](https://github.com/CTRLRun/ctrlrun/blob/main/docs/assets/verify-browser-wiring.mjs)
-is the one that proves the button on this page gets wired: this site is a single-page
+is the one that proves the controls on this page get wired: this site is a single-page
application, and the first version of the script looked for its container before the page had
rendered it, so the button did nothing.
+
+
## If it does not run here
The page tells you what failed and gives you the command that always works:
diff --git a/tests/test_docs_travelling.py b/tests/test_docs_travelling.py
index 959382a4..fac14df4 100644
--- a/tests/test_docs_travelling.py
+++ b/tests/test_docs_travelling.py
@@ -99,27 +99,59 @@ def test_the_page_tells_a_reader_what_to_do_when_it_does_not_run():
assert "That is this page failing, not the library." in " ".join(TRY_IT.split())
+VERIFIED = json.loads((DOCS / "assets" / "browser-demo.verified.json").read_text(encoding="utf-8"))
+
+
def test_the_page_says_it_runs_the_released_version_and_where_the_proof_is():
assert "verify-browser-demo.mjs" in TRY_IT
assert "released version" in TRY_IT
- assert "2026-09-06" in TRY_IT and "2026-09-06" in HARNESS
-def _browser_demo_program() -> str:
- """The Python the Try-it page runs, lifted out of the JavaScript that carries it."""
- body = re.search(r"var PROGRAM = \[(.*?)\]\.join", SCRIPT, re.S)
- assert body, "docs/try-it.js: no PROGRAM array — the page's Python moved"
+def test_the_page_quotes_the_run_the_harness_recorded():
+ """`browser-demo.verified.json` is written by the harness at the end of a run that passed,
+ and it is the only source for the numbers the page quotes: the date, the Pyodide, Python
+ and SQLite versions in the "Verified" paragraph, and the `ctrlrun X on Python Y` line at
+ the top of the transcript. A page that quoted a version nothing had run would be the copy
+ problem again, one level up."""
+ for key in ("date", "pyodide", "python", "sqlite", "ctrlrun"):
+ assert VERIFIED.get(key), f"browser-demo.verified.json has no {key}"
+ verified = TRY_IT.split("Verified, and how to check", 1)[1].split("", 1)[0]
+ assert f"**{VERIFIED['date']}**" in verified, "the page's verified date is not the run's"
+ assert f"Pyodide {VERIFIED['pyodide']}" in verified
+ assert f"Python {VERIFIED['python']}" in verified
+ assert f"SQLite {VERIFIED['sqlite']}" in verified
+ assert f"`ctrlrun` {VERIFIED['ctrlrun']} from PyPI" in verified
+ transcript = TRY_IT.split("```text", 1)[1].split("```", 1)[0]
+ assert transcript.strip().startswith(
+ f"ctrlrun {VERIFIED['ctrlrun']} on Python {VERIFIED['python']}"
+ ), transcript.strip().splitlines()[0]
+ assert f"Last run {VERIFIED['date']}" in HARNESS, "the harness comment names another run"
+ assert "browser-demo.verified.json" in HARNESS
+
+
+def _python_in_the_script(name: str) -> str:
+ """One of the Python programs the Try-it page runs, lifted out of the JavaScript."""
+ body = re.search(rf"var {name} = \[(.*?)\]\.join", SCRIPT, re.S)
+ assert body, f"docs/try-it.js: no {name} array — the page's Python moved"
try:
lines = json.loads(f"[{body.group(1)}]")
except json.JSONDecodeError as exc: # pragma: no cover - a malformed array is the failure
raise AssertionError(
- f"docs/try-it.js: PROGRAM is no longer JSON-parseable ({exc}). Keep it to "
+ f"docs/try-it.js: {name} is no longer JSON-parseable ({exc}). Keep it to "
"double-quoted strings with no comment inside the array and no trailing comma: "
"verify-browser-demo.mjs parses it the same way."
) from exc
return "\n".join(lines)
+def _browser_demo_program() -> str:
+ return _python_in_the_script("PROGRAM")
+
+
+def _playground_module() -> str:
+ return _python_in_the_script("PLAYGROUND")
+
+
def test_the_browser_demo_program_is_valid_python():
"""The page's Python is a string inside a JavaScript file, which nothing else compiles.
@@ -198,6 +230,163 @@ def test_the_harness_runs_the_program_the_page_runs():
assert "run_demo(" not in HARNESS, "the harness has grown its own copy of the program again"
+# --- the playground ------------------------------------------------------------------------
+
+
+def _playground_step():
+ """`step`, from the module the page runs, executed here against the checkout's ctrlrun.
+
+ The page's JavaScript owns the DOM and nothing else: every outcome a reader sees is the JSON
+ this function returns. So the sequence the page tells the reader to try is run here, natively,
+ and each outcome asserted — with no Node, no Pyodide and no network. This is the third check
+ the page describes, and it exists because the first two once both stayed green while the
+ page's own copy of the Python was broken.
+ """
+ namespace: dict[str, object] = {}
+ exec(compile(_playground_module(), "docs/try-it.js PLAYGROUND", "exec"), namespace)
+ step = namespace["step"]
+
+ def call(**request):
+ return json.loads(step(json.dumps(request)))
+
+ return call
+
+
+def test_the_playground_module_is_valid_python_and_defines_step():
+ namespace: dict[str, object] = {}
+ exec(compile(_playground_module(), "docs/try-it.js PLAYGROUND", "exec"), namespace)
+ assert callable(namespace.get("step"))
+
+
+def test_the_playground_runs_the_sequence_the_page_tells_the_reader_to_try():
+ """Steps 1 to 6 of "Try this, in order", each outcome as the page states it."""
+ step = _playground_step()
+
+ # 1. €500 on txn_1: allowed and committed.
+ first = step(op="refund", payment_id="txn_1", amount=50000, lose_reply=False)
+ assert first["outcome"] == "executed", first
+ assert first["receipt"]["decision"] == "allow"
+ assert first["receipt"]["result"] == "committed"
+ assert first["remote_calls"] == 1
+
+ # 2. €2,000 on txn_2: a human decides; approve; €5,000 on it is refused; €2,000 executes.
+ asked = step(op="refund", payment_id="txn_2", amount=200000, lose_reply=False)
+ assert asked["outcome"] == "approval_required", asked
+ assert asked["remote_calls"] == 0
+ granted = step(op="approve", request_id=asked["request_id"])
+ assert granted["approval_id"] == asked["request_id"]
+ assert granted["action_hash"].startswith("sha256:")
+ mutated = step(op="refund", payment_id="txn_2", amount=500000, approval_id=asked["request_id"])
+ assert mutated["outcome"] == "approval_mismatch", mutated
+ assert mutated["reason"] == "mismatch"
+ assert mutated["remote_calls"] == 0, "the mutated amount reached the remote"
+ assert mutated["receipt"]["result"] == "blocked"
+ approved = step(op="refund", payment_id="txn_2", amount=200000, approval_id=asked["request_id"])
+ assert approved["outcome"] == "executed", approved
+ assert approved["receipt"]["decision"] == "approve"
+ assert approved["receipt"]["approval_id"] == asked["request_id"]
+ assert approved["remote_calls"] == 1
+
+ # 3. The same approval presented again: consumed.
+ replayed = step(op="refund", payment_id="txn_2", amount=200000, approval_id=asked["request_id"])
+ assert replayed["outcome"] == "approval_mismatch", replayed
+ assert replayed["reason"] == "consumed"
+ assert replayed["remote_calls"] == 1
+
+ # 4. €20,000 on txn_3: denied, and no request was created.
+ denied = step(op="refund", payment_id="txn_3", amount=2000000, lose_reply=False)
+ assert denied["outcome"] == "denied", denied
+ assert "request_id" not in denied
+ assert denied["remote_calls"] == 0
+ assert denied["receipt"]["result"] == "denied"
+
+ # 5. €500 on txn_4 with the reply lost: AMBIGUOUS; the retry is refused; one remote call.
+ lost = step(op="refund", payment_id="txn_4", amount=50000, lose_reply=True)
+ assert lost["outcome"] == "reply_lost", lost
+ assert lost["receipt"]["result"] == "ambiguous"
+ assert lost["remote_calls"] == 1
+ retried = step(op="refund", payment_id="txn_4", amount=50000, lose_reply=False)
+ assert retried["outcome"] == "ambiguous_retry", retried
+ assert retried["remote_calls"] == 1, "the blind retry reached the remote"
+ assert retried["receipt"]["result"] == "blocked"
+
+ # 6. €500 on txn_1 again: the effect already happened.
+ duplicate = step(op="refund", payment_id="txn_1", amount=50000, lose_reply=False)
+ assert duplicate["outcome"] == "duplicate", duplicate
+ assert duplicate["remote_calls"] == 1
+
+
+def test_the_playground_refuses_a_negative_amount_as_the_policy_says():
+ """Both ends of every band are bound; a refund of a negative amount is a charge."""
+ step = _playground_step()
+ charged = step(op="refund", payment_id="txn_1", amount=-500, lose_reply=False)
+ assert charged["outcome"] == "denied", charged
+ assert charged["remote_calls"] == 0
+
+
+def test_the_playground_has_no_way_to_grant_but_the_human_button():
+ """No auto-approve, no dry run, no flag: the only path to a grant is `op: approve` with a
+ request id, which is `grant_approval` on the store — the write `ctrlrun approve` makes."""
+ module = _playground_module()
+ assert "grant_approval(" in module
+ assert module.count("grant_approval(") == 1
+ for forbidden in ("auto_approve", "dry_run", "ScriptedApprovalProvider", "mode"):
+ assert forbidden not in module, forbidden
+ step = _playground_step()
+ with pytest.raises(Exception): # noqa: B017 - an unknown request id is not a grant
+ step(op="approve", request_id="apr_0000")
+
+
+def test_the_playground_policy_on_the_page_is_the_policy_in_the_module():
+ """The page shows a YAML block and the module carries one; a reader reasons from the block
+ they can see, so the two are held equal rather than described as equal."""
+ module = _playground_module()
+ in_module = re.search(r'POLICY = """\n(.*?)"""', module, re.S)
+ assert in_module, "the module no longer carries POLICY as a triple-quoted string"
+ shown = TRY_IT.split("```yaml", 1)[1].split("```", 1)[0]
+
+ assert shown.strip() == in_module.group(1).strip()
+
+
+def test_the_page_names_every_outcome_the_module_can_return():
+ """Each `outcome` the module reports has the line the page tells the reader to expect."""
+ module = _playground_module()
+ outcomes = set(re.findall(r'result\["outcome"\] = "(\w+)"', module))
+ assert outcomes == {
+ "executed",
+ "approval_required",
+ "approval_mismatch",
+ "denied",
+ "duplicate",
+ "ambiguous_retry",
+ "reply_lost",
+ }
+ for exception in (
+ "ApprovalRequired",
+ "ApprovalMismatch",
+ "ActionDenied",
+ "DuplicateEffect",
+ "AmbiguousEffect",
+ "AMBIGUOUS",
+ "consumed",
+ ):
+ assert exception in TRY_IT, exception
+ assert 'case "' in SCRIPT
+ for outcome in outcomes:
+ assert f'case "{outcome}":' in SCRIPT, f"the page has no branch for {outcome}"
+
+
+def test_the_playground_wires_every_control_the_page_mounts():
+ playground = re.search(r'PLAYGROUND_ID = "([^"]+)"', SCRIPT).group(1)
+ assert f'id="{playground}"' in TRY_IT
+ for name in ("amount", "payment_id", "lose_reply", "run", "approve"):
+ assert f'name="{name}"' in TRY_IT, name
+ assert f'[name="{name}"]' in SCRIPT, f"the script never selects {name}"
+ assert "runPython(PLAYGROUND)" in SCRIPT
+ assert "runPython(PLAYGROUND)" in HARNESS, "the harness does not run the playground"
+ assert "step(" in HARNESS
+
+
@pytest.mark.authority
def test_the_page_quotes_lines_the_demo_prints():
"""The transcript on the page is the demo's own output, not a sketch of it.