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.
-
+
+
+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("<\\/", ""))
+
+
+def _assert_spec_is_projected(spec: dict, where: object) -> 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)