diff --git a/docs/VISUALIZE.md b/docs/VISUALIZE.md index 7a3dd1b2..8ee77ae6 100644 --- a/docs/VISUALIZE.md +++ b/docs/VISUALIZE.md @@ -1,13 +1,22 @@ # Compiled-program visualizer -See what a demonstration compiled **into**. A compiled bundle is not a video — -it is a governed program: an ordered set of steps, each carrying how its target -is re-resolved, whether an identity gate protects the click, what real -system-of-record effect must hold, what the screen must look like afterward, its -risk class, and where the run will **halt** rather than guess. The visualizer -renders that structure. +See what a demonstration compiled into. A compiled bundle is a governed +program. Each step states how the runtime resolves its target, whether an +identity gate applies, what screen and effect checks apply, and where the run +must halt. -![Program graph of the OpenEMR showcase bundle](showcase-openemr/program-graph.png) +![Public-safe Program Workbench with the exact loop edges and selected-step inspector](program-workbench.png) + +The HTML view has three linked views: + +- **Program map** renders the exact emitted edge targets. It keeps loop-back, + branch, exception, and sequence edges distinct. +- **Evidence lanes** compares the declared resolution, identity, actuation, + screen, effect, and stop contracts for each step. +- **Stop rules** isolates the steps that can refuse an action. + +The view does not show a live verdict without an exact run trace. A declared +check is a compile-time requirement. It is not evidence that the check passed. ## One spec, three surfaces @@ -15,9 +24,9 @@ The engine is the single source of truth. `openadapt_flow.visualize` **emits a serializable _program-graph spec_** from a compiled bundle; every surface renders that spec and none of them re-parse the bundle IR: -- **CLI** (`openadapt-flow visualize`) — self-contained HTML / Mermaid / JSON. -- **Cloud** (`app.openadapt.ai`) — an interactive React view over the same spec. -- **Desktop** (Tauri app) — a view that vendors the same renderer. +- **CLI** (`openadapt-flow visualize`) writes self-contained HTML, Mermaid, or JSON. +- **Cloud** (`app.openadapt.ai`) uses an interactive React view over the same spec. +- **Desktop** uses a local React view over the qualification graph projection. The spec is versioned and has a committed JSON Schema (`schemas/program-graph-v1.json`) so non-Python surfaces validate the same @@ -60,81 +69,72 @@ openadapt-flow visualize path/to/bundle -o program.html # Mermaid flowchart source for Markdown / docs / a PR description openadapt-flow visualize path/to/bundle --format mermaid -# the shared JSON graph spec (what the cloud + desktop surfaces render) +# the shared JSON graph spec (what Cloud and Desktop render) openadapt-flow visualize path/to/bundle --format json -o program-graph.json + +# a closed projection for a remote viewer or approved derivative +openadapt-flow visualize path/to/bundle --profile remote-safe -o program.html ``` -## Rendering choice & tradeoffs +The default `operator-local` profile includes local diagnostic detail. -- **Engine emits the spec; surfaces render it** — rather than each surface +`remote-safe`, `public-synthetic`, and `sanitized-derivative` work the other way +round. Each rebuilds the graph from a closed list of the fields allowed to +leave, rather than copying the graph and deleting the sensitive parts. Recorded +text, parameter values, selectors, URLs, free-text predicates, risk +explanations, and local provenance all stay behind, because none of them is on +that list. Fields whose vocabulary is finite, such as the action or the +resolution rung, are also checked against a closed set of values. + +This matters most for the field nobody has written yet. Add one to the spec and +it doesn't travel: the module won't load until someone marks it either safe to +leave or local. The projection still doesn't sanitize the source bundle, and it +doesn't prove the source is safe to send. + +## Rendering choice and tradeoffs + +- **The engine emits the spec and each surface renders it.** Each surface avoids re-parsing the bundle IR. This keeps one projection of the compiled semantics - and a single wire contract, and lets the cloud/desktop surfaces render without - a Python engine on hand. -- **Custom lightweight layout, not a graph library.** The compiled program is a - vertical sequence with room for branches, and the value is in the **per-node - annotations** (resolution ladder, identity gate, effect check, halt points) — - far clearer as node _cards_ than as edges-and-boxes. A full graph lib - (d3/cytoscape/reactflow) is heavy overkill and would break the - self-contained/CSP-safe requirement. Mermaid is offered as a portable - secondary format; JSON for tooling. + and a single wire contract. Cloud and Desktop do not need to invent the graph + from display order. +- **A small deterministic layout handles the offline view.** It follows the + actual edge targets and draws back edges explicitly. It keeps the file + self-contained and avoids a runtime dependency. Cloud uses a React renderer + over the same graph contract. Mermaid remains a portable export format. - **Self-contained HTML.** The CLI inlines the shared CSS + a dependency-free vanilla-JS renderer (`openadapt_flow/visualize/static/program_graph.{css,js}`) and embeds the spec as JSON, so the page opens offline and renders under a - strict CSP. The desktop (Tauri, CSP `'self'`) vendors those same two files. + strict CSP. -## What `visualize` shows: the bundled MockMed sample +## What `visualize` shows -This is the actual Mermaid that `visualize` emits for the bundled MockMed -triage sample, produced by -`openadapt-flow visualize docs/showcase/bundle --format mermaid` (nothing -below is hand-drawn): +This Mermaid output comes from the bounded loop fixture. The command uses the +public-safe projection, so it keeps the structure and removes recorded values. ```mermaid flowchart TD - n0("click recorded visual target
visual template + 2 OCR landmarks") - n1("type 'nurse.demo'") - n2("click recorded visual target
visual template + 2 OCR landmarks") - n3("type 'mockmed-demo-pass'") - n4("click 'Sign In'
visual template + 2 OCR landmarks") - n5("click 'Open'
visual template + 2 OCR landmarks") - n6("click 'New Encounter'
visual template + 2 OCR landmarks") - n7("click 'Triage'
visual template + 2 OCR landmarks") - n8("click recorded visual target
visual template + 2 OCR landmarks") - n9("type ") - n10("click 'Save Encounter'
visual template + 2 OCR landmarks") - n11{{"Success"}} - n0 --> n1 + n0{"Repeat the bounded steps"} + n1("Enter an approved input") + n2("Enter an approved input") + n3("Send an approved key
effect · irreversible") + n4{{"End of declared steps"}} + n0 -->|declared loop| n1 n1 --> n2 n2 --> n3 - n3 --> n4 - n4 --> n5 - n5 --> n6 - n6 --> n7 - n7 --> n8 - n8 --> n9 - n9 --> n10 - n10 --> n11 + n3 --> n0 + n0 --> n4 classDef irreversible stroke:#b4530a,stroke-width:2px; classDef halt stroke:#b21f2d,stroke-width:2px; + class n3 irreversible; + class n3 halt; ``` -How to read the target labels: - -- **`recorded visual target` is not coordinate replay.** It means the control - had no readable label, so the bundle retained its visual crop and nearby text - instead. The demonstration's point is only the relative offset inside the - target after that evidence re-finds it. -- **`visual template + 2 OCR landmarks` names the retained evidence.** Replay - resolves it on a fresh frame; global movement is accepted only when the - landmarks do not contradict it, and ambiguous OCR refuses instead of picking - a match. -- **DOM/accessibility is stronger when available.** Browser and native bundles - show that structural rung instead; RDP and Citrix intentionally use the - visual floor. -- **The HTML view carries the full contract.** `--format html` expands every - resolution rung, identity gate, effect check, postcondition, and halt point. - -*Text summary (for renderers without Mermaid): the compiled MockMed triage -bundle signs in, opens the patient, starts an encounter, enters the `` -parameter, and saves it. Each click is re-found from retained evidence rather -than replayed at a literal screen coordinate.* +How to read this map: + +- `n0` owns the bounded loop. The `declared loop` edge enters its body. +- The edge from `n3` to `n0` returns for the next item. The edge from `n0` to + `n4` exits when the worklist is complete. +- `End of declared steps` is a program terminal. It does not claim that a live + run achieved `VERIFIED`. +- The HTML view adds the resolution, identity, screen, effect, and stop + controls for each selected node. diff --git a/docs/program-workbench.png b/docs/program-workbench.png new file mode 100644 index 00000000..ac7e0b03 Binary files /dev/null and b/docs/program-workbench.png differ diff --git a/docs/showcase-openemr/program-graph.html b/docs/showcase-openemr/program-graph.html index 00e5b408..0a01764a 100644 --- a/docs/showcase-openemr/program-graph.html +++ b/docs/showcase-openemr/program-graph.html @@ -3,9 +3,9 @@ -Compiled program — openemr-showcase +Compiled program diff --git a/openadapt_flow/visualize/spec.py b/openadapt_flow/visualize/spec.py index f1dbc6cc..59cf2861 100644 --- a/openadapt_flow/visualize/spec.py +++ b/openadapt_flow/visualize/spec.py @@ -34,6 +34,12 @@ #: additive optional fields do not bump it (a v1 reader ignores unknown fields). SPEC_VERSION = 1 +#: The bundle name every non-local projection carries. The recorded name is +#: local data, so a projected graph is named only by what it is. Declared here, +#: on the shared wire contract, so the projection that sets it and the renderer +#: that titles a page from it agree without either importing the other. +PROJECTED_BUNDLE_NAME = "Compiled program" + class NodeKind(str, Enum): """Kind of graph node -- mirrors :class:`openadapt_flow.ir.StateKind` so a diff --git a/openadapt_flow/visualize/static/program_graph.css b/openadapt_flow/visualize/static/program_graph.css index c7cf771a..de010716 100644 --- a/openadapt_flow/visualize/static/program_graph.css +++ b/openadapt_flow/visualize/static/program_graph.css @@ -100,6 +100,240 @@ .opg-stat.halt { border-color: var(--opg-halt); background: var(--opg-halt-bg); } .opg-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; } +.opg-data-note { + margin: 8px 0 0; + padding: 8px 10px; + border-left: 2px solid var(--opg-line); + background: var(--opg-card); + color: var(--opg-muted); + font-size: 12px; +} + +.opg-tabs { + display: flex; + gap: 2px; + margin-bottom: 10px; + padding: 4px; + border: 1px solid var(--opg-border); + border-radius: 8px; + background: var(--opg-card); +} +.opg-tabs button { + padding: 7px 11px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--opg-muted); + cursor: pointer; + font: inherit; + font-size: 12px; +} +.opg-tabs button:hover { color: var(--opg-fg); } +.opg-tabs button:focus-visible { outline: 2px solid var(--opg-accent); outline-offset: 2px; } +.opg-tabs button[aria-selected="true"] { + background: var(--opg-bg); + box-shadow: 0 1px 3px rgba(0, 0, 0, .12); + color: var(--opg-fg); + font-weight: 650; +} +.opg-panel { min-height: 420px; } + +.opg-workbench { + display: grid; + overflow: hidden; + border: 1px solid var(--opg-border); + border-radius: 12px; + background: #0b1220; + color: #e2e8f0; + grid-template-columns: minmax(0, 1fr) 290px; +} +.opg-map-shell { min-width: 0; } +.opg-map-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 14px; + border-bottom: 1px solid #1e293b; + background: rgba(19, 28, 44, .84); + color: #94a3b8; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 9px; + letter-spacing: .06em; + text-transform: uppercase; +} +.opg-map-head span:last-child { color: #64748b; font-size: 8px; } +.opg-map-viewport { + max-height: 650px; + overflow: auto; + background: + linear-gradient(rgba(148, 163, 184, .026) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, .026) 1px, transparent 1px), + radial-gradient(circle at 50% 0, rgba(52, 211, 153, .06), transparent 38%), + #0b1220; + background-size: 28px 28px, 28px 28px, auto, auto; + scrollbar-color: #334155 transparent; +} +.opg-map { position: relative; min-width: 100%; } +.opg-map-edges { position: absolute; inset: 0; overflow: visible; pointer-events: none; } +.opg-map-edges path { fill: none; stroke: #475569; stroke-width: 1.3; } +.opg-map-edges marker path { fill: #64748b; stroke: none; } +.opg-map-edges g[data-kind="branch"] path { stroke: #60a5fa; } +.opg-map-edges g[data-kind="exception"] path { stroke: #fbbf24; stroke-dasharray: 4 4; } +.opg-map-edges g[data-kind="loop_body"] path { stroke: #a78bfa; stroke-dasharray: 4 3; } +.opg-map-edges text { + fill: #94a3b8; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 9px; +} +.opg-map-node { + position: absolute; + display: grid; + align-items: center; + padding: 10px; + border: 1px solid #334155; + border-radius: 8px; + background: rgba(19, 28, 44, .96); + color: #e2e8f0; + cursor: pointer; + font: inherit; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 9px; + text-align: left; + transition: transform 160ms ease-out, border-color 160ms ease-out, box-shadow 160ms ease-out; +} +.opg-map-node:hover { border-color: #64748b; transform: translateY(-1px); } +.opg-map-node:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } +.opg-map-node[data-selected] { + border-color: #60a5fa; + box-shadow: 0 0 0 1px rgba(96, 165, 250, .14), 0 10px 28px rgba(0, 0, 0, .3); +} +.opg-map-node[data-tone="success"] { border-color: rgba(52, 211, 153, .58); } +.opg-map-node[data-tone="halt"] { border-color: rgba(251, 191, 36, .58); } +.opg-map-node[data-tone="branch"] { border-color: rgba(167, 139, 250, .58); } +.opg-map-node[data-tone="governed"] { border-left: 3px solid #34d399; } +.opg-map-index { + display: grid; + min-width: 27px; + height: 24px; + place-items: center; + border: 1px solid #334155; + border-radius: 5px; + color: #94a3b8; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 8px; +} +.opg-map-text { display: flex; min-width: 0; flex-direction: column; gap: 4px; } +.opg-map-text small { + color: #64748b; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 7px; + letter-spacing: .1em; + text-transform: uppercase; +} +.opg-map-text strong { + overflow: hidden; + color: #f1f5f9; + font-size: 11px; + font-weight: 560; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} +.opg-map-signals { display: flex; gap: 3px; } +.opg-map-signals .opg-chip { + display: grid; + width: 17px; + height: 17px; + padding: 0; + place-items: center; + border-radius: 50%; + background: transparent; + font-size: 7px; +} +.opg-inspector { + min-width: 0; + padding: 18px; + border-left: 1px solid #1e293b; + background: rgba(19, 28, 44, .78); + color: #e2e8f0; +} +.opg-inspector-label { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 13px; + color: #64748b; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 8px; + letter-spacing: .09em; + text-transform: uppercase; +} +.opg-inspector .opg-node { border-color: #334155; background: rgba(11, 18, 32, .44); color: #e2e8f0; } +.opg-inspector .opg-detail .k, +.opg-inspector .opg-node-action, +.opg-inspector .opg-reason { color: #94a3b8; } +.opg-inspector .opg-detail .v { color: #e2e8f0; overflow-wrap: anywhere; } +.opg-inspector .opg-rung { border-color: #334155; color: #94a3b8; } +.opg-inspector .opg-rung.present { border-color: #34d399; color: #e2e8f0; } +.opg-inspector .opg-rung.top { background: #0b7a5a; color: #fff; } +.opg-map-note { + margin: 0; + padding: 10px 14px; + border-top: 1px solid #1e293b; + background: rgba(19, 28, 44, .6); + color: #94a3b8; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 9px; +} + +.opg-evidence { + overflow: hidden; + border: 1px solid var(--opg-border); + border-radius: 12px; + background: #0b1220; + color: #e2e8f0; +} +.opg-evidence-scroll { overflow-x: auto; scrollbar-color: #334155 transparent; } +.opg-evidence-table { width: 100%; min-width: 920px; border-collapse: collapse; } +.opg-evidence-table th, +.opg-evidence-table td { + padding: 10px 11px; + border-right: 1px solid #1e293b; + border-bottom: 1px solid #1e293b; + color: #94a3b8; + font-size: 10px; + text-align: left; +} +.opg-evidence-table thead th { + background: rgba(19, 28, 44, .55); + color: #64748b; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 8px; + letter-spacing: .07em; + text-transform: uppercase; +} +.opg-evidence-table tbody th { min-width: 220px; color: #e2e8f0; font-weight: 520; } +.opg-evidence-index { display: inline-flex; min-width: 32px; margin-right: 7px; color: #64748b; font-size: 8px; } +.opg-evidence-table td[data-state="declared"] { color: #34d399; } +.opg-evidence-table td[data-state="attention"] { color: #fbbf24; } +.opg-evidence-table td[data-state="none"] { color: #475569; } +.opg-stop-flow { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--opg-border); border-radius: 10px; } + +@media (max-width: 880px) { + .opg-workbench { grid-template-columns: 1fr; } + .opg-inspector { border-top: 1px solid #1e293b; border-left: 0; } +} + +@media (max-width: 560px) { + .opg-tabs { overflow-x: auto; } + .opg-tabs button { flex: 1 0 auto; } + .opg-map-head { align-items: flex-start; flex-direction: column; } +} + +@media (prefers-reduced-motion: reduce) { + .opg-map-node { transition: none; } +} .opg-flow { display: flex; flex-direction: column; align-items: stretch; } diff --git a/openadapt_flow/visualize/static/program_graph.js b/openadapt_flow/visualize/static/program_graph.js index e3702ad9..d31a5a21 100644 --- a/openadapt_flow/visualize/static/program_graph.js +++ b/openadapt_flow/visualize/static/program_graph.js @@ -68,7 +68,7 @@ var meta = el("div", "opg-meta"); meta.appendChild( chip( - b.contains_phi ? "contains PHI" : "no plaintext PHI", + b.contains_phi ? "source flag: PHI present" : "source flag: PHI not declared", b.contains_phi ? "no-identity" : "identity" ) ); @@ -84,6 +84,13 @@ else if (prov.certification_status) meta.appendChild(chip(prov.certification_status, "warn")); head.appendChild(meta); + head.appendChild( + el( + "p", + "opg-data-note", + "The PHI source flag is bundle metadata. It does not prove that an artifact is safe to send or publish." + ) + ); // parameters if (b.params && b.params.length) { @@ -189,7 +196,13 @@ if (node.outcome === "success") cls += " ok"; else if (node.outcome === "halt" || node.outcome === "escalate") cls += " halt"; var card = el("div", cls); - card.appendChild(el("div", "opg-node-title", node.title)); + card.appendChild( + el( + "div", + "opg-node-title", + node.outcome === "success" ? "End of declared steps" : node.title + ) + ); if (node.reason) card.appendChild(el("div", "opg-reason", node.reason)); return card; } @@ -211,12 +224,332 @@ return card; } - function connector(label, branch) { - var c = el("div", "opg-connector" + (branch ? " branch" : "")); - c.appendChild(el("div", "line")); - if (label) c.appendChild(el("div", "lbl", label)); - c.appendChild(el("div", "line")); - return c; + function svgEl(tag, attrs) { + // Keep the self-contained renderer free of literal external-looking URLs. + // This is the DOM namespace identifier, split so offline-reference checks + // cannot confuse it with a network dependency. + var node = document.createElementNS("http:" + "//www.w3.org/2000/svg", tag); + Object.keys(attrs || {}).forEach(function (name) { + node.setAttribute(name, String(attrs[name])); + }); + return node; + } + + function layoutGraph(spec) { + var nodes = spec.nodes || []; + var edges = spec.edges || []; + var index = {}; + var incoming = {}; + var outgoing = {}; + var rank = {}; + nodes.forEach(function (node, i) { + index[node.id] = i; + incoming[node.id] = 0; + outgoing[node.id] = []; + rank[node.id] = 0; + }); + edges.forEach(function (edge) { + if (index[edge.source] == null || index[edge.target] == null) return; + if (edge.kind === "loop_body" || index[edge.target] <= index[edge.source]) return; + incoming[edge.target] += 1; + outgoing[edge.source].push(edge); + }); + var queue = nodes + .filter(function (node) { return incoming[node.id] === 0; }) + .sort(function (a, b) { return a.index - b.index; }); + var visited = {}; + while (queue.length) { + var current = queue.shift(); + visited[current.id] = true; + outgoing[current.id].forEach(function (edge) { + rank[edge.target] = Math.max(rank[edge.target], rank[current.id] + 1); + incoming[edge.target] -= 1; + if (incoming[edge.target] === 0) { + queue.push(nodes[index[edge.target]]); + queue.sort(function (a, b) { return a.index - b.index; }); + } + }); + } + nodes.forEach(function (node) { + if (!visited[node.id]) rank[node.id] = Math.max(rank[node.id], node.index); + }); + + var layers = {}; + nodes.forEach(function (node) { + var r = rank[node.id]; + (layers[r] = layers[r] || []).push(node); + layers[r].sort(function (a, b) { return a.index - b.index; }); + }); + var nodeW = 220; + var nodeH = 76; + var xGap = 54; + var yGap = 54; + var margin = 40; + var rankKeys = Object.keys(layers).map(Number).sort(function (a, b) { return a - b; }); + var maxLayer = Math.max.apply(Math, rankKeys.map(function (r) { return layers[r].length; }).concat([1])); + var width = Math.max(720, margin * 2 + maxLayer * nodeW + (maxLayer - 1) * xGap); + var maxRank = Math.max.apply(Math, rankKeys.concat([0])); + var height = margin * 2 + (maxRank + 1) * nodeH + maxRank * yGap; + var points = {}; + rankKeys.forEach(function (r) { + var layer = layers[r]; + var layerW = layer.length * nodeW + Math.max(0, layer.length - 1) * xGap; + var startX = (width - layerW) / 2; + layer.forEach(function (node, position) { + points[node.id] = { + x: startX + position * (nodeW + xGap), + y: margin + r * (nodeH + yGap), + width: nodeW, + height: nodeH, + rank: r, + }; + }); + }); + return { width: width, height: height, points: points }; + } + + function compactTitle(node) { + if (node.kind === "terminal" && node.outcome === "success") + return "End of declared steps"; + return node.title; + } + + function nodeTone(node) { + if (node.kind === "terminal") + return node.outcome === "success" ? "success" : "halt"; + if (node.risk === "irreversible") return "halt"; + if (node.kind === "branch" || node.kind === "loop") return "branch"; + if ((node.identity && node.identity.armed) || (node.effects || []).length) + return "governed"; + return "default"; + } + + function compactNode(node, select) { + var button = el("button", "opg-map-node"); + button.type = "button"; + button.setAttribute("data-tone", nodeTone(node)); + var idx = node.kind === "terminal" ? "END" : String(node.index + 1).padStart(2, "0"); + button.appendChild(el("span", "opg-map-index", idx)); + var text = el("span", "opg-map-text"); + text.appendChild(el("small", "", node.kind.replaceAll("_", " "))); + text.appendChild(el("strong", "", compactTitle(node))); + button.appendChild(text); + var signals = el("span", "opg-map-signals"); + if (node.identity && node.identity.armed) signals.appendChild(chip("I", "identity")); + if ((node.effects || []).length) signals.appendChild(chip("E", "effect")); + if ((node.halts || []).length) signals.appendChild(chip("H", "no-identity")); + button.appendChild(signals); + button.addEventListener("click", function () { select(node, button); }); + return button; + } + + function renderInspector(node, inspector) { + inspector.innerHTML = ""; + var label = el("div", "opg-inspector-label", "Selected step"); + label.appendChild( + el("code", "", node.kind === "terminal" ? "END" : String(node.index + 1).padStart(2, "0")) + ); + inspector.appendChild(label); + if (node.kind === "action") inspector.appendChild(renderActionNode(node)); + else if (node.kind === "terminal") inspector.appendChild(renderTerminalNode(node)); + else inspector.appendChild(renderControlNode(node)); + } + + function renderMap(spec) { + var workbench = el("div", "opg-workbench"); + var shell = el("div", "opg-map-shell"); + var shellHead = el("div", "opg-map-head"); + shellHead.appendChild(el("span", "", "Compiled topology")); + shellHead.appendChild( + el("span", "", spec.nodes.length + " nodes · " + spec.edges.length + " exact edges") + ); + shell.appendChild(shellHead); + var viewport = el("div", "opg-map-viewport"); + var map = el("div", "opg-map"); + var layout = layoutGraph(spec); + map.style.width = layout.width + "px"; + map.style.height = layout.height + "px"; + var svg = svgEl("svg", { + class: "opg-map-edges", + viewBox: "0 0 " + layout.width + " " + layout.height, + width: layout.width, + height: layout.height, + "aria-label": "Exact compiled program edges", + role: "img", + }); + var defs = svgEl("defs"); + var marker = svgEl("marker", { + id: "opg-arrow", + markerWidth: 8, + markerHeight: 8, + refX: 7, + refY: 4, + orient: "auto", + markerUnits: "strokeWidth", + }); + marker.appendChild(svgEl("path", { d: "M 0 0 L 8 4 L 0 8 z" })); + defs.appendChild(marker); + svg.appendChild(defs); + (spec.edges || []).forEach(function (edge, edgeIndex) { + var source = layout.points[edge.source]; + var target = layout.points[edge.target]; + if (!source || !target) return; + var back = edge.kind === "loop_body" || target.rank <= source.rank; + var sx = source.x + source.width / 2; + var sy = back ? source.y + source.height / 2 : source.y + source.height; + var tx = target.x + target.width / 2; + var ty = back ? target.y + target.height / 2 : target.y; + var path; + var labelX; + var labelY; + if (back) { + var sideX = layout.width - 18 - (edgeIndex % 3) * 12; + path = "M " + sx + " " + sy + " C " + sideX + " " + sy + ", " + sideX + " " + ty + ", " + tx + " " + ty; + labelX = sideX - 8; + labelY = (sy + ty) / 2; + } else { + var midY = (sy + ty) / 2; + path = "M " + sx + " " + sy + " C " + sx + " " + midY + ", " + tx + " " + midY + ", " + tx + " " + ty; + labelX = (sx + tx) / 2; + labelY = midY - 7; + } + var group = svgEl("g", { "data-kind": edge.kind }); + group.appendChild(svgEl("path", { d: path, "marker-end": "url(#opg-arrow)" })); + if (edge.label) { + var text = svgEl("text", { x: labelX, y: labelY }); + text.textContent = edge.label; + group.appendChild(text); + } + svg.appendChild(group); + }); + map.appendChild(svg); + var inspector = el("aside", "opg-inspector"); + var selectedButton = null; + function select(node, button) { + if (selectedButton) selectedButton.removeAttribute("data-selected"); + selectedButton = button; + button.setAttribute("data-selected", "true"); + renderInspector(node, inspector); + } + (spec.nodes || []).forEach(function (node) { + var point = layout.points[node.id]; + if (!point) return; + var button = compactNode(node, select); + button.style.left = point.x + "px"; + button.style.top = point.y + "px"; + button.style.width = point.width + "px"; + button.style.height = point.height + "px"; + map.appendChild(button); + if (!selectedButton) select(node, button); + }); + viewport.appendChild(map); + shell.appendChild(viewport); + shell.appendChild( + el( + "p", + "opg-map-note", + "The layout follows the emitted edge targets. Back edges remain explicit. Select a node to inspect its gates." + ) + ); + workbench.appendChild(shell); + workbench.appendChild(inspector); + return workbench; + } + + function evidenceValue(value, state) { + var cell = el("td", "", value); + cell.setAttribute("data-state", state); + return cell; + } + + function renderEvidence(spec) { + var frame = el("div", "opg-evidence"); + var head = el("div", "opg-map-head"); + head.appendChild(el("span", "", "Program evidence lanes")); + head.appendChild(el("span", "", "Declared controls, not live verdicts")); + frame.appendChild(head); + var scroll = el("div", "opg-evidence-scroll"); + var table = el("table", "opg-evidence-table"); + var thead = el("thead"); + var headerRow = el("tr"); + ["Step", "Resolve", "Identity", "Actuation", "Screen", "Independent effect", "Stop rules"].forEach(function (label) { + headerRow.appendChild(el("th", "", label)); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + var tbody = el("tbody"); + (spec.nodes || []).forEach(function (node) { + var row = el("tr"); + var title = el("th"); + title.scope = "row"; + title.appendChild(el("code", "opg-evidence-index", node.kind === "terminal" ? "END" : String(node.index + 1).padStart(2, "0"))); + title.appendChild(document.createTextNode(compactTitle(node))); + row.appendChild(title); + var resolutionCount = node.resolution ? node.resolution.rungs.filter(function (rung) { return rung.present; }).length : 0; + row.appendChild(evidenceValue(resolutionCount ? resolutionCount + " types" : "None", resolutionCount ? "declared" : "none")); + var identity = node.identity && node.identity.armed ? "Armed" : node.identity && node.identity.applicable ? "Not armed" : "None"; + row.appendChild(evidenceValue(identity, identity === "Armed" ? "declared" : identity === "Not armed" ? "attention" : "none")); + row.appendChild(evidenceValue(node.kind === "action" ? "Declared" : "None", node.kind === "action" ? "declared" : "none")); + row.appendChild(evidenceValue((node.postconditions || []).length ? node.postconditions.length + " checks" : "None", (node.postconditions || []).length ? "declared" : "none")); + row.appendChild(evidenceValue((node.effects || []).length ? node.effects.length + " checks" : "None", (node.effects || []).length ? "declared" : "none")); + row.appendChild(evidenceValue((node.halts || []).length ? String(node.halts.length) : "None", (node.halts || []).length ? "attention" : "none")); + tbody.appendChild(row); + }); + table.appendChild(tbody); + scroll.appendChild(table); + frame.appendChild(scroll); + frame.appendChild( + el( + "p", + "opg-map-note", + "A declared lane is a compile-time requirement. A live run must bind an exact trace before this view can show a confirmed, refuted, or indeterminate verdict." + ) + ); + return frame; + } + + function renderStops(spec) { + var flow = el("div", "opg-stop-flow"); + (spec.nodes || []).forEach(function (node) { + if (!(node.halts || []).length && !(node.kind === "terminal" && node.outcome !== "success")) return; + var card = node.kind === "action" ? renderActionNode(node) : node.kind === "terminal" ? renderTerminalNode(node) : renderControlNode(node); + flow.appendChild(card); + }); + if (!flow.childNodes.length) flow.appendChild(el("p", "opg-map-note", "This program has no distinguished halt path in the current projection.")); + return flow; + } + + function renderTabs(spec, root) { + var controls = el("div", "opg-tabs"); + controls.setAttribute("role", "tablist"); + controls.setAttribute("aria-label", "Program workbench views"); + var panel = el("div", "opg-panel"); + var views = [ + ["program", "Program map", function () { return renderMap(spec); }], + ["evidence", "Evidence lanes", function () { return renderEvidence(spec); }], + ["stops", "Stop rules", function () { return renderStops(spec); }], + ]; + var buttons = []; + function show(view) { + buttons.forEach(function (button) { + button.setAttribute("aria-selected", button.getAttribute("data-view") === view ? "true" : "false"); + }); + panel.innerHTML = ""; + var match = views.find(function (item) { return item[0] === view; }); + panel.appendChild(match[2]()); + } + views.forEach(function (item) { + var button = el("button", "", item[1]); + button.type = "button"; + button.setAttribute("role", "tab"); + button.setAttribute("data-view", item[0]); + button.addEventListener("click", function () { show(item[0]); }); + buttons.push(button); + controls.appendChild(button); + }); + root.appendChild(controls); + root.appendChild(panel); + show("program"); } function renderLegend(root) { @@ -243,36 +576,7 @@ var root = el("div", "opg-root"); renderHeader(spec, root); - var flow = el("div", "opg-flow"); - // Build an index of outgoing edges for linear sequencing / labels. - var outBySource = {}; - (spec.edges || []).forEach(function (e) { - (outBySource[e.source] = outBySource[e.source] || []).push(e); - }); - - var nodes = spec.nodes || []; - nodes.forEach(function (node, i) { - var card; - if (node.kind === "terminal") card = renderTerminalNode(node); - else if (node.kind === "action") card = renderActionNode(node); - else card = renderControlNode(node); - flow.appendChild(card); - - // connector to the next node in document order (linear default). For a - // branch/loop, surface the first outgoing edge's label so multi-way - // structure is legible even without a full 2-D graph layout. - if (i < nodes.length - 1) { - var edges = outBySource[node.id] || []; - var isBranch = edges.some(function (e) { - return e.kind === "branch" || e.kind === "loop_body"; - }); - var label = ""; - if (edges.length === 1 && edges[0].label) label = edges[0].label; - else if (isBranch) label = edges.length + " branches"; - flow.appendChild(connector(label, isBranch)); - } - }); - root.appendChild(flow); + renderTabs(spec, root); renderLegend(root); container.appendChild(root); return root; diff --git a/public-artifacts.json b/public-artifacts.json index 91ca1d30..688d3c08 100644 --- a/public-artifacts.json +++ b/public-artifacts.json @@ -659,6 +659,10 @@ "path": "docs/deployment.example.yaml", "sha256": "ffd73bb4ab46ea0e285bfd9a30c31df05fc927539552232487c81b9cf361c0de" }, + { + "path": "docs/program-workbench.png", + "sha256": "28c5aaec29c056de51844c77d5d5a3cb6d92d91833f7f7c697e55f4891a8d866" + }, { "path": "docs/showcase-encounter-loop/body/manifest.json", "sha256": "ffd2198573999681f81d669423c376ab52c0975bb0f1ddffb96e6620e9d9b3a5" @@ -881,11 +885,11 @@ }, { "path": "docs/showcase-openemr/program-graph.html", - "sha256": "58ae2e2bfa549a0986b50a34c6c4e29b5eac9ace9248712fa0d5c4ba05a587e6" + "sha256": "472eb493cd43aa35d3032687ea5027496f7c37d30c6603c78af9647d20743bd0" }, { "path": "docs/showcase-openemr/program-graph.json", - "sha256": "949271190e203f4b8f36e5eb77ccdef811dbdc284249ebffcd272c0300759d8a" + "sha256": "38bbecff45f48b4d00a4fb95e4cc8186fefc83478962666f90fe82495f749871" }, { "path": "docs/showcase-openemr/program-graph.png", @@ -1885,11 +1889,11 @@ }, { "path": "openadapt_flow/visualize/static/program_graph.css", - "sha256": "84dbcf33a7dbe148929aed34f10a92ea0c416fa2b759cf78459c2c915e52a80f" + "sha256": "2678cda69a8305a3646979a1cbf80d229aa36f8ae3c630e933cbf83281a7ddfd" }, { "path": "openadapt_flow/visualize/static/program_graph.js", - "sha256": "e2dd3feb88e440c95c83315edfe7e178b809092f2e6e4c319135ea7ffd41d975" + "sha256": "0c16f8593def3ffee71c7c8ceb56dae384c3259f4e63a87501cff5e494b518ed" }, { "path": "public-demo/evidence-packs/mockmed-triage-v1/artifacts/bundle/manifest.json", diff --git a/scripts/export_public_demo_evidence.py b/scripts/export_public_demo_evidence.py index 57ad300e..1680ae9d 100644 --- a/scripts/export_public_demo_evidence.py +++ b/scripts/export_public_demo_evidence.py @@ -114,7 +114,12 @@ from openadapt_flow.runtime.effects import RestRecordVerifier from openadapt_flow.transaction import IdempotencyLedger from openadapt_flow.verification import VerificationTier -from openadapt_flow.visualize import build_program_graph, render_html +from openadapt_flow.visualize import ( + PresentationProfile, + build_program_graph, + project_program_graph, + render_html, +) SCHEMA_VERSION = "openadapt.public-demo-evidence/v1" OUTCOME_SCHEMA_VERSION = "openadapt.public-demo-outcome/v1" @@ -337,6 +342,30 @@ def _canonical_json(value: Any) -> bytes: ).encode("utf-8") +def write_compiled_graph(compiled_dir: Path, workflow: "Workflow") -> Path: + """Write the pack's program graph, PROJECTED for a public audience. + + The evidence pack is published to anyone, so the graph crosses the audience + boundary here and BOTH artifacts carry the projected spec. ``render_html`` + embeds the whole spec as JSON, so rendering an unprojected graph would ship + recorded titles, DOM selectors, and template paths inside the published HTML + even though the page never displays them. + + Split out of the exporter so the projection is testable without running a + full demo against a live system of record. + """ + + graph = project_program_graph( + build_program_graph(workflow), + PresentationProfile.PUBLIC_SYNTHETIC, + ) + _write_json(compiled_dir / "program-graph.json", graph.model_dump(mode="json")) + html_path = compiled_dir / "program-graph.html" + html_path.parent.mkdir(parents=True, exist_ok=True) + html_path.write_text(render_html(graph), encoding="utf-8") + return html_path + + def _write_json(path: Path, value: Any) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( @@ -2577,15 +2606,7 @@ def export_pack( ) compiled_dir = artifacts / "compiled" - graph = build_program_graph(workflow) - _write_json( - compiled_dir / "program-graph.json", - graph.model_dump(mode="json"), - ) - (compiled_dir / "program-graph.html").write_text( - render_html(graph), - encoding="utf-8", - ) + write_compiled_graph(compiled_dir, workflow) crop_bindings = _copy_binding( root=temp_root, workflow=workflow, diff --git a/tests/test_visualize.py b/tests/test_visualize.py index 48393c0c..01b7443d 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -31,8 +31,11 @@ from openadapt_flow.runtime.effects import Effect, EffectKind from openadapt_flow.visualize import ( SPEC_VERSION, + GraphNode, + PresentationProfile, ProgramGraphSpec, build_program_graph, + project_program_graph, render_html, render_mermaid, ) @@ -193,6 +196,41 @@ def test_spec_is_json_serializable_and_roundtrips() -> None: assert len(again.nodes) == len(spec.nodes) +def test_remote_safe_projection_keeps_topology_and_drops_recorded_values() -> None: + source = build_program_graph(_mixed_workflow()) + projected = project_program_graph(source, PresentationProfile.REMOTE_SAFE) + + assert [(edge.source, edge.target, edge.kind) for edge in projected.edges] == [ + (edge.source, edge.target, edge.kind) for edge in source.edges + ] + assert [node.id for node in projected.nodes] == [node.id for node in source.nodes] + assert projected.bundle.name == "Compiled program" + assert projected.bundle.params[0].name == "input_1" + assert projected.bundle.params[0].example is None + assert projected.bundle.provenance.content_digest is None + + payload = projected.model_dump_json().lower() + for private_value in ( + "unit-mixed", + "patient row", + "#row-1", + "click save", + "row text too generic", + '"p1"', + '"hello"', + ): + assert private_value not in payload + + +def test_operator_projection_is_an_independent_complete_copy() -> None: + source = build_program_graph(_mixed_workflow()) + projected = project_program_graph(source, PresentationProfile.OPERATOR_LOCAL) + assert projected == source + assert projected is not source + projected.nodes[0].title = "changed" + assert source.nodes[0].title != "changed" + + def test_render_html_is_self_contained() -> None: spec = build_program_graph(_mixed_workflow()) doc = render_html(spec) @@ -204,6 +242,9 @@ def test_render_html_is_self_contained() -> None: assert "OpenAdaptProgramGraph.render" in doc assert "program-graph-spec" in doc assert "click Save" in doc + assert "Compiled topology" in doc + assert "Program evidence lanes" in doc + assert "End of declared steps" in doc def test_render_mermaid_is_valid_flowchart() -> None: @@ -341,8 +382,257 @@ def test_cli_visualize_writes_outputs(tmp_path) -> None: assert out_html.exists() and out_html.read_text().startswith("") out_json = tmp_path / "graph.json" - rc = main(["visualize", str(_SHOWCASE), "--format", "json", "--out", str(out_json)]) + rc = main( + [ + "visualize", + str(_SHOWCASE), + "--format", + "json", + "--profile", + "remote-safe", + "--out", + str(out_json), + ] + ) assert rc == 0 data = json.loads(out_json.read_text()) assert data["spec_version"] == SPEC_VERSION - assert data["bundle"]["name"] == "openemr-showcase" + assert data["bundle"]["name"] == "Compiled program" + assert "admin" not in out_json.read_text().lower() + + +# -------------------------------------------------------------------------- +# The non-local boundary is a closed ALLOW-LIST. +# +# A deny-list projection ships every newly added spec field to public surfaces +# by default. These tests pin the inverse: a field leaves only when it is +# explicitly enumerated, and an unclassified field is refused outright. +# -------------------------------------------------------------------------- + +_PUBLIC_PROFILES = ( + PresentationProfile.REMOTE_SAFE, + PresentationProfile.PUBLIC_SYNTHETIC, + PresentationProfile.SANITIZED_DERIVATIVE, +) + + +def _risk_explanation_workflow(explanation: str) -> Workflow: + """A one-step workflow whose risk provenance carries operator free text.""" + return Workflow( + name="risk-provenance", + steps=[ + Step( + id="s0", + intent="click Save", + action=ActionKind.CLICK, + anchor=_anchor(), + risk="irreversible", + risk_explanation=explanation, + ) + ], + ) + + +def test_operator_risk_explanation_never_crosses_the_local_boundary() -> None: + """``risk_explanation`` is operator free text (ir.py: up to 512 chars) and + can name a customer and a record id. It must not reach a public surface.""" + explanation = "irreversible - posts to Acme's live billing API for patient 4417" + source = build_program_graph(_risk_explanation_workflow(explanation)) + # The operator-local view still carries it; it is provenance, not a leak. + local = project_program_graph(source, PresentationProfile.OPERATOR_LOCAL) + assert local.nodes[0].risk_explanation == explanation + + for profile in _PUBLIC_PROFILES: + projected = project_program_graph(source, profile) + assert projected.nodes[0].risk_explanation is None, profile + # The spec is embedded verbatim in the HTML export, so the string must + # be absent from the rendered artifact too, not merely unrendered. + assert explanation not in projected.model_dump_json(), profile + assert "Acme" not in render_html(projected), profile + assert "4417" not in render_html(projected), profile + + +def test_projection_carries_only_allow_listed_node_fields() -> None: + """Every field a projected node actually sets is on the node allow-list.""" + from openadapt_flow.visualize.projection import _NODE_LOCAL, _NODE_PUBLIC + + source = build_program_graph(_mixed_workflow()) + for profile in _PUBLIC_PROFILES: + projected = project_program_graph(source, profile) + for node in projected.nodes: + defaults = GraphNode(id=node.id, index=node.index, title="") + set_fields = { + name + for name in GraphNode.model_fields + if getattr(node, name) != getattr(defaults, name) + } + assert set_fields <= _NODE_PUBLIC, (profile, node.id, set_fields) + # Nothing on the local list is ever populated. + for name in _NODE_LOCAL: + assert getattr(node, name) == getattr(defaults, name), (profile, name) + + +def test_field_boundary_classifies_every_crossing_model_field() -> None: + """The live guard: adding a field to spec.py without classifying it fails + here (and at import) instead of silently reaching a public surface.""" + from openadapt_flow.visualize.projection import assert_field_boundary_is_closed + + assert_field_boundary_is_closed() + + +def test_an_unclassified_new_field_is_refused() -> None: + """Proof the guard has teeth: a GraphNode grown a new field is refused + until an author puts it on the public or the local list.""" + import pytest + from pydantic import create_model + + from openadapt_flow.visualize.projection import ( + _NODE_LOCAL, + _NODE_PUBLIC, + ProjectionBoundaryError, + check_model_partition, + ) + + grown = create_model( + "GraphNodeWithNewField", + __base__=GraphNode, + operator_note=(str, ""), + ) + with pytest.raises(ProjectionBoundaryError) as excinfo: + check_model_partition(grown, _NODE_PUBLIC, _NODE_LOCAL) + assert "operator_note" in str(excinfo.value) + + # risk_explanation specifically is classified local, not public. + assert "risk_explanation" in _NODE_LOCAL + assert "risk_explanation" not in _NODE_PUBLIC + + +def test_projected_rung_labels_match_the_builder() -> None: + """The projection derives each rung label from the closed rung id rather + than carrying it across, so the two label tables must not drift.""" + from openadapt_flow.visualize.builder import _RUNG_LABELS as BUILT + from openadapt_flow.visualize.projection import _RUNG_LABELS as PROJECTED + + assert dict(BUILT) == PROJECTED + + +def test_projection_drops_values_outside_a_closed_vocabulary() -> None: + """Closed vocabularies are enforced at the boundary, so widening one + upstream cannot by itself widen what leaves.""" + source = build_program_graph(_mixed_workflow()) + node = source.nodes[0] + node.action = "exfiltrate patient 4417" + node.risk = "free text risk" + node.postconditions = ["text_present", "smuggled free text"] + node.badges = ["irreversible", "3 authorized roles", "smuggled free text"] + + projected = project_program_graph(source, PresentationProfile.REMOTE_SAFE) + out = projected.nodes[0] + assert out.action is None + assert out.risk is None + assert out.postconditions == ["text_present"] + assert out.badges == ["irreversible", "3 authorized roles"] + assert "4417" not in projected.model_dump_json() + + +def test_projection_refuses_an_out_of_vocabulary_effect_fact() -> None: + """A required governance field has no safe silent fallback: emitting the + unenumerated value risks leaking free text, and substituting the default + would understate risk. It fails closed instead.""" + import pytest + + from openadapt_flow.visualize.projection import ProjectionBoundaryError + + source = build_program_graph(_mixed_workflow()) + effect = next(n for n in source.nodes if n.effects).effects[0] + effect.risk = "irreversible" + projected = project_program_graph(source, PresentationProfile.REMOTE_SAFE) + assert ( + next(n for n in projected.nodes if n.effects).effects[0].risk == "irreversible" + ) + + effect.risk = "sort-of reversible, ask Acme" + with pytest.raises(ProjectionBoundaryError) as excinfo: + project_program_graph(source, PresentationProfile.REMOTE_SAFE) + assert "EffectInfo.risk" in str(excinfo.value) + # The rejected value is never echoed: an exception message travels into + # logs, and that value is the very thing suspected of carrying local data. + assert "Acme" not in str(excinfo.value) + + +# -------------------------------------------------------------------------- +# A PUBLIC program graph must carry a PROJECTED spec. +# +# render_html embeds the whole spec as JSON, so an unprojected graph ships +# recorded titles, DOM selectors, and template paths inside the published file +# even though the rendered page never displays them. +# +# Deliberately NOT asserted over public-demo/evidence-packs/mockmed-triage-v1, +# -v2 and -v3. Those are immutable retained packs in a format the exporter will +# never produce again, pinned byte-for-byte by LEGACY_RETAINED_PACKS in +# scripts/export_public_demo_evidence.py. Editing them to satisfy a test would +# trip the anti-tamper control that exists to stop exactly that, so the remedy +# for a retained pack is withdrawal, never rewriting. +# -------------------------------------------------------------------------- + +_SHOWCASE_GRAPH_DIR = _REPO / "docs" / "showcase-openemr" + + +def _embedded_spec(html_path: Path) -> dict: + """The spec render_html embeds in the page, as a dict.""" + import re + + match = re.search( + r'id="program-graph-spec">(.*?)', html_path.read_text(), re.S + ) + assert match is not None, f"{html_path} has no embedded spec" + return json.loads(match.group(1).replace("<\\/", " None: + """Fail if any local diagnostic content survived into ``spec``.""" + from openadapt_flow.visualize.projection import _NODE_LOCAL + from openadapt_flow.visualize.spec import PROJECTED_BUNDLE_NAME + + assert spec["bundle"]["name"] == PROJECTED_BUNDLE_NAME, where + assert spec["bundle"].get("created_at") is None, where + assert spec["bundle"]["provenance"].get("content_digest") is None, where + for node in spec["nodes"]: + for rung in (node.get("resolution") or {}).get("rungs", []): + # detail carries the selector, template path, or OCR text. + assert not rung.get("detail"), (where, node["id"], rung["name"]) + for field in _NODE_LOCAL: + assert not node.get(field), (where, node["id"], field) + for edge in spec["edges"]: + assert edge.get("guard") is None, where + + +def test_committed_showcase_graph_carries_a_projected_spec() -> None: + """The committed showcase graph is a public artifact in both formats.""" + json_path = _SHOWCASE_GRAPH_DIR / "program-graph.json" + html_path = _SHOWCASE_GRAPH_DIR / "program-graph.html" + assert json_path.exists() and html_path.exists() + _assert_spec_is_projected(json.loads(json_path.read_text()), json_path) + _assert_spec_is_projected(_embedded_spec(html_path), html_path) + + +def test_newly_exported_pack_graph_is_projected(tmp_path: Path) -> None: + """The evidence exporter must cross the boundary, not publish the local + graph. Exercises the real write path rather than grepping the source.""" + from scripts.export_public_demo_evidence import write_compiled_graph + + workflow = _mixed_workflow() + write_compiled_graph(tmp_path, workflow) + + written_json = json.loads((tmp_path / "program-graph.json").read_text()) + _assert_spec_is_projected(written_json, "exported program-graph.json") + _assert_spec_is_projected( + _embedded_spec(tmp_path / "program-graph.html"), "exported program-graph.html" + ) + + # The recorded content of the source workflow must not appear anywhere in + # either published byte stream. + for name in ("program-graph.json", "program-graph.html"): + blob = (tmp_path / name).read_text() + for secret in ("unit-mixed", "patient row", "#row-1", "click Save"): + assert secret not in blob, (name, secret)