Skip to content

Add governed program visualization profiles - #415

Merged
abrichr merged 7 commits into
mainfrom
codex/program-state-flow
Aug 28, 2026
Merged

Add governed program visualization profiles#415
abrichr merged 7 commits into
mainfrom
codex/program-state-flow

Conversation

@abrichr

@abrichr abrichr commented Aug 27, 2026

Copy link
Copy Markdown
Member

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

  • 16 visualizer tests
  • Ruff checks
  • Browser visual review

@abrichr

abrichr commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Reviewed from the diff. The four-profile idea is right and _safe_title / _safe_halts are the correct shape — they construct safe strings from a fixed vocabulary rather than trying to scrub unsafe ones. But project_program_graph does not follow that shape, and one field leaks today.

The leak

projected = 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 remote-safe and public-synthetic.

GraphNode.risk_explanation is not named. From ir.py:1005 it is a free-text field, max_length=512, described as:

"Why the compiler or qualifying operator assigned this risk. This is provenance, not runtime evidence."

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 risk_explanation: "irreversible — posts to Acme Corp's live billing API for patient 4417". That sentence is then rendered in a public-synthetic export.

I checked the other unnamed fields before claiming a pattern, and they are fine: badges is built from a fixed literal set in visualize/builder.py, and guard_on_unmet and postconditions are controlled vocabularies. risk_explanation is the one free-text field that escapes.

The structural point

The default is backwards. With a deny-list on a deep copy, any field added to GraphNode later ships to public surfaces until someone remembers to null it, and nothing fails when they forget.

openadapt-cloud#336, from this same cluster and the same hour, solves the identical problem the other way: src/lib/runStatePresentation.ts declares a closed FACT_LABELS and types its key as keyof typeof FACT_LABELS, so an unenumerated fact cannot compile. That is the pattern this file should use — build the projected node from named safe fields instead of copying and subtracting.

Minimum to unblock: null risk_explanation for non-local profiles, and add a test asserting no free-text node field survives a public-synthetic projection. The structural rewrite can follow.

Two CI failures, both mechanical

  • lint: formatting only, projection.py:86 — the param.model_copy(...) line needs wrapping.
  • wheel: check_release_consistency.py reports unregistered=['docs/program-workbench.png']. The new screenshot must be registered in the public artifact inventory and reviewed.

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.

abrichr and others added 6 commits August 27, 2026 18:10
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>
@abrichr
abrichr merged commit 1185f87 into main Aug 28, 2026
16 checks passed
@abrichr
abrichr deleted the codex/program-state-flow branch August 28, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant