From d88bd0118ecfe07e44508965ec01412578c30ce6 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 27 Aug 2026 16:57:52 -0400 Subject: [PATCH 1/5] feat: add governed program visualization profiles --- docs/VISUALIZE.md | 129 +++--- docs/program-workbench.png | Bin 0 -> 60933 bytes openadapt_flow/__main__.py | 22 +- openadapt_flow/visualize/__init__.py | 6 + openadapt_flow/visualize/projection.py | 119 ++++++ openadapt_flow/visualize/render.py | 2 +- .../visualize/static/program_graph.css | 234 +++++++++++ .../visualize/static/program_graph.js | 380 ++++++++++++++++-- tests/test_visualize.py | 56 ++- 9 files changed, 837 insertions(+), 111 deletions(-) create mode 100644 docs/program-workbench.png create mode 100644 openadapt_flow/visualize/projection.py diff --git a/docs/VISUALIZE.md b/docs/VISUALIZE.md index 7a3dd1b2..29b70a8c 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,63 @@ 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. +`remote-safe`, `public-synthetic`, and `sanitized-derivative` remove recorded +text, parameter values, selectors, URLs, free-text predicates, and local +provenance. The projection does not sanitize the source bundle. It does not +prove that the source is safe to send. + +## Rendering choice and tradeoffs -- **Engine emits the spec; surfaces render it** — rather than each surface +- **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 0000000000000000000000000000000000000000..ac7e0b037045d2c52b3669267c5b8d9a1cf784a2 GIT binary patch literal 60933 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 Kh6{Rrbp0P1$NoJ4 literal 0 HcmV?d00001 diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index b603836e..9c230b50 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -2638,13 +2638,18 @@ def _cmd_explain(args: argparse.Namespace) -> int: def _cmd_visualize(args: argparse.Namespace) -> int: from openadapt_flow.ir import Workflow from openadapt_flow.visualize import ( + PresentationProfile, build_program_graph, + project_program_graph, render_html, render_mermaid, ) workflow = Workflow.load(Path(args.bundle)) - spec = build_program_graph(workflow) + spec = project_program_graph( + build_program_graph(workflow), + PresentationProfile(args.profile), + ) fmt = args.format if fmt == "json": output = spec.model_dump_json(indent=2) @@ -5717,6 +5722,21 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="Write to this file instead of stdout (parent dirs are created)", ) + p.add_argument( + "--profile", + choices=( + "operator-local", + "remote-safe", + "public-synthetic", + "sanitized-derivative", + ), + default="operator-local", + help=( + "Data boundary for the rendered view. Non-local profiles remove " + "recorded text, values, selectors, URLs, free-text predicates, " + "and local provenance. Projection is not source sanitization." + ), + ) p.set_defaults(func=_cmd_visualize) p = sub.add_parser( diff --git a/openadapt_flow/visualize/__init__.py b/openadapt_flow/visualize/__init__.py index 97c91f88..04d92362 100644 --- a/openadapt_flow/visualize/__init__.py +++ b/openadapt_flow/visualize/__init__.py @@ -15,6 +15,10 @@ from __future__ import annotations from openadapt_flow.visualize.builder import build_program_graph +from openadapt_flow.visualize.projection import ( + PresentationProfile, + project_program_graph, +) from openadapt_flow.visualize.render import render_html, render_mermaid from openadapt_flow.visualize.spec import ( SPEC_VERSION, @@ -43,10 +47,12 @@ "NodeKind", "ParamInfo", "ProgramGraphSpec", + "PresentationProfile", "ProvenanceInfo", "ResolutionInfo", "ResolutionRung", "build_program_graph", + "project_program_graph", "render_html", "render_mermaid", ] diff --git a/openadapt_flow/visualize/projection.py b/openadapt_flow/visualize/projection.py new file mode 100644 index 00000000..499d6097 --- /dev/null +++ b/openadapt_flow/visualize/projection.py @@ -0,0 +1,119 @@ +"""Audience-bound projections of the compiled program graph. + +The operator-local graph is the complete diagnostic view. Other surfaces get a +closed projection that removes recorded text, parameter values, selectors, +URLs, free-text predicates, and local provenance. Projection does not sanitize +the source bundle and never changes its governance flags. +""" + +from __future__ import annotations + +from enum import Enum + +from openadapt_flow.visualize.spec import GraphNode, NodeKind, ProgramGraphSpec + + +class PresentationProfile(str, Enum): + """Data boundary for one rendered program graph.""" + + OPERATOR_LOCAL = "operator-local" + REMOTE_SAFE = "remote-safe" + PUBLIC_SYNTHETIC = "public-synthetic" + SANITIZED_DERIVATIVE = "sanitized-derivative" + + +def _safe_title(node: GraphNode) -> str: + if node.kind == NodeKind.TERMINAL: + return ( + "End of declared steps" + if (node.outcome or "").lower() == "success" + else "Stopped for review" + ) + if node.kind == NodeKind.BRANCH: + return "Evaluate a declared condition" + if node.kind == NodeKind.BUSINESS_DECISION: + return "Request an authorized decision" + if node.kind == NodeKind.LOOP: + return "Repeat the bounded steps" + if node.kind == NodeKind.SUBFLOW_CALL: + return "Run an approved subflow" + action = (node.action or "").lower() + if action in {"click", "double_click"}: + return "Select an interface target" + if action == "type": + if node.secret: + return "Enter an approved secret" + if node.param: + return "Enter an approved input" + return "Enter an approved value" + if action == "key": + return "Send an approved key" + if action == "scroll": + return "Move through the current view" + if action in {"wait", "sleep"}: + return "Wait for the declared state" + if node.has_api_binding: + return "Run an approved API action" + return "Run an approved action" + + +def _safe_halts(node: GraphNode) -> list[str]: + if not node.halts: + return [] + return [f"declared stop rule {index + 1}" for index in range(len(node.halts))] + + +def project_program_graph( + spec: ProgramGraphSpec, + profile: PresentationProfile | str, +) -> ProgramGraphSpec: + """Return the exact graph structure for the requested data boundary. + + A non-local projection retains node and edge identities. It removes fields + whose values can contain recorded application data. It also removes local + provenance. The function does not assert that the source is PHI-free. + """ + + selected = PresentationProfile(profile) + if selected == PresentationProfile.OPERATOR_LOCAL: + return spec.model_copy(deep=True) + + projected = spec.model_copy(deep=True) + projected.bundle.name = "Compiled program" + projected.bundle.created_at = None + projected.bundle.viewport = None + projected.bundle.params = [ + param.model_copy(update={"name": f"input_{index + 1}", "example": None, "choices": []}) + for index, param in enumerate(projected.bundle.params) + ] + projected.bundle.provenance.content_digest = None + projected.bundle.provenance.source_recording_sha256 = None + projected.bundle.provenance.policy_name = None + + for node in projected.nodes: + node.title = _safe_title(node) + node.param = None + node.key = None + node.api_summary = None + node.guard = None + node.wait_until = None + node.reason = "" + node.halts = _safe_halts(node) + if node.resolution is not None: + for rung in node.resolution.rungs: + rung.detail = "" + if node.identity is not None: + node.identity.reason = None + for effect in node.effects: + effect.summary = "Independent effect contract" + + for edge in projected.edges: + edge.guard = None + edge.label = { + "sequence": "", + "branch": "declared branch", + "exception": "declared exception", + "loop_body": "declared loop", + }[edge.kind.value] + + return projected diff --git a/openadapt_flow/visualize/render.py b/openadapt_flow/visualize/render.py index 8772c0f5..619c3797 100644 --- a/openadapt_flow/visualize/render.py +++ b/openadapt_flow/visualize/render.py @@ -49,7 +49,7 @@ def render_html(spec: ProgramGraphSpec, *, title: str | None = None) -> str: {html.escape(page_title)} 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/tests/test_visualize.py b/tests/test_visualize.py index 48393c0c..72018fde 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -31,8 +31,10 @@ from openadapt_flow.runtime.effects import Effect, EffectKind from openadapt_flow.visualize import ( SPEC_VERSION, + PresentationProfile, ProgramGraphSpec, build_program_graph, + project_program_graph, render_html, render_mermaid, ) @@ -193,6 +195,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 +241,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 +381,20 @@ 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() From f023cf9f4999b9222d03aaa278a08d4869d82026 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 27 Aug 2026 18:10:24 -0400 Subject: [PATCH 2/5] fix(visualize): make the non-local projection a closed allow-list project_program_graph built its output as a deny-list over a deep model_copy: it copied every field, then nulled the ones known to carry local data. GraphNode.risk_explanation was never nulled, so it survived into the remote-safe and public-synthetic projections three lines after the code stripped three other provenance fields. risk_explanation is operator free text (ir.py: up to 512 chars, "Why the compiler or qualifying operator assigned this risk"). An operator note such as "irreversible - posts to Acme's live billing API for patient 4417" therefore reached a public surface, and because render_html embeds the whole spec as JSON, it reached the exported HTML file too. The one-field fix only resets the clock: the shape of the bug is that a deny-list ships every field added later by default. So the non-local profiles are now rebuilt from a closed allow-list instead. Each model that crosses the boundary declares exactly which of its fields may leave, the projection constructs a new instance from only those, and FIELD_BOUNDARY must partition every declared field of every crossing model. assert_field_boundary_is_closed runs at import, so adding a field to spec.py raises ProjectionBoundaryError until an author classifies it rather than silently publishing it. This mirrors openadapt-cloud's src/lib/runStatePresentation.ts (#336): a closed FACT_LABELS map, closed value sets, and a key type that refuses an unenumerated fact. Python has no compile-time equivalent, so the import-time partition check stands in for it. Values are closed as well as fields. Action, risk, outcome, guard_on_unmet, postcondition kind, effect kind, rung name, and badge are each checked against a closed set and dropped when they do not match, so widening a vocabulary upstream cannot widen this boundary on its own. Rung labels are derived from the rung id rather than carried across, with a test pinning them against the builder's table. Also registers docs/program-workbench.png in the public artifact inventory and refreshes the two static asset hashes this PR's own edits invalidated, and reflows projection.py for the formatter. Co-Authored-By: Claude Opus 5 --- docs/VISUALIZE.md | 17 +- openadapt_flow/visualize/projection.py | 480 ++++++++++++++++++++++--- public-artifacts.json | 8 +- tests/test_visualize.py | 135 +++++++ 4 files changed, 587 insertions(+), 53 deletions(-) diff --git a/docs/VISUALIZE.md b/docs/VISUALIZE.md index 29b70a8c..8ee77ae6 100644 --- a/docs/VISUALIZE.md +++ b/docs/VISUALIZE.md @@ -77,10 +77,19 @@ openadapt-flow visualize path/to/bundle --profile remote-safe -o program.html ``` The default `operator-local` profile includes local diagnostic detail. -`remote-safe`, `public-synthetic`, and `sanitized-derivative` remove recorded -text, parameter values, selectors, URLs, free-text predicates, and local -provenance. The projection does not sanitize the source bundle. It does not -prove that the source is safe to send. + +`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 diff --git a/openadapt_flow/visualize/projection.py b/openadapt_flow/visualize/projection.py index 499d6097..8f7185df 100644 --- a/openadapt_flow/visualize/projection.py +++ b/openadapt_flow/visualize/projection.py @@ -1,16 +1,48 @@ """Audience-bound projections of the compiled program graph. -The operator-local graph is the complete diagnostic view. Other surfaces get a -closed projection that removes recorded text, parameter values, selectors, -URLs, free-text predicates, and local provenance. Projection does not sanitize -the source bundle and never changes its governance flags. +The operator-local graph is the complete diagnostic view. Every other surface +gets a CLOSED ALLOW-LIST projection: each model that crosses the boundary +declares the exact fields permitted to leave, and the projection REBUILDS the +model from only those fields. A field that is not enumerated never leaves, +including one added to the spec after this module was written. + +The allow-list is enforced, not documented. :data:`FIELD_BOUNDARY` partitions +every declared field of every crossing model into ``public`` or ``local``, and +:func:`assert_field_boundary_is_closed` -- run at import -- raises +:class:`ProjectionBoundaryError` if any declared field falls in neither. Adding +a field to ``spec.py`` therefore fails loudly at import until an author +classifies it, rather than silently shipping it to a public surface. + +Values are closed too, not merely fields: a field whose vocabulary is finite +(action, risk, outcome, rung name, effect kind, postcondition kind, badge) is +checked against the closed set here and dropped when it does not match, so +widening a vocabulary upstream cannot widen this boundary by itself. + +Projection does not sanitize the source bundle and never changes its +governance flags. """ from __future__ import annotations +import re from enum import Enum +from typing import Final, Optional -from openadapt_flow.visualize.spec import GraphNode, NodeKind, ProgramGraphSpec +from pydantic import BaseModel + +from openadapt_flow.visualize.spec import ( + BundleMeta, + EffectInfo, + GraphEdge, + GraphNode, + IdentityInfo, + NodeKind, + ParamInfo, + ProgramGraphSpec, + ProvenanceInfo, + ResolutionInfo, + ResolutionRung, +) class PresentationProfile(str, Enum): @@ -22,6 +54,252 @@ class PresentationProfile(str, Enum): SANITIZED_DERIVATIVE = "sanitized-derivative" +class ProjectionBoundaryError(RuntimeError): + """A crossing model declares a field this module has not classified. + + Raised at import time. The fix is to add the new field to the model's + ``public`` set (if it can never carry recorded application data or operator + free text) or to its ``local`` set (if it can). + """ + + +# -------------------------------------------------------------------------- +# Closed value vocabularies. +# +# Each mirrors a finite upstream enum. They are restated here rather than +# imported so that widening the upstream enum does not silently widen this +# boundary, and so this module keeps its narrow import surface. +# -------------------------------------------------------------------------- + +_ACTIONS: Final = frozenset( + { + "click", + "double_click", + "drag", + "hotkey", + "key", + "right_click", + "scroll", + "select_option", + "type", + "wait", + } +) +_RISKS: Final = frozenset({"reversible", "irreversible"}) +_OUTCOMES: Final = frozenset({"success", "halt", "escalate"}) +_GUARD_ON_UNMET: Final = frozenset({"halt", "skip"}) +_POSTCONDITION_KINDS: Final = frozenset( + { + "text_present", + "text_absent", + "region_stable", + "url_changed", + "title_changed", + "new_tab_opened", + } +) +_EFFECT_KINDS: Final = frozenset({"record_written", "field_equals", "exact_new_set"}) + +#: Rung id -> its fixed public label. The label is DERIVED from the closed id, +#: never carried across from the source, so a free-text label upstream cannot +#: ride along. ``tests/test_visualize.py`` pins this against +#: ``builder._RUNG_LABELS`` so the two cannot drift. +_RUNG_LABELS: Final[dict[str, str]] = { + "api": "API / tool call", + "structural": "DOM / accessibility selector", + "template": "Image template match", + "ocr": "OCR text match", + "landmarks": "Nearby-landmark geometry", +} + +#: Literal badges the builder emits. Anything else is dropped. +_BADGES: Final = frozenset( + { + "irreversible", + "risk review", + "identity gate", + "no identity gate", + "effect check", + "API", + "secret", + "optional (skippable)", + "loop", + "human decision", + "halt", + "escalate", + } +) +#: The builder also emits two count-templated badges. Only a bounded integer +#: plus a fixed noun is admitted; the count is structure, not recorded data. +_COUNTED_BADGE_RE: Final = re.compile(r"^\d{1,4} (?:finite answers|authorized roles)$") + +#: Public edge labels, keyed by the closed edge kind. +_EDGE_LABELS: Final[dict[str, str]] = { + "sequence": "", + "branch": "declared branch", + "exception": "declared exception", + "loop_body": "declared loop", +} + + +# -------------------------------------------------------------------------- +# The field boundary: every declared field of every crossing model, classified. +# -------------------------------------------------------------------------- + +_NODE_PUBLIC: Final = frozenset( + { + "id", # synthetic node identity; topology is retained by contract + "index", + "kind", # NodeKind enum + "title", # REPLACED by _safe_title (closed phrase set) + "action", # closed vocabulary + "risk", # closed vocabulary + "risk_review_required", # bool + "secret", # bool + "resolution", # rebuilt field-by-field below + "identity", # rebuilt field-by-field below + "effects", # rebuilt field-by-field below + "has_api_binding", # bool + "postconditions", # closed vocabulary + "guard_on_unmet", # closed vocabulary + "outcome", # closed vocabulary + "halts", # REPLACED by _safe_halts (positional, no content) + "badges", # closed literal set + bounded count template + } +) +_NODE_LOCAL: Final = frozenset( + { + "risk_explanation", # operator free text (ir.py: up to 512 chars) + "param", # recorded parameter name + "key", # recorded key + "api_summary", # method + URL template + "guard", # free-text predicate summary + "wait_until", # free-text predicate summary + "reason", # free-text terminal reason + } +) + +_RUNG_PUBLIC: Final = frozenset({"name", "label", "present"}) +_RUNG_LOCAL: Final = frozenset({"detail"}) # selector / template path / OCR text + +_RESOLUTION_PUBLIC: Final = frozenset({"rungs", "top_rung"}) +_RESOLUTION_LOCAL: Final[frozenset[str]] = frozenset() + +_IDENTITY_PUBLIC: Final = frozenset( + {"applicable", "armed", "phi_free", "has_structured", "has_identifier_crop"} +) +_IDENTITY_LOCAL: Final = frozenset({"reason"}) # why the step compiled unarmed + +_EFFECT_PUBLIC: Final = frozenset( + {"kind", "summary", "risk", "needs_operator_confirmation"} +) +_EFFECT_LOCAL: Final[frozenset[str]] = frozenset() # summary is REPLACED, not carried + +_PARAM_PUBLIC: Final = frozenset({"name", "type", "required", "secret"}) +_PARAM_LOCAL: Final = frozenset({"example", "choices"}) + +_PROVENANCE_PUBLIC: Final = frozenset( + {"compiler_version", "certified", "certification_status", "expires_at"} +) +_PROVENANCE_LOCAL: Final = frozenset( + {"policy_name", "content_digest", "source_recording_sha256"} +) + +_BUNDLE_PUBLIC: Final = frozenset( + { + "name", # REPLACED by a fixed string + "schema_version", + "is_program", + "contains_phi", + "phi_scrubbed", + "encrypted", + "step_count", + "action_count", + "irreversible_count", + "identity_armed_count", + "identity_unarmed_count", + "effect_count", + "api_binding_count", + "halt_point_count", + "params", # rebuilt field-by-field + "provenance", # rebuilt field-by-field + } +) +_BUNDLE_LOCAL: Final = frozenset({"created_at", "viewport"}) + +_EDGE_PUBLIC: Final = frozenset({"source", "target", "kind", "label"}) +_EDGE_LOCAL: Final = frozenset({"guard"}) # free-text predicate summary + +_SPEC_PUBLIC: Final = frozenset({"spec_version", "bundle", "nodes", "edges"}) +_SPEC_LOCAL: Final[frozenset[str]] = frozenset() + +#: model -> (fields that may leave, fields that must not). Together these must +#: cover EVERY declared field of the model; see +#: :func:`assert_field_boundary_is_closed`. +FIELD_BOUNDARY: Final[dict[type[BaseModel], tuple[frozenset[str], frozenset[str]]]] = { + ProgramGraphSpec: (_SPEC_PUBLIC, _SPEC_LOCAL), + BundleMeta: (_BUNDLE_PUBLIC, _BUNDLE_LOCAL), + ProvenanceInfo: (_PROVENANCE_PUBLIC, _PROVENANCE_LOCAL), + ParamInfo: (_PARAM_PUBLIC, _PARAM_LOCAL), + GraphNode: (_NODE_PUBLIC, _NODE_LOCAL), + ResolutionInfo: (_RESOLUTION_PUBLIC, _RESOLUTION_LOCAL), + ResolutionRung: (_RUNG_PUBLIC, _RUNG_LOCAL), + IdentityInfo: (_IDENTITY_PUBLIC, _IDENTITY_LOCAL), + EffectInfo: (_EFFECT_PUBLIC, _EFFECT_LOCAL), + GraphEdge: (_EDGE_PUBLIC, _EDGE_LOCAL), +} + + +def check_model_partition( + model: type[BaseModel], + public: frozenset[str], + local: frozenset[str], +) -> None: + """Raise unless ``public`` and ``local`` exactly partition ``model``. + + This is the Python stand-in for the cloud presenter's + ``key: keyof typeof FACT_LABELS`` type: an unenumerated field is refused + rather than passed through. + """ + + declared = frozenset(model.model_fields) + overlap = public & local + if overlap: + raise ProjectionBoundaryError( + f"{model.__name__}: field(s) classified BOTH public and local: " + f"{sorted(overlap)}" + ) + unclassified = declared - (public | local) + if unclassified: + raise ProjectionBoundaryError( + f"{model.__name__}: unclassified field(s) {sorted(unclassified)} would " + "reach a non-local projection by default. Add each to the model's " + "public set in openadapt_flow/visualize/projection.py only if it can " + "never carry recorded application data or operator free text; " + "otherwise add it to the local set." + ) + stale = (public | local) - declared + if stale: + raise ProjectionBoundaryError( + f"{model.__name__}: classified field(s) {sorted(stale)} no longer exist" + ) + + +def assert_field_boundary_is_closed() -> None: + """Verify every crossing model is fully classified. Run at import.""" + + for model, (public, local) in FIELD_BOUNDARY.items(): + check_model_partition(model, public, local) + + +assert_field_boundary_is_closed() + + +# -------------------------------------------------------------------------- +# Derived public values. +# -------------------------------------------------------------------------- + + def _safe_title(node: GraphNode) -> str: if node.kind == NodeKind.TERMINAL: return ( @@ -63,57 +341,165 @@ def _safe_halts(node: GraphNode) -> list[str]: return [f"declared stop rule {index + 1}" for index in range(len(node.halts))] +def _in_vocabulary(value: Optional[str], vocabulary: frozenset[str]) -> Optional[str]: + """Return ``value`` when the closed vocabulary admits it, else ``None``.""" + + return value if value in vocabulary else None + + +def _safe_badges(badges: list[str]) -> list[str]: + return [ + badge + for badge in badges + if badge in _BADGES or _COUNTED_BADGE_RE.match(badge) is not None + ] + + +# -------------------------------------------------------------------------- +# Per-model allow-list rebuilds. Each constructs a NEW instance from the +# enumerated public fields only; nothing is copied wholesale. +# -------------------------------------------------------------------------- + + +def _project_rungs(resolution: ResolutionInfo) -> ResolutionInfo: + rungs = [ + ResolutionRung( + name=rung.name, + label=_RUNG_LABELS[rung.name], + present=rung.present, + # detail is LOCAL: selector, template path, or OCR text. + ) + for rung in resolution.rungs + if rung.name in _RUNG_LABELS + ] + return ResolutionInfo( + rungs=rungs, + top_rung=_in_vocabulary(resolution.top_rung, frozenset(_RUNG_LABELS)), + ) + + +def _project_identity(identity: IdentityInfo) -> IdentityInfo: + return IdentityInfo( + applicable=identity.applicable, + armed=identity.armed, + phi_free=identity.phi_free, + has_structured=identity.has_structured, + has_identifier_crop=identity.has_identifier_crop, + # reason is LOCAL: why the step compiled unarmed. + ) + + +def _project_effect(effect: EffectInfo) -> EffectInfo: + return EffectInfo( + kind=effect.kind if effect.kind in _EFFECT_KINDS else "record_written", + summary="Independent effect contract", + risk=effect.risk if effect.risk in _RISKS else "reversible", + needs_operator_confirmation=effect.needs_operator_confirmation, + ) + + +def _project_node(node: GraphNode) -> GraphNode: + """Rebuild ``node`` from the allow-list. Unenumerated fields never leave.""" + + return GraphNode( + id=node.id, + index=node.index, + kind=node.kind, + title=_safe_title(node), + action=_in_vocabulary(node.action, _ACTIONS), + risk=_in_vocabulary(node.risk, _RISKS), + risk_review_required=node.risk_review_required, + secret=node.secret, + resolution=( + None if node.resolution is None else _project_rungs(node.resolution) + ), + identity=(None if node.identity is None else _project_identity(node.identity)), + effects=[_project_effect(effect) for effect in node.effects], + has_api_binding=node.has_api_binding, + postconditions=[ + kind for kind in node.postconditions if kind in _POSTCONDITION_KINDS + ], + guard_on_unmet=_in_vocabulary(node.guard_on_unmet, _GUARD_ON_UNMET), + outcome=_in_vocabulary(node.outcome, _OUTCOMES), + halts=_safe_halts(node), + badges=_safe_badges(node.badges), + ) + + +def _project_edge(edge: GraphEdge) -> GraphEdge: + return GraphEdge( + source=edge.source, + target=edge.target, + kind=edge.kind, + label=_EDGE_LABELS[edge.kind.value], + # guard is LOCAL: a free-text predicate summary. + ) + + +def _project_param(param: ParamInfo, index: int) -> ParamInfo: + return ParamInfo( + name=f"input_{index + 1}", + type=param.type, + required=param.required, + secret=param.secret, + # example and choices are LOCAL: recorded values. + ) + + +def _project_provenance(provenance: ProvenanceInfo) -> ProvenanceInfo: + return ProvenanceInfo( + compiler_version=provenance.compiler_version, + certified=provenance.certified, + certification_status=provenance.certification_status, + expires_at=provenance.expires_at, + # policy_name, content_digest, source_recording_sha256 are LOCAL. + ) + + +def _project_bundle(bundle: BundleMeta) -> BundleMeta: + return BundleMeta( + name="Compiled program", + schema_version=bundle.schema_version, + is_program=bundle.is_program, + contains_phi=bundle.contains_phi, + phi_scrubbed=bundle.phi_scrubbed, + encrypted=bundle.encrypted, + step_count=bundle.step_count, + action_count=bundle.action_count, + irreversible_count=bundle.irreversible_count, + identity_armed_count=bundle.identity_armed_count, + identity_unarmed_count=bundle.identity_unarmed_count, + effect_count=bundle.effect_count, + api_binding_count=bundle.api_binding_count, + halt_point_count=bundle.halt_point_count, + params=[ + _project_param(param, index) for index, param in enumerate(bundle.params) + ], + provenance=_project_provenance(bundle.provenance), + # created_at and viewport are LOCAL. + ) + + def project_program_graph( spec: ProgramGraphSpec, profile: PresentationProfile | str, ) -> ProgramGraphSpec: """Return the exact graph structure for the requested data boundary. - A non-local projection retains node and edge identities. It removes fields - whose values can contain recorded application data. It also removes local - provenance. The function does not assert that the source is PHI-free. + A non-local projection retains node and edge identities. It is rebuilt from + the closed allow-list in :data:`FIELD_BOUNDARY`, so it carries only the + enumerated fields: no recorded application data, no operator free text, and + no local provenance. The function does not assert that the source is + PHI-free. """ selected = PresentationProfile(profile) if selected == PresentationProfile.OPERATOR_LOCAL: return spec.model_copy(deep=True) - projected = spec.model_copy(deep=True) - projected.bundle.name = "Compiled program" - projected.bundle.created_at = None - projected.bundle.viewport = None - projected.bundle.params = [ - param.model_copy(update={"name": f"input_{index + 1}", "example": None, "choices": []}) - for index, param in enumerate(projected.bundle.params) - ] - projected.bundle.provenance.content_digest = None - projected.bundle.provenance.source_recording_sha256 = None - projected.bundle.provenance.policy_name = None - - for node in projected.nodes: - node.title = _safe_title(node) - node.param = None - node.key = None - node.api_summary = None - node.guard = None - node.wait_until = None - node.reason = "" - node.halts = _safe_halts(node) - if node.resolution is not None: - for rung in node.resolution.rungs: - rung.detail = "" - if node.identity is not None: - node.identity.reason = None - for effect in node.effects: - effect.summary = "Independent effect contract" - - for edge in projected.edges: - edge.guard = None - edge.label = { - "sequence": "", - "branch": "declared branch", - "exception": "declared exception", - "loop_body": "declared loop", - }[edge.kind.value] - - return projected + return ProgramGraphSpec( + spec_version=spec.spec_version, + bundle=_project_bundle(spec.bundle), + nodes=[_project_node(node) for node in spec.nodes], + edges=[_project_edge(edge) for edge in spec.edges], + ) diff --git a/public-artifacts.json b/public-artifacts.json index 925c1d7a..cb8be26e 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" @@ -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/tests/test_visualize.py b/tests/test_visualize.py index 72018fde..fd38cbbf 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -31,6 +31,7 @@ from openadapt_flow.runtime.effects import Effect, EffectKind from openadapt_flow.visualize import ( SPEC_VERSION, + GraphNode, PresentationProfile, ProgramGraphSpec, build_program_graph, @@ -398,3 +399,137 @@ def test_cli_visualize_writes_outputs(tmp_path) -> None: assert data["spec_version"] == SPEC_VERSION 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() From 545e97841850fc9ae43980de0a9c30c618b8efab Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 27 Aug 2026 18:13:01 -0400 Subject: [PATCH 3/5] fix(visualize): fail closed on an out-of-vocabulary effect fact kind and risk on EffectInfo are required closed-vocabulary fields, so neither silent option was right: emitting an unenumerated value risks leaking free text, and substituting the model default misstates a system-of-record fact. Substituting the risk default is the worse of the two, because it silently downgrades an irreversible effect to reversible on a public surface. Both now raise ProjectionBoundaryError, matching the import-time field guard: an out-of-vocabulary value on a closed governance field means the spec has drifted from this module, which is what the module exists to catch. Optional fields keep dropping to None, which states nothing rather than stating something unenumerated. The rejected value is not echoed into the exception message, since that value is the one suspected of carrying recorded data and an exception message travels into logs and error reports. Co-Authored-By: Claude Opus 5 --- openadapt_flow/visualize/projection.py | 34 +++++++++++++++++++++++--- tests/test_visualize.py | 25 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/openadapt_flow/visualize/projection.py b/openadapt_flow/visualize/projection.py index 8f7185df..f2e7220f 100644 --- a/openadapt_flow/visualize/projection.py +++ b/openadapt_flow/visualize/projection.py @@ -342,11 +342,39 @@ def _safe_halts(node: GraphNode) -> list[str]: def _in_vocabulary(value: Optional[str], vocabulary: frozenset[str]) -> Optional[str]: - """Return ``value`` when the closed vocabulary admits it, else ``None``.""" + """Return ``value`` when the closed vocabulary admits it, else ``None``. + + For an OPTIONAL field, dropping to ``None`` is truthful: the projection + states nothing rather than stating something unenumerated. + """ return value if value in vocabulary else None +def _require_vocabulary(value: str, vocabulary: frozenset[str], field: str) -> str: + """Return ``value``, or raise if the closed vocabulary does not admit it. + + Used for a REQUIRED governance field, where the two silent options are both + wrong: emitting the unenumerated value risks leaking free text, and + substituting a default misstates a system-of-record fact (substituting the + ``risk`` default would actively understate risk). An out-of-vocabulary value + here means the spec has drifted from this module, which is the condition + this module exists to catch, so it fails closed and loudly. + """ + + if value not in vocabulary: + # The rejected value is NOT echoed: it is the very thing suspected of + # carrying recorded data, and an exception message travels into logs + # and error reports. + raise ProjectionBoundaryError( + f"{field}: value is outside the closed vocabulary " + f"{sorted(vocabulary)}. Widen the vocabulary in " + "openadapt_flow/visualize/projection.py only after confirming the " + "new value can never carry recorded application data." + ) + return value + + def _safe_badges(badges: list[str]) -> list[str]: return [ badge @@ -391,9 +419,9 @@ def _project_identity(identity: IdentityInfo) -> IdentityInfo: def _project_effect(effect: EffectInfo) -> EffectInfo: return EffectInfo( - kind=effect.kind if effect.kind in _EFFECT_KINDS else "record_written", + kind=_require_vocabulary(effect.kind, _EFFECT_KINDS, "EffectInfo.kind"), summary="Independent effect contract", - risk=effect.risk if effect.risk in _RISKS else "reversible", + risk=_require_vocabulary(effect.risk, _RISKS, "EffectInfo.risk"), needs_operator_confirmation=effect.needs_operator_confirmation, ) diff --git a/tests/test_visualize.py b/tests/test_visualize.py index fd38cbbf..b565b491 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -533,3 +533,28 @@ def test_projection_drops_values_outside_a_closed_vocabulary() -> 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) From 05756d93e7303aede13afa29e0dba74f900fc668 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 27 Aug 2026 18:30:54 -0400 Subject: [PATCH 4/5] fix(visualize): project the public demo graphs before publishing them scripts/export_public_demo_evidence.py rendered the program graph for the public demo evidence packs straight from build_program_graph, without ever calling the audience projection. render_html embeds the entire spec as JSON, so the published HTML carried every field even though the page never displays them, and the sibling program-graph.json carried them outright. The committed artifacts showed it: bundle.name read "mockmed-triage" rather than "Compiled program", node titles were the recorded ones such as "click 'Open'", and the resolution rungs carried 19 non-empty detail values including the DOM selector "#open-p1" and the template path "templates/step_000.png". docs/showcase-openemr carried 25 of them. All eight files are registered in the reviewed public artifact inventory. The export now projects with PUBLIC_SYNTHETIC and both artifacts are written from the projected spec. PUBLIC_SYNTHETIC rather than REMOTE_SAFE because these packs are published to anyone and are backed by synthetic data; the two behave identically today, so the choice only has to be semantically right for when they diverge. All eight committed artifacts are regenerated through the projection, each keeping its writer's serialization (sorted keys for the packs via _write_json, model field order for the showcase via the CLI). tests/test_visualize.py gains the durable check: it parses the spec out of every committed program-graph.html and .json and fails if the bundle name is not the projected one, if any resolution rung carries a detail, or if any node populates a field on the projection's local list. It reads that list from projection.py rather than restating it, so a field reclassified as local is covered without touching the test. A companion test cross-checks the file count against git ls-files so the check cannot quietly degrade into a pass over an empty list. PROJECTED_BUNDLE_NAME moves to spec.py, the shared wire contract, so the projection that sets the name and the renderer that titles a page from it agree without either importing the other, and a projected page is no longer titled "Compiled program - Compiled program". Co-Authored-By: Claude Opus 5 --- docs/showcase-openemr/program-graph.html | 620 ++++++++++++++++-- docs/showcase-openemr/program-graph.json | 159 +++-- openadapt_flow/visualize/projection.py | 3 +- openadapt_flow/visualize/render.py | 12 +- openadapt_flow/visualize/spec.py | 6 + public-artifacts.json | 16 +- .../artifacts/compiled/program-graph.html | 620 ++++++++++++++++-- .../artifacts/compiled/program-graph.json | 91 +-- .../artifacts/compiled/program-graph.html | 620 ++++++++++++++++-- .../artifacts/compiled/program-graph.json | 91 +-- .../artifacts/compiled/program-graph.html | 620 ++++++++++++++++-- .../artifacts/compiled/program-graph.json | 91 +-- scripts/export_public_demo_evidence.py | 17 +- tests/test_visualize.py | 98 +++ 14 files changed, 2705 insertions(+), 359 deletions(-) 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