Add governed program visualization profiles - #415
Conversation
|
Reviewed from the diff. The four-profile idea is right and The leakprojected = spec.model_copy(deep=True) # copies everything
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
...This is a deny-list over a full deep copy. Whatever is not named survives into
So it is operator-authored prose, it is explicitly provenance, and it passes through verbatim — three lines after the code deliberately strips three other provenance fields. The module docstring says the projection "removes recorded text, parameter values, selectors, URLs, free-text predicates, and local provenance." For this field that is not true. Concrete failure: an operator writes I checked the other unnamed fields before claiming a pattern, and they are fine: The structural pointThe default is backwards. With a deny-list on a deep copy, any field added to
Minimum to unblock: null Two CI failures, both mechanical
Not merging this. The leak is small to fix but it is a data-boundary defect in an open-core repo, and it should be fixed by someone who owns this boundary rather than patched by me inside your PR. |
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ort path Reverts the three mockmed-triage packs to their bytes on main. They are immutable retained packs, pinned byte-for-byte by LEGACY_RETAINED_PACKS in scripts/export_public_demo_evidence.py, whose comment names this exact case: the pin "prevents a pack from retaining the old id/commit while replacing an old report and its self-declared inventory digest." Rewriting their program-graph files tripped validate_pack with "inventory mismatch: artifacts/compiled/program-graph.html". That is the anti-tamper control working, not a stale pin. Refreshing manifest.sha256 and the pinned digests to get a green run would have defeated the control instead of satisfying it. The remedy for an immutable pack is withdrawal, never a silent rewrite, and that is a separate decision. Kept: the exporter now projects, so every pack it produces from here on carries a projected spec, and docs/showcase-openemr (not a retained pack) is regenerated through the projection. The graph write is extracted into write_compiled_graph so the projection is testable without running a demo against a live system of record. tests/test_visualize.py exercises that function directly and asserts the written .json and .html both carry a projected spec and contain none of the source workflow's recorded content. This replaces the earlier source grep, and it no longer asserts over the three retained packs, which are frozen in a format the exporter will never produce again. public-artifacts.json now reflects only what actually changed: the showcase pair, the workbench screenshot, and the two static assets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What changed
This upgrades the offline visualizer into a program workbench. It uses declared edges for branches and loops. It also adds map, evidence, and stop-rule views.
Four projection profiles control what leaves the local boundary. The public and remote profiles keep topology but remove local evidence values, target text, selectors, URLs, and provenance.
The renderer remains plain HTML, CSS, and SVG. It doesn't add a graph runtime dependency.
Validation