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
'; +const DEMO = + '
idle
'; +const PLAYGROUND = + '
' + + '' + + '' + + '' + + '' + + '' + + "
idle
"; + +function mount(dom, html = DEMO) { + dom.window.document.querySelector("main").innerHTML = html; } async function settle(dom) { await new Promise((resolve) => dom.window.setTimeout(resolve, 50)); } +let failures = 0; +function check(label, ok) { + console.log(`${ok ? "ok " : "FAIL"} ${label}`); + if (!ok) failures += 1; +} + // 1. No container: the script must touch nothing at all. { const dom = fresh(); await settle(dom); - const body = dom.window.document.body.innerHTML; - console.log("no container, body untouched:", body === "
"); + check("no container, body untouched", dom.window.document.body.innerHTML === "
"); } -// 2. Container mounted after the script ran: the button must end up wired, and clicking it -// must reach the loader rather than doing nothing. +// 2. Demo container mounted after the script ran: the button must end up wired, and clicking +// it must reach the loader rather than doing nothing. { const dom = fresh(); let asked = false; @@ -54,12 +71,12 @@ async function settle(dom) { mount(dom); await settle(dom); const container = dom.window.document.getElementById("ctrlrun-browser-demo"); - console.log("wired after late mount:", container.dataset.wired === "yes"); + check("demo wired after late mount", container.dataset.wired === "yes"); container.querySelector("button").dispatchEvent(new dom.window.MouseEvent("click")); await settle(dom); - console.log("click reached the loader:", asked); - console.log("failure told the reader what to do:", + check("demo click reached the loader", asked); + check("demo failure told the reader what to do", container.querySelector("pre").textContent.includes("pip install ctrlrun && ctrlrun demo")); } @@ -72,6 +89,106 @@ async function settle(dom) { await settle(dom); mount(dom); await settle(dom); - console.log("re-wired after navigation:", + check("demo re-wired after navigation", dom.window.document.getElementById("ctrlrun-browser-demo").dataset.wired === "yes"); } + +// 4. The playground: every control builds the request the module expects, and every outcome +// the module can return has a line. Pyodide is a stub whose `step` answers by shape. +{ + const dom = fresh(); + const requests = []; + const stub = { + loadPackage: async () => undefined, + pyimport: () => ({ install: async () => undefined }), + globals: { set: (name, value) => { stub.request = JSON.parse(value); } }, + runPython(code) { + if (code !== "step(PLAYGROUND_REQUEST)") return undefined; // the module load + const r = stub.request; + requests.push(r); + if (r.op === "approve") return JSON.stringify({ op: "approve", approval_id: r.request_id, action_hash: "sha256:abc" }); + let outcome = "executed"; + if (r.approval_id && r.amount !== 200000) outcome = "approval_mismatch"; + else if (r.amount > 1000000 || r.amount < 0) outcome = "denied"; + else if (r.amount > 100000 && !r.approval_id) outcome = "approval_required"; + else if (r.lose_reply) outcome = "reply_lost"; + else if (r.payment_id === "dup") outcome = "duplicate"; + else if (r.payment_id === "amb") outcome = "ambiguous_retry"; + return JSON.stringify({ op: "refund", outcome, reason: outcome === "approval_mismatch" ? "mismatch" : "rule[2]", request_id: "apr_1", remote_calls: 0, receipt: { decision: "allow", result: "committed" } }); + }, + }; + dom.window.loadPyodide = async () => stub; + mount(dom, PLAYGROUND); + await settle(dom); + const panel = dom.window.document.getElementById("ctrlrun-playground"); + check("playground wired after late mount", panel.dataset.wired === "yes"); + const amount = panel.querySelector('input[name="amount"]'); + const payment = panel.querySelector('input[name="payment_id"]'); + const lose = panel.querySelector('input[name="lose_reply"]'); + const run = panel.querySelector('button[name="run"]'); + const approve = panel.querySelector('button[name="approve"]'); + const log = () => panel.querySelector("pre").textContent; + check("approve hidden until something is pending", approve.hidden === true); + + const click = async (button) => { + button.dispatchEvent(new dom.window.MouseEvent("click")); + await settle(dom); + }; + await click(run); + check("€500 builds a 50000-cent request", requests[0].amount === 50000 && requests[0].payment_id === "txn_1" && requests[0].lose_reply === false); + check("executed line names the receipt", log().includes("executed; receipt committed")); + + amount.value = "2000"; + payment.value = "txn_2"; + await click(run); + check("€2,000 asks a human and shows Approve", log().includes("ApprovalRequired") && approve.hidden === false); + await click(approve); + check("Approve sends op=approve with the request id", requests.at(-1).op === "approve" && requests.at(-1).request_id === "apr_1"); + check("Approve hides itself and names the grant", approve.hidden === true && log().includes("human approves apr_1")); + amount.value = "5000"; + await click(run); + check("€5,000 presents the approval and is refused", requests.at(-1).approval_id === "apr_1" && log().includes("ApprovalMismatch")); + amount.value = "2000"; + await click(run); + check("€2,000 presents the same approval and executes", requests.at(-1).approval_id === "apr_1" && log().includes("executed")); + + payment.value = "txn_3"; + amount.value = "20000"; + await click(run); + check("€20,000 does not present txn_2's approval", requests.at(-1).approval_id === undefined); + check("denied line", log().includes("ActionDenied")); + + payment.value = "txn_4"; + amount.value = "500"; + lose.checked = true; + await click(run); + check("lose the reply is sent", requests.at(-1).lose_reply === true && log().includes("AMBIGUOUS")); + payment.value = "amb"; + lose.checked = false; + await click(run); + check("ambiguous retry line", log().includes("AmbiguousEffect")); + payment.value = "dup"; + await click(run); + check("duplicate line", log().includes("DuplicateEffect")); + amount.value = "abc"; + await click(run); + check("a non-number is refused by the page, not sent", log().includes("enter a number") && requests.at(-1).payment_id === "dup"); +} + +// 5. A playground whose runtime fails to load tells the reader what to do. +{ + const dom = fresh(); + dom.window.loadPyodide = async () => { throw new Error("stub: no runtime"); }; + mount(dom, PLAYGROUND); + await settle(dom); + const panel = dom.window.document.getElementById("ctrlrun-playground"); + panel.querySelector('button[name="run"]').dispatchEvent(new dom.window.MouseEvent("click")); + await settle(dom); + check("playground failure told the reader what to do", + panel.querySelector("pre").textContent.includes("pip install ctrlrun && ctrlrun demo")); +} + +if (failures) { + console.log(`${failures} check(s) failed`); + process.exit(1); +} diff --git a/docs/index.mdx b/docs/index.mdx index 9f42dd77..daba681e 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -27,7 +27,7 @@ pip install ctrlrun && ctrlrun demo - No install. Real Python and the released wheel run in this tab. + No install. Approve €2,000, execute €5,000, lose a reply, retry: real refusals, in this tab. One policy file, one decorator, one approval from the shell, three receipts. @@ -117,7 +117,7 @@ Policy: refunds up to €1,000 are autonomous, up to €10,000 need a human, abo The other four are approval mutation, two agents racing for one effect, approval replay, and an agent trying to act outside what was delegated to it. [Try it in your browser](/try-it) runs -the same demo without an install, or read the full transcript in the +the same demo without an install and lets you break one refund yourself, or read the full transcript in the [repository README](https://github.com/CTRLRun/ctrlrun#what-ctrlrun-demo-shows). ## What it does @@ -246,7 +246,7 @@ the framework's own interrupt, and a framework with no such primitive does not n Protect one function end to end and read the receipt. Ten minutes. - The five demo scenarios, running on the released wheel in this tab. + One refund you can break six ways, and the five demo scenarios, on the released wheel. Refunds, deploys, IAM, deletions, email, MCP, LangGraph: each a recipe that runs. diff --git a/docs/try-it.js b/docs/try-it.js index 76e8892e..847f6592 100644 --- a/docs/try-it.js +++ b/docs/try-it.js @@ -1,34 +1,141 @@ /* - * The browser demo, for docs/try-it.mdx. + * The browser playground and the browser demo, for docs/try-it.mdx. * * Mintlify includes every .js file in the content directory on every page and cannot scope one - * to a single page, so this does nothing at all unless the page it belongs to has mounted its - * container. Everything it touches lives inside that container. + * to a single page, so this does nothing at all unless the page it belongs to has mounted a + * container. Everything it touches lives inside those containers. * * What it does: load Pyodide from the jsDelivr CDN, install `ctrlrun` from PyPI with micropip, - * and run `ctrlrun demo` in the reader's own browser. Nothing is sent anywhere: the demo is - * in-process, writes its evidence to Pyodide's in-memory filesystem, and opens no socket. The - * version it prints is whatever is released on PyPI, which is the version a reader would get - * from `pip install ctrlrun`. + * and run real CTRLRun in the reader's own browser. Nothing is sent anywhere: the remote is a + * fake in the same process, the state store is in memory, and no socket is opened. The version + * is whatever is released on PyPI, which is the version a reader would get from + * `pip install ctrlrun`. * - * Verified against Pyodide 314.0.6 (Python 3.14.2) with ctrlrun 0.5.0 by - * docs/assets/verify-browser-demo.mjs, which reads PROGRAM out of this file and runs it under - * Node. It read its own copy until 2026-09-06, and a syntax error in the copy that shipped - * reached the deployed page. + * Two pieces of Python live in this file, each a JSON array of lines so that the harnesses and + * the test suite can read them out rather than carrying a copy: + * + * PLAYGROUND — a module defining `step(request_json) -> result_json` over one `Control`, one + * in-memory store and one fake Stripe. The page's controls build a request; the + * module runs the protected call and reports what CTRLRun did, as JSON. The + * JavaScript owns the DOM and nothing else — every outcome the reader sees was + * produced by `ctrlrun` in the tab. `tests/test_docs_travelling.py` runs this + * module natively through the whole sequence the page suggests, on every commit, + * with no Node and no network. + * PROGRAM — `ctrlrun demo`, the five scenarios, for the button below the playground. + * + * Verified against Pyodide 314.0.6 (Python 3.14.2) with ctrlrun 0.5.0 by docs/assets/verify-browser-demo.mjs, + * which reads both arrays out of this file and runs them under Node. It read its own copy + * until 2026-09-06, and a syntax error in the copy that shipped reached the deployed page. */ (function () { "use strict"; var PYODIDE = "https://cdn.jsdelivr.net/pyodide/v314.0.6/full/"; var CONTAINER = "ctrlrun-browser-demo"; + var PLAYGROUND_ID = "ctrlrun-playground"; // `sqlite3` is bundled into Pyodide 314 and is not a loadable package; `pyyaml` is one of // Pyodide's own builds; `click` arrives as ctrlrun's dependency through micropip. var PACKAGES = ["micropip", "pyyaml"]; - // The Python this page runs. `runPython` returns the value of the last expression, so the - // last line is an expression rather than an assignment, and it is a single line: a trailing - // `+` outside brackets is a SyntaxError, which is exactly what shipped here and put a + // The playground module. Public API only — `Control`, `InMemoryStateStore`, + // `LocalApprovalProvider`, `Policy.from_yaml`, `@protect`, `context`, `with_approval`, and the + // exceptions — so what a reader sees here is what the same calls do in their own process. An + // approval is a `grant_approval` on the store, the same write `ctrlrun approve` makes; there + // is no auto-approve and no dry run. Keep it JSON-parseable: double-quoted strings, no comment + // inside the array, no trailing comma. The policy in it is the one the page shows, and a test + // holds the two equal. + var PLAYGROUND = [ + "import json", + "from ctrlrun import Control, InMemoryStateStore, LocalApprovalProvider, Policy", + "from ctrlrun import ActionDenied, AmbiguousEffect, ApprovalMismatch, ApprovalRequired, DuplicateEffect", + "from ctrlrun import context, protect, with_approval", + "", + "POLICY = \"\"\"", + "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", + "\"\"\"", + "", + "", + "class FakeStripe:", + " def __init__(self):", + " self.calls = []", + " self.lose_reply = False", + "", + " def refund(self, payment_id, amount):", + " self.calls.append(payment_id)", + " if self.lose_reply:", + " raise TimeoutError(\"no response from api.stripe.com after 30s\")", + " return {\"id\": \"re_\" + payment_id, \"amount\": amount, \"status\": \"succeeded\"}", + "", + "", + "store = InMemoryStateStore()", + "control = Control(Policy.from_yaml(POLICY, source=\"\"), store, LocalApprovalProvider(store))", + "remote = FakeStripe()", + "", + "", + "@protect(\"stripe.refund\", effect=\"refund:{payment_id}\", control=control)", + "def refund(payment_id, amount):", + " return remote.refund(payment_id, amount)", + "", + "", + "def _run(payment_id, amount, approval_id):", + " if approval_id:", + " with with_approval(approval_id):", + " return refund(payment_id=payment_id, amount=amount)", + " return refund(payment_id=payment_id, amount=amount)", + "", + "", + "def step(request_json):", + " request = json.loads(request_json)", + " if request[\"op\"] == \"approve\":", + " approval = store.grant_approval(request[\"request_id\"], \"human:you\")", + " return json.dumps({\"op\": \"approve\", \"approval_id\": approval.approval_id, \"action_hash\": approval.action_hash})", + " payment_id = str(request[\"payment_id\"]).strip() or \"txn_1\"", + " amount = int(request[\"amount\"])", + " remote.lose_reply = bool(request.get(\"lose_reply\"))", + " result = {\"op\": \"refund\", \"payment_id\": payment_id, \"amount\": amount}", + " try:", + " with context(agent=\"refund-agent\"):", + " _run(payment_id, amount, request.get(\"approval_id\"))", + " result[\"outcome\"] = \"executed\"", + " except ApprovalRequired as pending:", + " result[\"outcome\"] = \"approval_required\"", + " result[\"request_id\"] = pending.request_id", + " except ApprovalMismatch as refused:", + " result[\"outcome\"] = \"approval_mismatch\"", + " result[\"reason\"] = refused.reason", + " except ActionDenied as refused:", + " result[\"outcome\"] = \"denied\"", + " result[\"reason\"] = refused.reason", + " except DuplicateEffect as refused:", + " result[\"outcome\"] = \"duplicate\"", + " result[\"reason\"] = str(refused)", + " except AmbiguousEffect as refused:", + " result[\"outcome\"] = \"ambiguous_retry\"", + " result[\"reason\"] = str(refused)", + " except TimeoutError as lost:", + " result[\"outcome\"] = \"reply_lost\"", + " result[\"reason\"] = str(lost)", + " result[\"remote_calls\"] = remote.calls.count(payment_id)", + " receipts = [r for r in store.receipts() if r.arguments.get(\"payment_id\") == payment_id]", + " if receipts:", + " last = receipts[-1]", + " result[\"receipt\"] = {\"decision\": str(last.decision), \"result\": str(last.result), \"effect_key\": last.effect_key, \"approval_id\": last.approval_id}", + " return json.dumps(result)" + ].join("\n"); + + // The Python the demo button runs. `runPython` returns the value of the last expression, so + // the last line is an expression rather than an assignment, and it is a single line: a + // trailing `+` outside brackets is a SyntaxError, which is exactly what shipped here and put a // traceback in front of every reader. Two harnesses claimed to have verified this page and // neither ran this string — `verify-browser-wiring.mjs` loads this script with Pyodide // stubbed, and `verify-browser-demo.mjs` carried its own copy of the program. The demo @@ -65,7 +172,229 @@ }); } + // One runtime for both containers: the second one to ask waits on the first one's download. + var runtimePromise = null; + + function runtime(say) { + if (runtimePromise) return runtimePromise; + runtimePromise = (async function () { + 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 pyodide = await window.loadPyodide({ indexURL: PYODIDE }); + say("Installing ctrlrun from PyPI…"); + await pyodide.loadPackage(PACKAGES); + var micropip = pyodide.pyimport("micropip"); + await micropip.install("ctrlrun"); + return pyodide; + })(); + runtimePromise.catch(function () { + runtimePromise = null; // a failed download is retried on the next click + }); + return runtimePromise; + } + + function failure(error, command) { + return ( + "This 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 " + + command + + "\n" + ); + } + + var REFUSED = "#f28b82"; + var GRANTED = "#8bd5a0"; + + // 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.
+  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.