From 1bdf7b23db3ea3539ef0147d2963623964ff13f9 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 15:45:26 -0500 Subject: [PATCH 1/6] Prevent resealing unverified bundles Signed-off-by: abrichr --- .../openadapt_flow/authorization_summary.py | 11 ++- .../flow/openadapt_flow/bundle_validation.py | 91 ++++++++++++++----- packages/flow/openadapt_flow/ir.py | 12 +++ .../openadapt_flow/qualification_proposal.py | 41 ++++++++- .../flow/tests/test_bundle_original_format.py | 46 ++++++++++ .../test_bundle_release_compatibility.py | 10 ++ .../flow/tests/test_qualification_proposal.py | 26 ++++++ 7 files changed, 209 insertions(+), 28 deletions(-) diff --git a/packages/flow/openadapt_flow/authorization_summary.py b/packages/flow/openadapt_flow/authorization_summary.py index 6ce5648b25..54e605927f 100644 --- a/packages/flow/openadapt_flow/authorization_summary.py +++ b/packages/flow/openadapt_flow/authorization_summary.py @@ -53,13 +53,22 @@ def __init__(self, code: str, detail: str = "") -> None: def bundle_digest(bundle_dir: Path) -> str: """Verify content identity against the original seal and current asset bytes.""" from openadapt_flow.bundle_validation import ( + BundleIntegrityError, compute_file_hashes, content_identity_digest, ) from openadapt_flow.ir import Workflow workflow = Workflow.load(bundle_dir) - return content_identity_digest(workflow, compute_file_hashes(workflow, bundle_dir)) + if workflow._loaded_seal is None or not workflow._loaded_seal.content_digest: + raise BundleIntegrityError( + "authorization summary requires a persisted bundle integrity seal" + ) + assets = workflow.decrypted_templates() if workflow.encrypted else None + return content_identity_digest( + workflow, + compute_file_hashes(workflow, bundle_dir, assets=assets), + ) def matching_bundle(digest: str, candidates: Sequence[Path]) -> Optional[Path]: diff --git a/packages/flow/openadapt_flow/bundle_validation.py b/packages/flow/openadapt_flow/bundle_validation.py index 115f9ee8db..fb4c448e62 100644 --- a/packages/flow/openadapt_flow/bundle_validation.py +++ b/packages/flow/openadapt_flow/bundle_validation.py @@ -517,6 +517,40 @@ def _verify_workflow_content(workflow: "Workflow", stored: BundleManifest) -> No ) +def verify_loaded_source_integrity( + workflow: "Workflow", + bundle_dir: Path | str, + stored: BundleManifest, + *, + decrypted_assets: Optional[dict[str, bytes]] = None, +) -> None: + """Verify the immutable source seal before a loaded workflow is resealed. + + Unlike :func:`verify_integrity`, this intentionally does not compare the + current runtime model fingerprint. A caller may have deliberately edited a + previously verified workflow in memory and then save that as new content. + The original payload, manifest identity, and original assets must still + verify first, so an unchecked or tampered load cannot be laundered by that + new seal. + """ + loaded = workflow._loaded_seal + if loaded is None or not loaded.content_digest: + raise BundleIntegrityError( + "cannot reseal a loaded bundle without a persisted integrity seal" + ) + if ( + stored.content_digest != loaded.content_digest + or tuple(sorted(stored.file_hashes.items())) != loaded.file_hashes + or stored.schema_version != loaded.schema_version + or stored.encrypted != loaded.encrypted + or _sha256_bytes(loaded.original_payload) != stored.content_digest + ): + raise BundleIntegrityError( + "bundle original manifest integrity changed after loading" + ) + _verify_asset_hashes(bundle_dir, stored, decrypted_assets=decrypted_assets) + + def _referenced_asset_paths(workflow: "Workflow") -> set[str]: """Every bundle-relative image path the IR references (templates, identifier crops, postcondition template crops), across the linear steps AND every @@ -819,6 +853,38 @@ class BundleIntegrityError(Exception): written.""" +def _verify_asset_hashes( + bundle_dir: Path | str, + stored: BundleManifest, + *, + decrypted_assets: Optional[dict[str, bytes]] = None, +) -> None: + bundle = Path(bundle_dir) + for rel, expected in stored.file_hashes.items(): + if decrypted_assets is not None: + data = decrypted_assets.get(rel) + if data is None: + raise BundleIntegrityError( + f"manifest lists sealed asset {rel!r} but its authenticated " + "plaintext is unavailable" + ) + if _sha256_bytes(data) != expected: + raise BundleIntegrityError( + f"sealed asset {rel!r} plaintext hash mismatch " + "(tampered or corrupted)" + ) + continue + path = bundle / rel + if not path.is_file(): + raise BundleIntegrityError( + f"manifest lists asset {rel!r} but it is missing from the bundle" + ) + if _sha256_file(path) != expected: + raise BundleIntegrityError( + f"asset {rel!r} hash mismatch (tampered or corrupted)" + ) + + def verify_integrity( workflow: "Workflow", bundle_dir: Path | str, @@ -853,30 +919,7 @@ def verify_integrity( # the sealed asset set) breaks this. _verify_workflow_content(workflow, stored) # 2. Each sealed asset must still hash to its recorded value. - bundle = Path(bundle_dir) - for rel, expected in stored.file_hashes.items(): - if decrypted_assets is not None: - data = decrypted_assets.get(rel) - if data is None: - raise BundleIntegrityError( - f"manifest lists sealed asset {rel!r} but its authenticated " - "plaintext is unavailable" - ) - if _sha256_bytes(data) != expected: - raise BundleIntegrityError( - f"sealed asset {rel!r} plaintext hash mismatch " - "(tampered or corrupted)" - ) - continue - path = bundle / rel - if not path.is_file(): - raise BundleIntegrityError( - f"manifest lists asset {rel!r} but it is missing from the bundle" - ) - if _sha256_file(path) != expected: - raise BundleIntegrityError( - f"asset {rel!r} hash mismatch (tampered or corrupted)" - ) + _verify_asset_hashes(bundle_dir, stored, decrypted_assets=decrypted_assets) # --------------------------------------------------------------------------- diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index 151d6fd0bf..804ff699e7 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -3529,6 +3529,18 @@ def save( bundle = Path(bundle_dir) from openadapt_flow import sealed_set as _sealed + if self._loaded_seal is not None: + if self.manifest is None: + raise _bv.BundleIntegrityError( + "loaded bundle lost its original integrity manifest" + ) + _bv.verify_loaded_source_integrity( + self, + bundle, + self.manifest, + decrypted_assets=(self._decrypted_templates if self.encrypted else None), + ) + if _sealed.active_writer(bundle) is not None or _sealed.is_sealed(bundle): # A sealed bundle stays sealed whatever ``encrypt`` says: many # commands re-save a bundle with a bare ``save(bundle)``, and that diff --git a/packages/flow/openadapt_flow/qualification_proposal.py b/packages/flow/openadapt_flow/qualification_proposal.py index f5fab34c79..e99b2c9fa9 100644 --- a/packages/flow/openadapt_flow/qualification_proposal.py +++ b/packages/flow/openadapt_flow/qualification_proposal.py @@ -433,17 +433,52 @@ def admit_local_dev( raise QualificationProposalError( "initialize qualification before local-dev admission" ) + stored_manifest = workflow.manifest + if stored_manifest is None: + raise QualificationProposalError( + "local-dev admission requires a persisted bundle integrity seal" + ) + from openadapt_flow import sealed_set from openadapt_flow.bundle_validation import ( + BundleIntegrityError, compute_content_digest, compute_file_hashes, + verify_loaded_source_integrity, ) bundle = Path(bundle_dir) + try: + verify_loaded_source_integrity( + workflow, + bundle, + stored_manifest, + decrypted_assets=( + workflow.decrypted_templates() + if workflow.encrypted or sealed_set.is_sealed(bundle) + else None + ), + ) + except BundleIntegrityError as exc: + raise QualificationProposalError( + "local-dev admission requires a verified persisted bundle seal" + ) from exc file_hashes = ( - dict(workflow.manifest.file_hashes) - if workflow.manifest is not None and workflow.manifest.file_hashes - else compute_file_hashes(workflow, bundle) + dict(stored_manifest.file_hashes) + if stored_manifest.file_hashes + else compute_file_hashes( + workflow, + bundle, + assets=( + workflow.decrypted_templates() + if workflow.encrypted or sealed_set.is_sealed(bundle) + else None + ), + ) ) + # The accepted proposal has intentionally changed qualification fields in + # memory. Hash that proposed new content only after the original on-disk + # seal and its assets have been verified above; save_accepted_bundle then + # reseals the same content before it is used. digest = compute_content_digest(workflow, file_hashes) project = workflow.qualification identity_contract = hashlib.sha256( diff --git a/packages/flow/tests/test_bundle_original_format.py b/packages/flow/tests/test_bundle_original_format.py index 59d868a919..1b78c77935 100644 --- a/packages/flow/tests/test_bundle_original_format.py +++ b/packages/flow/tests/test_bundle_original_format.py @@ -233,6 +233,52 @@ def test_unverified_load_cannot_launder_a_broken_original_seal(tmp_path): bundles.content_identity_digest(loaded, loaded.manifest.file_hashes) +@pytest.mark.parametrize("attack", ["workflow", "asset"]) +@pytest.mark.parametrize("copy_loaded", [False, True]) +def test_unverified_load_cannot_reseal_tampered_source( + tmp_path, attack, copy_loaded +): + bundle = saved_bundle(tmp_path) + if attack == "workflow": + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw["name"] = "tampered before unchecked load" + (bundle / "workflow.json").write_text(json.dumps(raw)) + else: + (bundle / "templates/button.png").write_bytes(b"tampered asset") + before = { + path.relative_to(bundle): path.read_bytes() + for path in bundle.rglob("*") + if path.is_file() + } + + loaded = Workflow.load(bundle, verify_integrity=False) + candidate = loaded.model_copy(deep=True) if copy_loaded else loaded + with pytest.raises(bundles.BundleIntegrityError): + candidate.save(bundle) + + after = { + path.relative_to(bundle): path.read_bytes() + for path in bundle.rglob("*") + if path.is_file() + } + assert after == before + + +def test_authorization_summary_refuses_manifestless_bundle(tmp_path): + bundle = saved_bundle(tmp_path) + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw.pop("manifest") + (bundle / "workflow.json").write_text(json.dumps(raw)) + (bundle / "manifest.json").unlink() + + from openadapt_flow.authorization_summary import bundle_digest + + with pytest.raises( + bundles.BundleIntegrityError, match="persisted bundle integrity seal" + ): + bundle_digest(bundle) + + def test_loaded_identity_refuses_replaced_asset_hashes_or_missing_manifest(tmp_path): bundle = saved_bundle(tmp_path) loaded = Workflow.load(bundle) diff --git a/packages/flow/tests/test_bundle_release_compatibility.py b/packages/flow/tests/test_bundle_release_compatibility.py index 1e85ef2050..5afb5a59db 100644 --- a/packages/flow/tests/test_bundle_release_compatibility.py +++ b/packages/flow/tests/test_bundle_release_compatibility.py @@ -57,6 +57,16 @@ def test_frozen_bundle_original_seal_loads_without_rewriting(frozen_bundle): assert hashes(bundle) == before +def test_verified_historical_bundle_can_be_resaved(frozen_bundle): + bundle, _expected = frozen_bundle + workflow = Workflow.load(bundle) + + workflow.save(bundle) + + reloaded = Workflow.load(bundle) + bundles.verify_integrity(reloaded, bundle, reloaded.manifest) + + @pytest.mark.parametrize("attack", ["workflow", "version", "asset"]) def test_frozen_bundle_tampering_refuses(frozen_bundle, attack): bundle, _ = frozen_bundle diff --git a/packages/flow/tests/test_qualification_proposal.py b/packages/flow/tests/test_qualification_proposal.py index e759b4c00d..78c0b77bf9 100644 --- a/packages/flow/tests/test_qualification_proposal.py +++ b/packages/flow/tests/test_qualification_proposal.py @@ -204,6 +204,32 @@ def test_accept_confirms_pins_and_keeps_break_it(tmp_path: Path) -> None: assert local.issuer_workflow == LOCAL_DEV_ISSUER_WORKFLOW +@pytest.mark.parametrize("source", ["unchecked_tamper", "manifestless"]) +def test_local_dev_admission_requires_a_verified_persisted_seal( + tmp_path: Path, source: str +) -> None: + workflow, recording, bundle = _write_workflow(tmp_path, _workflow()) + if source == "unchecked_tamper": + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw["name"] = "tampered before unchecked load" + (bundle / "workflow.json").write_text(json.dumps(raw), encoding="utf-8") + workflow = Workflow.load(bundle, verify_integrity=False) + else: + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw.pop("manifest") + (bundle / "workflow.json").write_text(json.dumps(raw), encoding="utf-8") + (bundle / "manifest.json").unlink() + workflow = Workflow.load(bundle) + + proposal = propose_qualification(workflow, recording_dir=recording) + accepted = accept_proposal(workflow, proposal) + with pytest.raises( + QualificationProposalError, + match="requires a verified persisted bundle seal", + ): + admit_local_dev(workflow, accepted, bundle_dir=bundle) + + def test_local_dev_key_cannot_enter_a_production_trust_map() -> None: registry = { LOCAL_DEV_KEY_ID: { From 96fe2f76dff130625048bae84ab5cf777a33ea54 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 16:53:45 -0500 Subject: [PATCH 2/6] Prevent resealing unverified Flow bundles Signed-off-by: abrichr --- .../openadapt_flow/authorization_summary.py | 26 ++-- .../flow/openadapt_flow/bundle_validation.py | 131 +++++++++++++++-- packages/flow/openadapt_flow/ir.py | 136 ++++++++++++++---- packages/flow/openadapt_flow/qualification.py | 7 + .../openadapt_flow/qualification_proposal.py | 4 + .../flow/openadapt_flow/sanitized_artifact.py | 20 ++- .../flow/tests/test_bundle_original_format.py | 82 ++++++++++- packages/flow/tests/test_bundle_schema_v2.py | 10 +- packages/flow/tests/test_business_decision.py | 21 +++ .../flow/tests/test_qualification_proposal.py | 5 +- .../flow/tests/test_sanitized_artifact.py | 5 + 11 files changed, 390 insertions(+), 57 deletions(-) diff --git a/packages/flow/openadapt_flow/authorization_summary.py b/packages/flow/openadapt_flow/authorization_summary.py index 54e605927f..cb73791b38 100644 --- a/packages/flow/openadapt_flow/authorization_summary.py +++ b/packages/flow/openadapt_flow/authorization_summary.py @@ -59,7 +59,12 @@ def bundle_digest(bundle_dir: Path) -> str: ) from openadapt_flow.ir import Workflow - workflow = Workflow.load(bundle_dir) + try: + workflow = Workflow.load(bundle_dir) + except BundleIntegrityError as exc: + raise BundleIntegrityError( + "authorization summary requires a persisted bundle integrity seal" + ) from exc if workflow._loaded_seal is None or not workflow._loaded_seal.content_digest: raise BundleIntegrityError( "authorization summary requires a persisted bundle integrity seal" @@ -124,9 +129,7 @@ def outline(bundle_dir: Path) -> ProgramOutline: """Sections (compiled ones if present, else one per field filled), writes, stops.""" from openadapt_flow.ir import Workflow - wf = Workflow.model_validate_json( - (Path(bundle_dir) / "workflow.json").read_text(encoding="utf-8") - ) + wf = Workflow.load(bundle_dir) steps = list(wf.steps) env = getattr(getattr(wf, "qualification", None), "environment", None) app = str(getattr(env, "application", "") or "") or wf.name.split("-", 1)[0] @@ -196,16 +199,21 @@ def _covered_by_digest(bundle_dir: Path, report: Path) -> bool: """True only when the report's exact bytes are a hashed file of the bundle.""" import hashlib - from openadapt_flow.bundle_validation import compute_file_hashes + from openadapt_flow.bundle_validation import ( + BundleIntegrityError, + compute_file_hashes, + ) from openadapt_flow.ir import Workflow try: rel = report.resolve().relative_to(bundle_dir.resolve()).as_posix() - wf = Workflow.model_validate_json( - (bundle_dir / "workflow.json").read_text("utf-8") + wf = Workflow.load(bundle_dir) + hashes = compute_file_hashes( + wf, + bundle_dir, + assets=wf.decrypted_templates() if wf.encrypted else None, ) - hashes = compute_file_hashes(wf, bundle_dir) - except (ValueError, OSError): + except (BundleIntegrityError, OSError, ValueError): return False return hashes.get(rel) == hashlib.sha256(report.read_bytes()).hexdigest() diff --git a/packages/flow/openadapt_flow/bundle_validation.py b/packages/flow/openadapt_flow/bundle_validation.py index fb4c448e62..abb62405e9 100644 --- a/packages/flow/openadapt_flow/bundle_validation.py +++ b/packages/flow/openadapt_flow/bundle_validation.py @@ -18,9 +18,8 @@ a certified bundle -- the policy it was certified against, its certification status, and an optional expiry. * **Migration** (:func:`migrate_bundle_dict`): a v1 bundle (or one with no - ``schema_version``) migrates cleanly to v2 in memory on read. v2 is a strict - superset of v1, so migration only stamps the new version and back-fills the - manifest if it is absent -- an existing bundle keeps replaying byte-for-byte. + ``schema_version``) migrates cleanly to v2 in memory when a caller explicitly + opts into unsealed legacy migration. Ordinary loads require a persisted seal. * **Load-time structural validation** (:func:`validate_workflow`): reject a malformed or uncertifiable graph with clear, per-issue errors BEFORE the replayer touches it. Rules span graph well-formedness (entry exists, every @@ -42,8 +41,9 @@ import copy import hashlib import json +import weakref from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any, Iterable, Iterator, Literal, Optional from pydantic import BaseModel, Field @@ -88,6 +88,25 @@ def _sha256_file(path: Path) -> str: return h.hexdigest() +def bundle_asset_path(bundle_dir: Path | str, rel: str) -> Path: + """Resolve a manifest asset only when it stays inside the bundle.""" + posix = PurePosixPath(rel) + if ( + not rel + or "\\" in rel + or posix.is_absolute() + or posix.as_posix() != rel + or any(part in {"", ".", ".."} for part in posix.parts) + ): + raise BundleIntegrityError(f"unsafe bundle asset path {rel!r}") + root = Path(bundle_dir).resolve() + candidate = root.joinpath(*posix.parts) + resolved = candidate.resolve() + if not resolved.is_relative_to(root) or candidate.is_symlink(): + raise BundleIntegrityError(f"bundle asset path escapes its directory: {rel!r}") + return candidate + + @dataclass(frozen=True) class _SealedCanonicalOmission: """One reviewed compatibility rule for a historical digest schema. @@ -469,6 +488,75 @@ class _LoadedSeal: file_hashes: tuple[tuple[str, str], ...] schema_version: int encrypted: bool + source_bundle_dir: Path + + +# Load authority is process-local and bound to the exact object. Pydantic +# private attributes are omitted from dumps but copied by model_copy, so the +# registry prevents a clone or model_validate(model_dump()) from inheriting it. +_verified_loads: dict[int, tuple[weakref.ReferenceType, object]] = {} + + +def has_verified_load_capability(workflow: "Workflow") -> bool: + entry = _verified_loads.get(id(workflow)) + return ( + entry is not None + and entry[0]() is workflow + and getattr(workflow, "_verification_capability", None) is entry[1] + ) + + +def _grant_verified_load_capability(workflow: "Workflow") -> None: + identity = id(workflow) + + def discard(reference: weakref.ReferenceType, *, identity: int = identity) -> None: + current = _verified_loads.get(identity) + if current is not None and current[0] is reference: + _verified_loads.pop(identity, None) + + reference = weakref.ref(workflow, discard) + token = object() + workflow._verification_capability = token + _verified_loads[identity] = (reference, token) + + +def transfer_verified_load_capability(source: "Workflow", target: "Workflow") -> None: + """Keep verified authority across an explicit in-memory model_copy only.""" + if has_verified_load_capability(source): + _grant_verified_load_capability(target) + + +def authorize_sanitized_derivative( + workflow: "Workflow", verified_source: "Workflow", source_bundle_dir: Path | str +) -> str: + """Authorize the sanitizer's verified-source → scrub → reseal transition.""" + source_manifest = verified_source.manifest + if ( + source_manifest is None + or not source_manifest.content_digest + or verified_source._loaded_seal is None + or not has_verified_load_capability(verified_source) + ): + raise BundleIntegrityError( + "sanitized bundle reseal requires a verified sealed source bundle" + ) + verify_loaded_source_integrity( + verified_source, + source_bundle_dir, + source_manifest, + decrypted_assets=( + verified_source.decrypted_templates() if verified_source.encrypted else None + ), + ) + if workflow.manifest is None: + raise BundleIntegrityError( + "sanitized bundle reseal requires the source manifest" + ) + workflow.manifest.provenance.sanitized_from_digest = source_manifest.content_digest + workflow._loaded_seal = None + workflow._unverified_load = False + _grant_verified_load_capability(workflow) + return source_manifest.content_digest def verify_original_content(raw: dict, stored: BundleManifest) -> bytes: @@ -481,7 +569,11 @@ def verify_original_content(raw: dict, stored: BundleManifest) -> bytes: def remember_original_content( - workflow: "Workflow", payload: bytes, stored: BundleManifest + workflow: "Workflow", + payload: bytes, + stored: BundleManifest, + *, + source_bundle_dir: Path | str, ) -> None: # The original payload is immutable; this second fingerprint detects later # mutation of the upgraded runtime object without reserializing the source. @@ -492,7 +584,9 @@ def remember_original_content( tuple(sorted(stored.file_hashes.items())), stored.schema_version, stored.encrypted, + Path(source_bundle_dir).resolve(), ) + _grant_verified_load_capability(workflow) def _verify_workflow_content(workflow: "Workflow", stored: BundleManifest) -> None: @@ -548,7 +642,13 @@ def verify_loaded_source_integrity( raise BundleIntegrityError( "bundle original manifest integrity changed after loading" ) - _verify_asset_hashes(bundle_dir, stored, decrypted_assets=decrypted_assets) + if not has_verified_load_capability(workflow): + raise BundleIntegrityError( + "loaded bundle verification capability is missing or was not preserved" + ) + _verify_asset_hashes( + loaded.source_bundle_dir, stored, decrypted_assets=decrypted_assets + ) def _referenced_asset_paths(workflow: "Workflow") -> set[str]: @@ -644,11 +744,11 @@ def compute_file_hashes( paths.add(f.relative_to(bundle).as_posix()) for rel in _referenced_asset_paths(workflow): - candidate = bundle / rel + candidate = bundle_asset_path(bundle, rel) if candidate.is_file(): - paths.add(Path(rel).as_posix()) + paths.add(rel) - return {rel: _sha256_file(bundle / rel) for rel in sorted(paths)} + return {rel: _sha256_file(bundle_asset_path(bundle, rel)) for rel in sorted(paths)} def _workflow_content(workflow: "Workflow") -> dict: @@ -689,6 +789,10 @@ def content_identity_digest(workflow: "Workflow", file_hashes: dict[str, str]) - or separately call verify_integrity. New serialization/build_manifest always uses compute_content_digest instead, so a new seal covers its new format. """ + if workflow._unverified_load: + raise BundleIntegrityError( + "an unchecked bundle load has no verified content identity" + ) loaded = workflow._loaded_seal if loaded is None: return compute_content_digest(workflow, file_hashes) @@ -784,6 +888,9 @@ def build_manifest( governed_authorization_template=( prior_prov.governed_authorization_template if prior_prov else None ), + sanitized_from_digest=( + prior_prov.sanitized_from_digest if prior_prov else None + ), ) return BundleManifest( content_digest=digest, @@ -861,6 +968,7 @@ def _verify_asset_hashes( ) -> None: bundle = Path(bundle_dir) for rel, expected in stored.file_hashes.items(): + path = bundle_asset_path(bundle, rel) if decrypted_assets is not None: data = decrypted_assets.get(rel) if data is None: @@ -874,7 +982,6 @@ def _verify_asset_hashes( "(tampered or corrupted)" ) continue - path = bundle / rel if not path.is_file(): raise BundleIntegrityError( f"manifest lists asset {rel!r} but it is missing from the bundle" @@ -909,8 +1016,8 @@ def verify_integrity( (re-saving reseals them). Only meaningful for a bundle that carries a persisted manifest with a - non-empty digest; legacy (pre-v2) bundles have no sealed digest to compare - against, and the caller skips this check for them.""" + non-empty digest. An explicit legacy migration opt-in creates a new seal + before the caller can save it.""" if not stored.content_digest and workflow._loaded_seal is None: return # 1. Recompute the digest over the loaded workflow content using the SEALED diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index 804ff699e7..e5188032b7 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -3009,6 +3009,11 @@ class BundleProvenance(BaseModel): description="Exact approved sanitized recording archive used for compilation", pattern="^[a-f0-9]{64}$", ) + sanitized_from_digest: Optional[str] = Field( + default=None, + description="Verified source bundle content digest for a sanitized derivative", + pattern="^[a-f0-9]{64}$", + ) compiler_config_sha256: Optional[str] = Field( default=None, description="Canonical digest of the compiler options used for this bundle", @@ -3097,7 +3102,8 @@ class BundleManifest(BaseModel): AES-256-GCM -- both ``workflow.json`` and every ``templates/*.png`` crop. The ``file_hashes`` are always digests over the PLAINTEXT asset (sealed BEFORE encryption), so integrity re-verifies against the decrypted crops. - Additive: a v1 bundle carries no manifest and one is computed on read. + Additive: a v1 bundle carries no manifest; computing a replacement seal + requires the caller to opt into legacy migration explicitly. """ schema_version: int = SCHEMA_VERSION @@ -3211,7 +3217,10 @@ def _decrypt_template_assets(bundle: Path, key: Optional[str]) -> dict[str, byte def _verify_sealed_template_integrity( - workflow: "Workflow", stored: "BundleManifest", decrypted: dict[str, bytes] + workflow: "Workflow", + bundle: Path, + stored: "BundleManifest", + decrypted: dict[str, bytes], ) -> None: """Integrity check for an ENCRYPTED bundle, run against the DECRYPTED crops in memory (the on-disk assets are ciphertext, so the disk-based @@ -3228,6 +3237,7 @@ def _verify_sealed_template_integrity( return _bv._verify_workflow_content(workflow, stored) for rel, expected in stored.file_hashes.items(): + _bv.bundle_asset_path(bundle, rel) data = decrypted.get(rel) if data is None: raise _bv.BundleIntegrityError( @@ -3380,8 +3390,8 @@ class Workflow(BaseModel): # Sealed on ``save`` (per-asset hashes, a whole-bundle content digest, the # compiler version, and -- for a certified bundle -- the certifying policy + # status + optional expiry) and re-verified on ``load``. Additive and - # backward-compatible: a v1 bundle carries no manifest, so one is computed - # on read (see ``openadapt_flow.bundle_validation``). Excluded from the + # backward-compatible: a v1 bundle carries no manifest, and callers must + # explicitly opt into the migration path to compute one on read. Excluded from the # content digest itself (the digest lives INSIDE it). manifest: Optional["BundleManifest"] = None @@ -3396,6 +3406,20 @@ class Workflow(BaseModel): _decrypted_templates: dict[str, bytes] = PrivateAttr(default_factory=dict) # Immutable source seal + upgraded-model fingerprint; never serialized. _loaded_seal: Any = PrivateAttr(default=None) + # Process-local authority granted only after integrity verification or by + # the sanitizer's verified-source transformation. Never serialized. + _verification_capability: Any = PrivateAttr(default=None) + # Explicitly unchecked loads remain un-saveable even if a caller attaches a + # fresh, empty manifest by hand. + _unverified_load: bool = PrivateAttr(default=False) + _legacy_migration: bool = PrivateAttr(default=False) + + def model_copy(self, *, update: Any = None, deep: bool = False) -> "Workflow": + clone = super().model_copy(update=update, deep=deep) + from openadapt_flow.bundle_validation import transfer_verified_load_capability + + transfer_verified_load_capability(self, clone) + return clone @model_validator(mode="after") def _undo_ids_are_unique(self) -> "Workflow": @@ -3470,10 +3494,12 @@ def _sync_disk_templates(self, bundle: Path) -> None: encrypted save removed from disk. A no-op for a freshly-compiled bundle (no in-memory crops; the compiler already wrote the plaintext PNGs).""" for rel, data in self._decrypted_templates.items(): - path = bundle / rel + from openadapt_flow.bundle_validation import bundle_asset_path + + path = bundle_asset_path(bundle, rel) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) - enc = bundle / f"{rel}.enc" + enc = bundle_asset_path(bundle, f"{rel}.enc") if enc.exists(): enc.unlink() @@ -3521,6 +3547,19 @@ def save( from openadapt_flow import bundle_validation as _bv _bv.bundle_schema_version({"schema_version": self.schema_version}) + if self._unverified_load: + raise _bv.BundleIntegrityError( + "an unchecked bundle load cannot be saved or resealed" + ) + if ( + self.manifest is not None + and self.manifest.content_digest + and not _bv.has_verified_load_capability(self) + ): + raise _bv.BundleIntegrityError( + "a manifest-bearing workflow needs its non-serializable " + "verified-load capability before it can be saved" + ) if self._loaded_seal is not None and not seal_manifest: raise _bv.BundleIntegrityError( "a loaded sealed bundle requires explicit resealing before serialization" @@ -3538,8 +3577,25 @@ def save( self, bundle, self.manifest, - decrypted_assets=(self._decrypted_templates if self.encrypted else None), + decrypted_assets=( + self._decrypted_templates if self.encrypted else None + ), ) + source = self._loaded_seal.source_bundle_dir + if source != bundle.resolve() and not self._loaded_seal.encrypted: + for rel, expected in self._loaded_seal.file_hashes: + source_asset = _bv.bundle_asset_path(source, rel) + target_asset = _bv.bundle_asset_path(bundle, rel) + if ( + not source_asset.is_file() + or _bv._sha256_file(source_asset) != expected + ): + raise _bv.BundleIntegrityError( + f"verified source asset {rel!r} changed before save-as" + ) + if not target_asset.exists(): + target_asset.parent.mkdir(parents=True, exist_ok=True) + target_asset.write_bytes(source_asset.read_bytes()) if _sealed.active_writer(bundle) is not None or _sealed.is_sealed(bundle): # A sealed bundle stays sealed whatever ``encrypt`` says: many @@ -3564,11 +3620,7 @@ def save( self.manifest = _bv.build_manifest(self, bundle) serialized = self.model_dump_json(indent=2) resealed_payload = None - if ( - self._loaded_seal is not None - and seal_manifest - and self.manifest is not None - ): + if seal_manifest and self.manifest is not None: resealed_payload = _bv.verify_original_content( json.loads(serialized), self.manifest ) @@ -3603,7 +3655,12 @@ def save( self.manifest.model_dump_json(indent=2).encode("utf-8") ) if resealed_payload is not None and self.manifest is not None: - _bv.remember_original_content(self, resealed_payload, self.manifest) + _bv.remember_original_content( + self, + resealed_payload, + self.manifest, + source_bundle_dir=bundle, + ) return path def _save_sealed_set( @@ -3640,11 +3697,7 @@ def _save_sealed_set( self.manifest = _bv.build_manifest(self, bundle, assets=assets) serialized = self.model_dump_json(indent=2).encode("utf-8") resealed_payload = None - if ( - self._loaded_seal is not None - and seal_manifest - and self.manifest is not None - ): + if seal_manifest and self.manifest is not None: from openadapt_flow import bundle_validation as _bv resealed_payload = _bv.verify_original_content( @@ -3659,7 +3712,12 @@ def _save_sealed_set( if owned: writer.finalize() if resealed_payload is not None and self.manifest is not None: - _bv.remember_original_content(self, resealed_payload, self.manifest) + _bv.remember_original_content( + self, + resealed_payload, + self.manifest, + source_bundle_dir=bundle, + ) return bundle / ("workflow.json" + _sealed.SEALED_SUFFIX) @classmethod @@ -3669,6 +3727,7 @@ def load( *, validate: bool = True, verify_integrity: bool = True, + allow_unsealed_legacy: bool = False, key: Optional[str] = None, ) -> "Workflow": """Load a bundle, migrating v1 -> v2, validating structure, and (for a @@ -3681,10 +3740,10 @@ def load( the *structural* category raises here; the effect-verification safety finding is surfaced by lint/certify, not the load path, so an existing uncertified-but-well-formed bundle still loads. - - ``verify_integrity`` (default True): if the bundle carries a sealed - manifest digest, recompute it and reject a tampered bundle. A legacy - (pre-v2) bundle has no sealed digest, so its manifest is computed - fresh and nothing is rejected. + - ``verify_integrity`` (default True): recompute a persisted sealed + digest and reject tampered content. Bundles without a persisted + digest are refused unless ``allow_unsealed_legacy=True`` is supplied + by a caller intentionally migrating a legacy artifact. - ``key`` (default None): decryption passphrase for an ENCRYPTED bundle (one saved with ``save(encrypt=True)``, present on disk as ``workflow.json.enc`` + ``templates/*.png.enc``). Resolved from ``key`` @@ -3743,12 +3802,20 @@ def load( if verify_integrity else _bv.source_canonical_payload(raw, stored.file_hashes) ) + if verify_integrity and (stored is None or not stored.content_digest): + if not allow_unsealed_legacy: + raise _bv.BundleIntegrityError( + "bundle has no persisted integrity seal; pass " + "allow_unsealed_legacy=True only for intentional legacy migration" + ) wf = cls.model_validate(_bv.migrate_bundle_dict(raw)) + wf._unverified_load = not verify_integrity if stored is not None: wf.manifest = stored - if original_payload is not None and stored is not None: - _bv.remember_original_content(wf, original_payload, stored) - + if original_payload is not None and stored is not None and verify_integrity: + _bv.remember_original_content( + wf, original_payload, stored, source_bundle_dir=bundle + ) if sealed_assets is not None: wf._decrypted_templates = sealed_assets elif bundle_encrypted: @@ -3757,12 +3824,27 @@ def load( # them via ``decrypted_template``; nothing cleartext lands on disk. wf._decrypted_templates = _decrypt_template_assets(bundle, key) + if original_payload is None and verify_integrity and allow_unsealed_legacy: + # Explicit legacy migration creates a new seal only after the + # caller accepted that the source had no integrity claim. For an + # encrypted bundle, hash the decrypted plaintext assets under + # their original names rather than the on-disk ciphertext files. + migration_assets = wf._decrypted_templates if bundle_encrypted else None + wf.manifest = _bv.build_manifest(wf, bundle, assets=migration_assets) + wf._legacy_migration = True + original_payload = _bv.source_canonical_payload( + wf.model_dump(mode="json"), wf.manifest.file_hashes + ) + _bv.remember_original_content( + wf, original_payload, wf.manifest, source_bundle_dir=bundle + ) + if verify_integrity and persisted is not None and wf.manifest is not None: if bundle_encrypted: # On-disk crops are ciphertext, so verify the sealed asset # digests against the decrypted plaintext held in memory. _verify_sealed_template_integrity( - wf, wf.manifest, wf._decrypted_templates + wf, bundle, wf.manifest, wf._decrypted_templates ) else: _bv.verify_integrity(wf, bundle, wf.manifest) diff --git a/packages/flow/openadapt_flow/qualification.py b/packages/flow/openadapt_flow/qualification.py index 9c660f640d..4920db33e4 100644 --- a/packages/flow/openadapt_flow/qualification.py +++ b/packages/flow/openadapt_flow/qualification.py @@ -1956,6 +1956,13 @@ def set_business_decision( raise QualificationError( f"the authored business decision makes the workflow invalid: {exc}" ) from exc + # This authoring API starts from a verified source, makes a deliberate + # in-memory derivative, and validates its complete serialized shape. Keep + # that source authority on the validated derivative; arbitrary callers of + # model_validate(model_dump()) remain unverified. + from openadapt_flow.bundle_validation import transfer_verified_load_capability + + transfer_verified_load_capability(candidate, validated) validation = validate_workflow(validated) if not validation.structural_ok: details = "; ".join( diff --git a/packages/flow/openadapt_flow/qualification_proposal.py b/packages/flow/openadapt_flow/qualification_proposal.py index e99b2c9fa9..f8da87b3b9 100644 --- a/packages/flow/openadapt_flow/qualification_proposal.py +++ b/packages/flow/openadapt_flow/qualification_proposal.py @@ -433,6 +433,10 @@ def admit_local_dev( raise QualificationProposalError( "initialize qualification before local-dev admission" ) + if workflow._unverified_load or workflow._legacy_migration: + raise QualificationProposalError( + "local-dev admission requires a verified persisted bundle seal" + ) stored_manifest = workflow.manifest if stored_manifest is None: raise QualificationProposalError( diff --git a/packages/flow/openadapt_flow/sanitized_artifact.py b/packages/flow/openadapt_flow/sanitized_artifact.py index a5114a6a1f..d1d7c49587 100644 --- a/packages/flow/openadapt_flow/sanitized_artifact.py +++ b/packages/flow/openadapt_flow/sanitized_artifact.py @@ -788,13 +788,18 @@ def _tree_rows(dest: Path) -> list[tuple[str, str]]: ] -def _reseal_sanitized_bundle(bundle: Path) -> None: +def _reseal_sanitized_bundle( + bundle: Path, verified_source: Any, source_bundle: Path +) -> None: """Rebuild generated bundle surfaces after a load-bearing transformation.""" from openadapt_flow.compiler.codegen import render_workflow_py from openadapt_flow.ir import Workflow workflow = Workflow.load(bundle, verify_integrity=False) + from openadapt_flow.bundle_validation import authorize_sanitized_derivative + + authorize_sanitized_derivative(workflow, verified_source, source_bundle) if workflow.manifest is not None: provenance = workflow.manifest.provenance provenance.policy_name = None @@ -869,6 +874,17 @@ def sanitize_artifact( artifacts.is_file(source / name) for name in ("workflow.json", "workflow.py", "manifest.json") ) + verified_source = None + if canonical_bundle: + from openadapt_flow.bundle_validation import BundleIntegrityError + from openadapt_flow.ir import Workflow + + try: + verified_source = Workflow.load(source) + except (BundleIntegrityError, OSError, ValueError, TypeError) as exc: + raise SanitizationError( + f"Cannot verify source bundle before sanitizing: {exc}" + ) from exc scrubber = privacy.get_scrubber() if scrubber is None: raise SanitizationError( @@ -992,7 +1008,7 @@ def sanitize_artifact( } ) if canonical_bundle and semantics == "not-preserved": - _reseal_sanitized_bundle(staging) + _reseal_sanitized_bundle(staging, verified_source, source) for entry in entries: derivative = staging / entry["path"] entry["derivative_sha256"] = _sha256_file(derivative) diff --git a/packages/flow/tests/test_bundle_original_format.py b/packages/flow/tests/test_bundle_original_format.py index 1b78c77935..10aa063990 100644 --- a/packages/flow/tests/test_bundle_original_format.py +++ b/packages/flow/tests/test_bundle_original_format.py @@ -57,6 +57,25 @@ def test_upgrade_is_explicit_deterministic_and_does_not_mutate_source(monkeypatc bundles.migrate_bundle_dict(original) +@pytest.mark.parametrize( + "asset", ["../outside.png", "/outside.png", "templates\\x.png"] +) +def test_manifest_asset_paths_cannot_escape_bundle(tmp_path, asset): + with pytest.raises(bundles.BundleIntegrityError, match="unsafe bundle asset path"): + bundles.bundle_asset_path(tmp_path / "bundle", asset) + + +def test_manifest_asset_paths_reject_symlink_escape(tmp_path): + bundle = tmp_path / "bundle" + bundle.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (bundle / "templates").symlink_to(outside, target_is_directory=True) + + with pytest.raises(bundles.BundleIntegrityError, match="escapes its directory"): + bundles.bundle_asset_path(bundle, "templates/crop.png") + + def test_original_seal_is_checked_before_current_model_construction( tmp_path, monkeypatch ): @@ -235,9 +254,7 @@ def test_unverified_load_cannot_launder_a_broken_original_seal(tmp_path): @pytest.mark.parametrize("attack", ["workflow", "asset"]) @pytest.mark.parametrize("copy_loaded", [False, True]) -def test_unverified_load_cannot_reseal_tampered_source( - tmp_path, attack, copy_loaded -): +def test_unverified_load_cannot_reseal_tampered_source(tmp_path, attack, copy_loaded): bundle = saved_bundle(tmp_path) if attack == "workflow": raw = json.loads((bundle / "workflow.json").read_bytes()) @@ -279,6 +296,65 @@ def test_authorization_summary_refuses_manifestless_bundle(tmp_path): bundle_digest(bundle) +def test_missing_seal_requires_explicit_legacy_migration_opt_in(tmp_path): + bundle = saved_bundle(tmp_path) + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw.pop("manifest") + (bundle / "workflow.json").write_text(json.dumps(raw)) + (bundle / "manifest.json").unlink() + + with pytest.raises( + bundles.BundleIntegrityError, match="no persisted integrity seal" + ): + Workflow.load(bundle) + + loaded = Workflow.load(bundle, allow_unsealed_legacy=True) + assert loaded.manifest is not None + assert loaded.manifest.content_digest + target = tmp_path / "migrated" + loaded.save(target) + reloaded = Workflow.load(target) + bundles.verify_integrity(reloaded, target, reloaded.manifest) + + +def test_model_validate_dump_does_not_recover_verified_load_capability(tmp_path): + bundle = saved_bundle(tmp_path) + loaded = Workflow.load(bundle) + clone = Workflow.model_validate(loaded.model_dump()) + + with pytest.raises(bundles.BundleIntegrityError, match="non-serializable"): + clone.save(bundle) + + +def test_authorization_display_paths_verify_bundle_before_reading(tmp_path): + bundle = saved_bundle(tmp_path) + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw["name"] = "tampered display input" + (bundle / "workflow.json").write_text(json.dumps(raw)) + + from openadapt_flow.authorization_summary import _covered_by_digest, outline + + with pytest.raises(bundles.BundleIntegrityError): + outline(bundle) + report = bundle / "QUALIFICATION.md" + report.write_text("synthetic report") + assert not _covered_by_digest(bundle, report) + + +def test_verified_load_can_save_as_and_carries_its_assets(tmp_path): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + target = tmp_path / "save-as" + + loaded.save(target) + + assert (target / "templates/button.png").read_bytes() == ( + source / "templates/button.png" + ).read_bytes() + reloaded = Workflow.load(target) + bundles.verify_integrity(reloaded, target, reloaded.manifest) + + def test_loaded_identity_refuses_replaced_asset_hashes_or_missing_manifest(tmp_path): bundle = saved_bundle(tmp_path) loaded = Workflow.load(bundle) diff --git a/packages/flow/tests/test_bundle_schema_v2.py b/packages/flow/tests/test_bundle_schema_v2.py index ae254c499e..e216484377 100644 --- a/packages/flow/tests/test_bundle_schema_v2.py +++ b/packages/flow/tests/test_bundle_schema_v2.py @@ -318,8 +318,7 @@ def test_fresh_workflow_defaults_to_v2(): def test_v1_bundle_loads_and_migrates(tmp_path): - """A raw v1 workflow.json (schema_version 1, no manifest) loads, migrates to - v2 in memory, and gets a manifest computed on read -- without breaking.""" + """Legacy bundles require explicit migration before they can be resealed.""" b = _write_bundle_dir(tmp_path) (b / "workflow.json").write_text( json.dumps( @@ -341,12 +340,17 @@ def test_v1_bundle_loads_and_migrates(tmp_path): } ) ) - wf = Workflow.load(b) + with pytest.raises(bv.BundleIntegrityError, match="no persisted integrity seal"): + Workflow.load(b) + + wf = Workflow.load(b, allow_unsealed_legacy=True) assert wf.schema_version == 2 # migrated on read assert wf.name == "legacy" assert wf.manifest is not None # computed because absent assert wf.manifest.content_digest # non-empty assert "templates/btn.png" in wf.manifest.file_hashes + wf.save(b) + assert Workflow.load(b).manifest.content_digest == wf.manifest.content_digest def test_migrate_bundle_dict_is_additive_noop_on_fields(): diff --git a/packages/flow/tests/test_business_decision.py b/packages/flow/tests/test_business_decision.py index b99c56d161..09227d6de6 100644 --- a/packages/flow/tests/test_business_decision.py +++ b/packages/flow/tests/test_business_decision.py @@ -301,6 +301,27 @@ def test_qualification_authoring_adds_one_typed_decision_and_advances_revision() assert authored.qualification.last_certification is None +def test_business_decision_authoring_preserves_verified_source_for_save(tmp_path): + workflow = _authoring_workflow() + init_project(workflow, environment=_qualification_environment()) + bundle = tmp_path / "bundle" + workflow.save(bundle) + verified = Workflow.load(bundle) + + authored = set_business_decision( + verified, + graph_id="__program__", + state_id="review", + decision=_authoring_spec(), + insert_before_state_id="accept", + ) + authored.save(bundle) + + reloaded = Workflow.load(bundle) + assert reloaded.program is not None + assert "review" in reloaded.program.states + + def test_qualification_cli_authors_and_inspects_one_exact_decision( tmp_path: Path, capsys: pytest.CaptureFixture[str], diff --git a/packages/flow/tests/test_qualification_proposal.py b/packages/flow/tests/test_qualification_proposal.py index 78c0b77bf9..25e58e073f 100644 --- a/packages/flow/tests/test_qualification_proposal.py +++ b/packages/flow/tests/test_qualification_proposal.py @@ -16,6 +16,7 @@ from PIL import Image from openadapt_flow.__main__ import main +from openadapt_flow.bundle_validation import BundleIntegrityError from openadapt_flow.ir import ( ActionKind, Anchor, @@ -219,7 +220,9 @@ def test_local_dev_admission_requires_a_verified_persisted_seal( raw.pop("manifest") (bundle / "workflow.json").write_text(json.dumps(raw), encoding="utf-8") (bundle / "manifest.json").unlink() - workflow = Workflow.load(bundle) + with pytest.raises(BundleIntegrityError, match="no persisted integrity seal"): + Workflow.load(bundle) + workflow = Workflow.load(bundle, allow_unsealed_legacy=True) proposal = propose_qualification(workflow, recording_dir=recording) accepted = accept_proposal(workflow, proposal) diff --git a/packages/flow/tests/test_sanitized_artifact.py b/packages/flow/tests/test_sanitized_artifact.py index e4ab761b5e..691d182a8f 100644 --- a/packages/flow/tests/test_sanitized_artifact.py +++ b/packages/flow/tests/test_sanitized_artifact.py @@ -349,6 +349,7 @@ def test_phi_bearing_canonical_bundle_is_scrubbed_resealed_and_loadable(tmp_path ) workflow.save(bundle) (bundle / "workflow.py").write_text(render_workflow_py(workflow), encoding="utf-8") + source_digest = Workflow.load(bundle).manifest.content_digest destination = tmp_path / "sanitized" manifest = sanitize_artifact(bundle, destination, kind="bundle") @@ -362,6 +363,10 @@ def test_phi_bearing_canonical_bundle_is_scrubbed_resealed_and_loadable(tmp_path assert b"Jane Doe" not in text_bytes assert b"MRN-123" not in text_bytes assert Workflow.load(destination).steps[0].anchor is not None + assert ( + Workflow.load(destination).manifest.provenance.sanitized_from_digest + == source_digest + ) sanitized_hints = Workflow.load(destination).backend_hints assert sanitized_hints is not None assert sanitized_hints.rdp_window_title == "[PERSON] - Appointments" From ee005ad7265ff753182017c35dce4d697d3c604f Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 22:18:02 -0500 Subject: [PATCH 3/6] Fix verified loop derivative resealing Signed-off-by: abrichr --- .../openadapt_flow/compiler/loop_authoring.py | 5 ++--- packages/flow/tests/test_loop_authoring.py | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/flow/openadapt_flow/compiler/loop_authoring.py b/packages/flow/openadapt_flow/compiler/loop_authoring.py index 67f037b3f3..b4aa47e373 100644 --- a/packages/flow/openadapt_flow/compiler/loop_authoring.py +++ b/packages/flow/openadapt_flow/compiler/loop_authoring.py @@ -263,6 +263,8 @@ def author_data_driven_loop( }, ) + # Keep the verified source manifest until Workflow.save verifies the + # original bundle, then reseals the authored derivative. return body.model_copy( deep=True, update={ @@ -279,8 +281,5 @@ def author_data_driven_loop( ), ), }, - # Drop the source bundle's sealed manifest -- ``save`` reseals a - # fresh integrity/provenance manifest over the looped bundle. - "manifest": None, }, ) diff --git a/packages/flow/tests/test_loop_authoring.py b/packages/flow/tests/test_loop_authoring.py index bf7f5949bb..56c7eafefd 100644 --- a/packages/flow/tests/test_loop_authoring.py +++ b/packages/flow/tests/test_loop_authoring.py @@ -354,6 +354,28 @@ def test_authored_bundle_round_trips_through_save_load(dirs): assert len(reloaded.data_sources["worklist"].rows) == 2 +def test_verified_loaded_body_can_be_saved_as_loop_derivative(tmp_path): + from openadapt_flow import bundle_validation + + source = tmp_path / "source" + original = _typed_note_body() + original.save(source) + body = Workflow.load(source) + assert body.manifest is not None and body.manifest.content_digest + + looped = author_data_driven_loop(body, [{"note": "a"}]) + destination = tmp_path / "loop" + looped.save(destination) + + saved = Workflow.load(destination) + assert saved.manifest is not None + assert saved.manifest.content_digest != body.manifest.content_digest + assert saved.program is not None + assert len(saved.data_sources["worklist"].rows) == 1 + bundle_validation.verify_integrity(saved, destination, saved.manifest) + assert Workflow.load(source).program is None + + # =========================================================================== # 2. real-Replayer evidence: healthy multi-record run + poisoned-record HALT # =========================================================================== From 3af925d8cb094a7fa8fa4cc64b6c15d7dc8588c7 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 22:46:31 -0500 Subject: [PATCH 4/6] Add verified template replacement authoring Signed-off-by: abrichr --- packages/flow/openadapt_flow/ir.py | 166 ++++++++++++++---- .../openadapt_flow/qualification_proposal.py | 2 +- .../flow/tests/test_authorization_sign.py | 5 +- .../flow/tests/test_bundle_original_format.py | 121 ++++++++++++- packages/flow/tests/test_bundle_schema_v2.py | 13 +- .../flow/tests/test_qualification_proposal.py | 2 +- packages/flow/tests/test_standard_change.py | 8 +- 7 files changed, 260 insertions(+), 57 deletions(-) diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index e5188032b7..811551461a 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -3404,6 +3404,9 @@ class Workflow(BaseModel): # from ``model_dump`` / the content digest (a private attribute). The # resolver consumes a decrypted crop via :meth:`decrypted_template`. _decrypted_templates: dict[str, bytes] = PrivateAttr(default_factory=dict) + # Authorized in-memory edits to existing template assets. The verified + # source crop cache above remains unchanged until the destination is saved. + _pending_template_replacements: dict[str, bytes] = PrivateAttr(default_factory=dict) # Immutable source seal + upgraded-model fingerprint; never serialized. _loaded_seal: Any = PrivateAttr(default=None) # Process-local authority granted only after integrity verification or by @@ -3412,7 +3415,6 @@ class Workflow(BaseModel): # Explicitly unchecked loads remain un-saveable even if a caller attaches a # fresh, empty manifest by hand. _unverified_load: bool = PrivateAttr(default=False) - _legacy_migration: bool = PrivateAttr(default=False) def model_copy(self, *, update: Any = None, deep: bool = False) -> "Workflow": clone = super().model_copy(update=update, deep=deep) @@ -3487,6 +3489,91 @@ def decrypted_templates(self) -> dict[str, bytes]: -> PNG bytes); empty for a plaintext bundle.""" return dict(self._decrypted_templates) + def stage_verified_template_replacement(self, rel: str, data: bytes) -> None: + """Stage bytes for an existing template on a verified loaded bundle. + + The original bundle and its in-memory decrypted asset cache are never + changed here. ``save`` verifies the source again before applying the + staged bytes to the destination and sealing the new content. + """ + from openadapt_flow import bundle_validation as _bv + + if not isinstance(data, bytes): + raise _bv.BundleIntegrityError("template replacement data must be bytes") + if ( + self._unverified_load + or self.manifest is None + or not self.manifest.content_digest + or self._loaded_seal is None + or not _bv.has_verified_load_capability(self) + ): + raise _bv.BundleIntegrityError( + "template replacement requires a verified loaded bundle" + ) + if not isinstance(rel, str) or not rel.startswith("templates/"): + raise _bv.BundleIntegrityError( + "template replacement must name a manifest-listed template asset" + ) + source = self._loaded_seal.source_bundle_dir + source_asset = _bv.bundle_asset_path(source, rel) + source_has_asset = ( + rel in self._decrypted_templates + if self._loaded_seal.encrypted + else source_asset.is_file() + ) + if rel not in self.manifest.file_hashes or not source_has_asset: + raise _bv.BundleIntegrityError( + "template replacement must name an existing manifest-listed asset" + ) + _bv.verify_loaded_source_integrity( + self, + source, + self.manifest, + decrypted_assets=( + self._decrypted_templates if self._loaded_seal.encrypted else None + ), + ) + self._pending_template_replacements = { + **self._pending_template_replacements, + rel: bytes(data), + } + + def _checked_template_replacements(self) -> dict[str, bytes]: + """Return staged replacements only while their loaded source is valid.""" + from openadapt_flow import bundle_validation as _bv + + replacements = dict(self._pending_template_replacements) + if not replacements: + return {} + if ( + self._unverified_load + or self.manifest is None + or not self.manifest.content_digest + or self._loaded_seal is None + or not _bv.has_verified_load_capability(self) + ): + raise _bv.BundleIntegrityError( + "staged template replacements require a verified loaded bundle" + ) + for rel in replacements: + if not isinstance(rel, str) or not rel.startswith("templates/"): + raise _bv.BundleIntegrityError( + "template replacement must name a manifest-listed template asset" + ) + source_asset = _bv.bundle_asset_path( + self._loaded_seal.source_bundle_dir, rel + ) + source_has_asset = ( + rel in self._decrypted_templates + if self._loaded_seal.encrypted + else source_asset.is_file() + ) + if rel not in self.manifest.file_hashes or not source_has_asset: + raise _bv.BundleIntegrityError( + "template replacement must name an existing manifest-listed asset" + ) + return replacements + def _sync_disk_templates(self, bundle: Path) -> None: """Materialize any in-memory plaintext crops to disk (removing a stale ``.enc`` sibling) BEFORE the manifest is (re)sealed, so a re-save hashes @@ -3547,6 +3634,11 @@ def save( from openadapt_flow import bundle_validation as _bv _bv.bundle_schema_version({"schema_version": self.schema_version}) + template_replacements = self._checked_template_replacements() + if template_replacements and not seal_manifest: + raise _bv.BundleIntegrityError( + "template replacements require a newly sealed manifest" + ) if self._unverified_load: raise _bv.BundleIntegrityError( "an unchecked bundle load cannot be saved or resealed" @@ -3568,24 +3660,24 @@ def save( bundle = Path(bundle_dir) from openadapt_flow import sealed_set as _sealed + source_assets: dict[str, bytes] = {} if self._loaded_seal is not None: if self.manifest is None: raise _bv.BundleIntegrityError( "loaded bundle lost its original integrity manifest" ) + source = self._loaded_seal.source_bundle_dir _bv.verify_loaded_source_integrity( self, - bundle, + source, self.manifest, decrypted_assets=( self._decrypted_templates if self.encrypted else None ), ) - source = self._loaded_seal.source_bundle_dir if source != bundle.resolve() and not self._loaded_seal.encrypted: for rel, expected in self._loaded_seal.file_hashes: source_asset = _bv.bundle_asset_path(source, rel) - target_asset = _bv.bundle_asset_path(bundle, rel) if ( not source_asset.is_file() or _bv._sha256_file(source_asset) != expected @@ -3593,21 +3685,36 @@ def save( raise _bv.BundleIntegrityError( f"verified source asset {rel!r} changed before save-as" ) - if not target_asset.exists(): - target_asset.parent.mkdir(parents=True, exist_ok=True) - target_asset.write_bytes(source_asset.read_bytes()) + source_assets[rel] = source_asset.read_bytes() if _sealed.active_writer(bundle) is not None or _sealed.is_sealed(bundle): # A sealed bundle stays sealed whatever ``encrypt`` says: many # commands re-save a bundle with a bare ``save(bundle)``, and that # must never turn a sealed bundle back into plaintext. - return self._save_sealed_set(bundle, key, seal_manifest=seal_manifest) + path = self._save_sealed_set( + bundle, + key, + seal_manifest=seal_manifest, + source_assets=source_assets, + template_replacements=template_replacements, + ) + self._pending_template_replacements = {} + return path bundle.mkdir(parents=True, exist_ok=True) (bundle / "templates").mkdir(exist_ok=True) + for rel, data in source_assets.items(): + target_asset = _bv.bundle_asset_path(bundle, rel) + if not target_asset.exists(): + target_asset.parent.mkdir(parents=True, exist_ok=True) + target_asset.write_bytes(data) # Restore any in-memory crops (from a prior encrypted load/save) to disk # as plaintext BEFORE the manifest hashes them; harmless no-op for a # freshly-compiled bundle whose crops are already on disk. self._sync_disk_templates(bundle) + for rel, data in template_replacements.items(): + target_asset = _bv.bundle_asset_path(bundle, rel) + target_asset.parent.mkdir(parents=True, exist_ok=True) + target_asset.write_bytes(data) if self.schema_version < SCHEMA_VERSION: self.schema_version = SCHEMA_VERSION # Reflect the at-rest state in the workflow BEFORE the manifest is sealed, @@ -3661,10 +3768,17 @@ def save( self.manifest, source_bundle_dir=bundle, ) + self._pending_template_replacements = {} return path def _save_sealed_set( - self, bundle: Path, key: Optional[str], *, seal_manifest: bool + self, + bundle: Path, + key: Optional[str], + *, + seal_manifest: bool, + source_assets: Optional[dict[str, bytes]] = None, + template_replacements: Optional[dict[str, bytes]] = None, ) -> Path: """Save into a sealed bundle (see :mod:`openadapt_flow.sealed_set`). @@ -3684,10 +3798,18 @@ def _save_sealed_set( ) writer = _sealed.SealedSetWriter(bundle, "bundle", passphrase, resume=True) assets = {rel: writer.read_bytes(rel) for rel in writer.files("templates/")} + for rel, data in (source_assets or {}).items(): + if assets.get(rel) != data: + writer.write(rel, data) + assets[rel] = data for rel, data in self._decrypted_templates.items(): if assets.get(rel) != data: writer.write(rel, data) assets[rel] = data + for rel, data in (template_replacements or {}).items(): + if assets.get(rel) != data: + writer.write(rel, data) + assets[rel] = data if self.schema_version < SCHEMA_VERSION: self.schema_version = SCHEMA_VERSION self.encrypted = True @@ -3727,7 +3849,6 @@ def load( *, validate: bool = True, verify_integrity: bool = True, - allow_unsealed_legacy: bool = False, key: Optional[str] = None, ) -> "Workflow": """Load a bundle, migrating v1 -> v2, validating structure, and (for a @@ -3742,8 +3863,8 @@ def load( uncertified-but-well-formed bundle still loads. - ``verify_integrity`` (default True): recompute a persisted sealed digest and reject tampered content. Bundles without a persisted - digest are refused unless ``allow_unsealed_legacy=True`` is supplied - by a caller intentionally migrating a legacy artifact. + digest are refused by default. ``verify_integrity=False`` permits + read-only inspection without granting verified-source capability. - ``key`` (default None): decryption passphrase for an ENCRYPTED bundle (one saved with ``save(encrypt=True)``, present on disk as ``workflow.json.enc`` + ``templates/*.png.enc``). Resolved from ``key`` @@ -3803,11 +3924,7 @@ def load( else _bv.source_canonical_payload(raw, stored.file_hashes) ) if verify_integrity and (stored is None or not stored.content_digest): - if not allow_unsealed_legacy: - raise _bv.BundleIntegrityError( - "bundle has no persisted integrity seal; pass " - "allow_unsealed_legacy=True only for intentional legacy migration" - ) + raise _bv.BundleIntegrityError("bundle has no persisted integrity seal") wf = cls.model_validate(_bv.migrate_bundle_dict(raw)) wf._unverified_load = not verify_integrity if stored is not None: @@ -3824,21 +3941,6 @@ def load( # them via ``decrypted_template``; nothing cleartext lands on disk. wf._decrypted_templates = _decrypt_template_assets(bundle, key) - if original_payload is None and verify_integrity and allow_unsealed_legacy: - # Explicit legacy migration creates a new seal only after the - # caller accepted that the source had no integrity claim. For an - # encrypted bundle, hash the decrypted plaintext assets under - # their original names rather than the on-disk ciphertext files. - migration_assets = wf._decrypted_templates if bundle_encrypted else None - wf.manifest = _bv.build_manifest(wf, bundle, assets=migration_assets) - wf._legacy_migration = True - original_payload = _bv.source_canonical_payload( - wf.model_dump(mode="json"), wf.manifest.file_hashes - ) - _bv.remember_original_content( - wf, original_payload, wf.manifest, source_bundle_dir=bundle - ) - if verify_integrity and persisted is not None and wf.manifest is not None: if bundle_encrypted: # On-disk crops are ciphertext, so verify the sealed asset diff --git a/packages/flow/openadapt_flow/qualification_proposal.py b/packages/flow/openadapt_flow/qualification_proposal.py index f8da87b3b9..d934fd22d8 100644 --- a/packages/flow/openadapt_flow/qualification_proposal.py +++ b/packages/flow/openadapt_flow/qualification_proposal.py @@ -433,7 +433,7 @@ def admit_local_dev( raise QualificationProposalError( "initialize qualification before local-dev admission" ) - if workflow._unverified_load or workflow._legacy_migration: + if workflow._unverified_load: raise QualificationProposalError( "local-dev admission requires a verified persisted bundle seal" ) diff --git a/packages/flow/tests/test_authorization_sign.py b/packages/flow/tests/test_authorization_sign.py index 5f001bc3fb..057030d7bf 100644 --- a/packages/flow/tests/test_authorization_sign.py +++ b/packages/flow/tests/test_authorization_sign.py @@ -89,7 +89,10 @@ def _bundle(root: Path) -> Path: ), ] wf = Workflow(name="clinic-intake-referral", steps=steps) - (bundle / "workflow.json").write_text(wf.model_dump_json(), encoding="utf-8") + wf.save(bundle) + verified = Workflow.load(bundle) + assert verified._loaded_seal is not None + assert verified.manifest is not None and verified.manifest.content_digest (bundle.parent / "QUALIFICATION.md").write_text(QUALIFICATION, encoding="utf-8") return bundle diff --git a/packages/flow/tests/test_bundle_original_format.py b/packages/flow/tests/test_bundle_original_format.py index 10aa063990..6bf70d0854 100644 --- a/packages/flow/tests/test_bundle_original_format.py +++ b/packages/flow/tests/test_bundle_original_format.py @@ -9,6 +9,8 @@ from PIL import Image from openadapt_flow import bundle_validation as bundles +from openadapt_flow import crypto, sealed_set +from openadapt_flow.bundle_sealing import seal_bundle from openadapt_flow.ir import ActionKind, Anchor, Step, Workflow @@ -209,6 +211,113 @@ def test_loaded_asset_tamper_refuses(tmp_path): bundles.verify_integrity(loaded, bundle, loaded.manifest) +def _bundle_files(bundle): + return { + path.relative_to(bundle): path.read_bytes() + for path in bundle.rglob("*") + if path.is_file() + } + + +def test_verified_template_replacement_saves_plaintext_derivative(tmp_path): + source = saved_bundle(tmp_path) + original_asset = (source / "templates/button.png").read_bytes() + replacement = b"replacement crop bytes" + loaded = Workflow.load(source) + derivative = loaded.model_copy() + derivative.stage_verified_template_replacement("templates/button.png", replacement) + + assert (source / "templates/button.png").read_bytes() == original_asset + assert "_pending_template_replacements" not in derivative.model_dump() + derivative.save(tmp_path / "derivative") + loaded.save(tmp_path / "unchanged") + + assert (source / "templates/button.png").read_bytes() == original_asset + assert (tmp_path / "derivative/templates/button.png").read_bytes() == replacement + assert (tmp_path / "unchanged/templates/button.png").read_bytes() == original_asset + assert Workflow.load(tmp_path / "derivative").manifest.content_digest + + +def test_verified_template_replacement_saves_encrypted_derivative( + tmp_path, monkeypatch +): + monkeypatch.setenv(crypto.ENV_KEY, "correct horse battery staple") + source = saved_bundle(tmp_path) + encrypted_source = tmp_path / "encrypted-source" + seal_bundle(source, encrypted_source) + source_snapshot = _bundle_files(encrypted_source) + replacement = b"encrypted replacement crop bytes" + loaded = Workflow.load(encrypted_source) + loaded.stage_verified_template_replacement("templates/button.png", replacement) + + destination = tmp_path / "encrypted-derivative" + with sealed_set.writing(destination, "bundle", "correct horse battery staple"): + loaded.save(destination, key="correct horse battery staple") + + assert _bundle_files(encrypted_source) == source_snapshot + assert not (destination / "templates/button.png").exists() + sealed_set.open_set(destination, "correct horse battery staple").verify_all() + reopened = Workflow.load(destination, key="correct horse battery staple") + assert reopened.decrypted_template("templates/button.png") == replacement + assert reopened.manifest.content_digest + + +def test_staged_template_replacement_refuses_changed_source(tmp_path): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + loaded.stage_verified_template_replacement("templates/button.png", b"replacement") + (source / "templates/button.png").write_bytes(b"tampered after staging") + + destination = tmp_path / "must-not-be-created" + with pytest.raises(bundles.BundleIntegrityError): + loaded.save(destination) + assert not destination.exists() + + +@pytest.mark.parametrize("data", [7, "replacement", bytearray(b"replacement")]) +def test_template_replacement_requires_bytes(tmp_path, data): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + with pytest.raises(bundles.BundleIntegrityError, match="data must be bytes"): + loaded.stage_verified_template_replacement("templates/button.png", data) + + +def test_template_replacement_refuses_save_without_new_seal(tmp_path): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + loaded.stage_verified_template_replacement("templates/button.png", b"replacement") + destination = tmp_path / "must-not-be-created" + + with pytest.raises( + bundles.BundleIntegrityError, match="require a newly sealed manifest" + ): + loaded.save(destination, seal_manifest=False) + assert not destination.exists() + + +@pytest.mark.parametrize( + "rel", ["workflow.json", "templates/missing.png", "templates/../workflow.json"] +) +def test_template_replacement_requires_existing_safe_template(tmp_path, rel): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + with pytest.raises(bundles.BundleIntegrityError): + loaded.stage_verified_template_replacement(rel, b"replacement") + + +def test_template_replacement_refuses_new_and_unchecked_workflows(tmp_path): + source = saved_bundle(tmp_path) + with pytest.raises(bundles.BundleIntegrityError): + Workflow(name="new", steps=[]).stage_verified_template_replacement( + "templates/button.png", b"replacement" + ) + unchecked = Workflow.load(source, verify_integrity=False) + with pytest.raises(bundles.BundleIntegrityError): + unchecked.stage_verified_template_replacement( + "templates/button.png", b"replacement" + ) + + def test_loaded_serialization_requires_explicit_reseal(tmp_path): bundle = saved_bundle(tmp_path) loaded = Workflow.load(bundle) @@ -248,6 +357,8 @@ def test_unverified_load_cannot_launder_a_broken_original_seal(tmp_path): loaded = Workflow.load(bundle, verify_integrity=False) with pytest.raises(bundles.BundleIntegrityError): bundles.verify_integrity(loaded, bundle, loaded.manifest) + with pytest.raises(bundles.BundleIntegrityError, match="unchecked bundle load"): + loaded.save(bundle) with pytest.raises(bundles.BundleIntegrityError): bundles.content_identity_digest(loaded, loaded.manifest.file_hashes) @@ -296,7 +407,7 @@ def test_authorization_summary_refuses_manifestless_bundle(tmp_path): bundle_digest(bundle) -def test_missing_seal_requires_explicit_legacy_migration_opt_in(tmp_path): +def test_missing_seal_cannot_be_migrated_by_loading_and_resaving(tmp_path): bundle = saved_bundle(tmp_path) raw = json.loads((bundle / "workflow.json").read_bytes()) raw.pop("manifest") @@ -308,14 +419,6 @@ def test_missing_seal_requires_explicit_legacy_migration_opt_in(tmp_path): ): Workflow.load(bundle) - loaded = Workflow.load(bundle, allow_unsealed_legacy=True) - assert loaded.manifest is not None - assert loaded.manifest.content_digest - target = tmp_path / "migrated" - loaded.save(target) - reloaded = Workflow.load(target) - bundles.verify_integrity(reloaded, target, reloaded.manifest) - def test_model_validate_dump_does_not_recover_verified_load_capability(tmp_path): bundle = saved_bundle(tmp_path) diff --git a/packages/flow/tests/test_bundle_schema_v2.py b/packages/flow/tests/test_bundle_schema_v2.py index e216484377..b7183e428b 100644 --- a/packages/flow/tests/test_bundle_schema_v2.py +++ b/packages/flow/tests/test_bundle_schema_v2.py @@ -317,8 +317,8 @@ def test_fresh_workflow_defaults_to_v2(): assert Workflow(name="x").schema_version == 2 -def test_v1_bundle_loads_and_migrates(tmp_path): - """Legacy bundles require explicit migration before they can be resealed.""" +def test_v1_bundle_without_a_seal_is_refused(tmp_path): + """Legacy bundles without a persisted seal are refused closed.""" b = _write_bundle_dir(tmp_path) (b / "workflow.json").write_text( json.dumps( @@ -343,15 +343,6 @@ def test_v1_bundle_loads_and_migrates(tmp_path): with pytest.raises(bv.BundleIntegrityError, match="no persisted integrity seal"): Workflow.load(b) - wf = Workflow.load(b, allow_unsealed_legacy=True) - assert wf.schema_version == 2 # migrated on read - assert wf.name == "legacy" - assert wf.manifest is not None # computed because absent - assert wf.manifest.content_digest # non-empty - assert "templates/btn.png" in wf.manifest.file_hashes - wf.save(b) - assert Workflow.load(b).manifest.content_digest == wf.manifest.content_digest - def test_migrate_bundle_dict_is_additive_noop_on_fields(): raw = {"name": "x", "steps": []} # no schema_version at all -> treated as v1 diff --git a/packages/flow/tests/test_qualification_proposal.py b/packages/flow/tests/test_qualification_proposal.py index 25e58e073f..fb3d8e50b1 100644 --- a/packages/flow/tests/test_qualification_proposal.py +++ b/packages/flow/tests/test_qualification_proposal.py @@ -222,7 +222,7 @@ def test_local_dev_admission_requires_a_verified_persisted_seal( (bundle / "manifest.json").unlink() with pytest.raises(BundleIntegrityError, match="no persisted integrity seal"): Workflow.load(bundle) - workflow = Workflow.load(bundle, allow_unsealed_legacy=True) + workflow = Workflow.load(bundle, verify_integrity=False) proposal = propose_qualification(workflow, recording_dir=recording) accepted = accept_proposal(workflow, proposal) diff --git a/packages/flow/tests/test_standard_change.py b/packages/flow/tests/test_standard_change.py index 546e662263..25766acd15 100644 --- a/packages/flow/tests/test_standard_change.py +++ b/packages/flow/tests/test_standard_change.py @@ -517,7 +517,9 @@ def test_a_changed_save_template_goes_to_a_person(mockmed, tmp_path) -> None: def edit(workflow: Workflow) -> None: _relabel(workflow) - (out / template).write_bytes((out / template).read_bytes() + b"\0") + workflow.stage_verified_template_replacement( + template, (out / template).read_bytes() + b"\0" + ) patched = _copy(bundle, out, edit) verdict = classify_change( @@ -900,7 +902,9 @@ def test_a_changed_file_of_another_step_goes_to_a_person(mockmed, tmp_path) -> N def edit(workflow: Workflow) -> None: _relabel(workflow) - (out / template).write_bytes((out / template).read_bytes() + b"\0") + workflow.stage_verified_template_replacement( + template, (out / template).read_bytes() + b"\0" + ) patched = _copy(bundle, out, edit) verdict = classify_change( From ec1b306e6a4c88a100d2f590dd2c0bcb73ff675f Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 23:16:34 -0500 Subject: [PATCH 5/6] Defer optional MCP transport until serving starts Signed-off-by: abrichr --- packages/flow/openadapt_flow/agent/cli.py | 6 +- .../flow/tests/test_agent_serve_imports.py | 133 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 packages/flow/tests/test_agent_serve_imports.py diff --git a/packages/flow/openadapt_flow/agent/cli.py b/packages/flow/openadapt_flow/agent/cli.py index caf1d279a4..66e9f6c048 100644 --- a/packages/flow/openadapt_flow/agent/cli.py +++ b/packages/flow/openadapt_flow/agent/cli.py @@ -766,10 +766,14 @@ def _legacy_runs_dir(args: argparse.Namespace) -> Optional[Path]: def _cmd_serve(args: argparse.Namespace) -> int: from openadapt_flow.agent.bridge import AgentBridge from openadapt_flow.agent.flow_service import open_attended_service - from openadapt_flow.agent.mcp import serve from openadapt_flow.agent.runner import default_flow_cli from openadapt_flow.agent.tutorial import TutorialError + def serve(*bridges: Any, **options: Any) -> None: + from openadapt_flow.agent.mcp import serve as serve_mcp + + serve_mcp(*bridges, **options) + if args.guard: return _serve_guard(args, serve) if args.verify: diff --git a/packages/flow/tests/test_agent_serve_imports.py b/packages/flow/tests/test_agent_serve_imports.py new file mode 100644 index 0000000000..cc4ef45477 --- /dev/null +++ b/packages/flow/tests/test_agent_serve_imports.py @@ -0,0 +1,133 @@ +"""Invalid serve modes need no optional transport; valid modes still delegate.""" + +from __future__ import annotations + +import builtins +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +from openadapt_flow.agent.cli import main + + +@pytest.mark.parametrize( + "argv", + [ + ["serve", "--teach-browser", "--url", "http://127.0.0.1:1"], + ["serve", "--teach-browser", "--bundles", "x"], + ["serve", "--tools-dir", "x"], + ], +) +def test_invalid_modes_never_attempt_optional_transport_import( + monkeypatch: pytest.MonkeyPatch, capsys: Any, argv: list[str] +) -> None: + original = builtins.__import__ + attempts: list[str] = [] + + def without_mcp(name: str, *args: Any, **kwargs: Any) -> Any: + if ( + name == "openadapt_flow.agent.mcp" + or name == "mcp" + or name.startswith("mcp.") + ): + attempts.append(name) + raise ModuleNotFoundError("synthetic optional transport unavailable") + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", without_mcp) + assert main(argv) == 2 + assert "--teach-browser" in capsys.readouterr().err + assert attempts == [] + + +@pytest.fixture() +def browser_bridge(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> tuple: + events: list[str] = [] + instances: list[Any] = [] + + class BrowserBridge: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + self.programs = {} + self.tools_dir = tmp_path / "tools" + self.files_dir = tmp_path / "files" + instances.append(self) + events.append("bridge") + + def close(self) -> None: + events.append("closed") + + monkeypatch.setattr( + "openadapt_flow.agent.browser_teach.BrowserTeachBridge", BrowserBridge + ) + return events, instances + + +def _serve_args(tmp_path: Path) -> list[str]: + return [ + "serve", + "--teach-browser", + "--tools-dir", + str(tmp_path / "tools"), + "--files-dir", + str(tmp_path / "files"), + ] + + +def test_valid_mode_delegates_exact_bridges_to_transport( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, browser_bridge: tuple +) -> None: + events, instances = browser_bridge + calls: list[tuple] = [] + transport = ModuleType("openadapt_flow.agent.mcp") + + def serve(*bridges: Any, **options: Any) -> None: + events.append("transport") + calls.append((bridges, options)) + + transport.serve = serve + monkeypatch.setitem(sys.modules, transport.__name__, transport) + assert main(_serve_args(tmp_path)) == 0 + assert calls == [((None, None, None, instances[0]), {})] + assert events == ["bridge", "transport", "closed"] + + +def test_valid_mode_preserves_missing_transport_failure_and_closes_bridge( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, browser_bridge: tuple +) -> None: + events, _instances = browser_bridge + original = builtins.__import__ + + def without_mcp(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "openadapt_flow.agent.mcp": + events.append("import") + raise ModuleNotFoundError("synthetic optional transport unavailable") + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", without_mcp) + with pytest.raises( + ModuleNotFoundError, match="synthetic optional transport unavailable" + ): + main(_serve_args(tmp_path)) + assert events == ["bridge", "import", "closed"] + + +def test_valid_mode_preserves_transport_error_and_closes_bridge( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, browser_bridge: tuple +) -> None: + events, instances = browser_bridge + transport = ModuleType("openadapt_flow.agent.mcp") + + def serve(*bridges: Any, **options: Any) -> None: + assert bridges == (None, None, None, instances[0]) + assert options == {} + events.append("transport") + raise RuntimeError("synthetic transport failure") + + transport.serve = serve + monkeypatch.setitem(sys.modules, transport.__name__, transport) + with pytest.raises(RuntimeError, match="synthetic transport failure"): + main(_serve_args(tmp_path)) + assert events == ["bridge", "transport", "closed"] From 4ba8dd35bb172e25b84d20eea0e32e00f0a6f8bd Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 6 Nov 2026 00:02:13 -0500 Subject: [PATCH 6/6] fix: regenerate synthetic showcase with current bundle manifest Signed-off-by: abrichr --- .../showcase-openemr/bundle/manifest.json | 47 + .../showcase-openemr/bundle/sections.json | 139 ++ .../bundle/templates/identifiers/step_005.png | Bin 0 -> 27538 bytes .../bundle/templates/identifiers/step_008.png | Bin 0 -> 7647 bytes .../bundle/templates/identifiers/step_014.png | Bin 0 -> 8644 bytes .../bundle/templates/layout_fingerprint.json | 1373 +++++++++++++++++ .../showcase-openemr/bundle/workflow.json | 753 +++++++-- .../docs/showcase-openemr/bundle/workflow.py | 14 +- 8 files changed, 2220 insertions(+), 106 deletions(-) create mode 100644 packages/flow/docs/showcase-openemr/bundle/manifest.json create mode 100644 packages/flow/docs/showcase-openemr/bundle/sections.json create mode 100644 packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_005.png create mode 100644 packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_008.png create mode 100644 packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_014.png create mode 100644 packages/flow/docs/showcase-openemr/bundle/templates/layout_fingerprint.json diff --git a/packages/flow/docs/showcase-openemr/bundle/manifest.json b/packages/flow/docs/showcase-openemr/bundle/manifest.json new file mode 100644 index 0000000000..0078685fb1 --- /dev/null +++ b/packages/flow/docs/showcase-openemr/bundle/manifest.json @@ -0,0 +1,47 @@ +{ + "schema_version": 2, + "content_digest": "6b41860d6e3b78cdba8511ffceabd0aeb11c6571ea870820b8f6cfa352aacfe3", + "file_hashes": { + "templates/identifiers/step_005.png": "ab6795dd012009449c7bc346ffec33db8a2c54c53cfbf47f5172b9d02e50f167", + "templates/identifiers/step_008.png": "8143294215bc1fc9fc09674c15e76c57b3949992e14b4d156f06c3daf2313c3f", + "templates/identifiers/step_014.png": "883eb0b5d10bf27fbf5b4a2892560787f89f811b06f00c0c9d6d15ec795a6afb", + "templates/layout_fingerprint.json": "240259c63576a467d974ea547f37b5c02ae955ff11e8ec5ffb9d88de2ef58446", + "templates/step_000.png": "f1828b7d1644fbe316c20d5e85904149f582bc851516a4f57ab87854e09d91f6", + "templates/step_000_expect.png": "a191ba28eaf2691ea2bdbde6043f87eeea0bda377ed1dbd92ed8f99e4645c38e", + "templates/step_001_expect.png": "a368fb929154c409069b8529c4159e95009de1cd90847ef8c9e6e7217383112e", + "templates/step_002.png": "edc6f6ff053c14c201f17cca8b46ae4b91b8e80858c203f8f47425552f4a2aea", + "templates/step_002_expect.png": "b588f9b3a2ac3c47e9f3b5482cb73e841bb1fceda2dcec1f25d76639c2d7885b", + "templates/step_003_expect.png": "5d0d7095f8556037dca2486a9f65b811afc778e56424c60596f2701e749d8d20", + "templates/step_004.png": "e92c4f9a9641f85ab09240761747f296a259514da6f0fa5dcb553f4cd24973c3", + "templates/step_005.png": "e21c6ca4622199cd03c4ec569d79992e275321dd633a532a8e95f9839692cd7c", + "templates/step_005_expect.png": "c5667abd0a4634138fe14c45fb9e9db24915730f9833b380a468c192b9705553", + "templates/step_006_expect.png": "f7e7bea1e80633fdfb6d59c0fe8a21e555258467771a4aae77e04e0ae5638237", + "templates/step_007_expect.png": "b00f01ef23bb84596f854e770197f88b4a573dc595dec10f4641d5853dfae8f6", + "templates/step_008.png": "daa4ed0af4be54f6c834124d428c3cc53782bf47f078e2aed3ac66b1821cc001", + "templates/step_008_expect.png": "eb616c03c24974233e5926db0ce325efd28ba09bf7d7294b2654182056a64001", + "templates/step_013.png": "44bcea01c29ffce089359a39defec0e9e54083af01410e9c4a3f7c0d25eb3cab", + "templates/step_013_expect.png": "49573416a7ebe7aff262d82ee44dd54ee21cb4a76ba99a07e666e46b5a721446", + "templates/step_014.png": "7feb81b1126ebf4da47b3d3cde66852d45df9c5c0de92271fd5967aa2191befb", + "templates/step_014_expect.png": "4b82d961d0ab7706325dc0aaae70e0432eb7f6130871cd9bbd48fe8b26a11774", + "templates/step_015.png": "a525c1b81d24176746463b435cfa870f27ae8851918ace8d667a91cbd429c58e", + "templates/step_015_expect.png": "5e7fba0ba58b1d27497f4a1148934be51886a92c94edaa50582ac3045642f74f", + "templates/step_017.png": "1952c2c78b8fbea7f97276e5ceb9a80a1f559deadca13ebb8e1526f272c8935f", + "templates/step_017_expect.png": "3bb8ad7cd2002f295c44a7d5e57a268d7dc2c712519bccb169cf8ca8f50392b7" + }, + "provenance": { + "compiler_version": "2.6.0", + "source_recording_sha256": null, + "sanitized_from_digest": null, + "compiler_config_sha256": "d3748d5c9304e9880719b005ec461ddfd6c1ad586139bb7ae870333099d7ae86", + "created_at": "2026-11-06T04:51:24.260972+00:00", + "policy_name": null, + "certified": false, + "certification_status": null, + "certified_at": null, + "expires_at": null, + "certification_invalidated_at": null, + "certification_invalidation_reason": null, + "governed_authorization_template": null + }, + "encrypted": false +} \ No newline at end of file diff --git a/packages/flow/docs/showcase-openemr/bundle/sections.json b/packages/flow/docs/showcase-openemr/bundle/sections.json new file mode 100644 index 0000000000..71d0fe9337 --- /dev/null +++ b/packages/flow/docs/showcase-openemr/bundle/sections.json @@ -0,0 +1,139 @@ +{ + "schema": "openadapt.flow.sections/v1", + "note": "Display metadata: named groups of steps for program maps. Execution and the outcome classifier ignore this file, and it is outside the content digest and the workflow contract hash.", + "workflow_contract_sha256": "93afd9d58a95ecbe1a09a99b822a110f7be535a182759887d1b6e9d3dc3beef8", + "sections": [ + { + "id": "fields-step_000", + "title": "1 field: text", + "title_source": "fields", + "kind": "fields", + "steps": [ + "step_000", + "step_001", + "step_002", + "step_003", + "step_004", + "step_005", + "step_006", + "step_007" + ], + "fields": [ + "text" + ], + "params": [] + }, + { + "id": "save-step_008", + "title": "+ Add", + "title_source": "ui", + "kind": "save", + "steps": [ + "step_008", + "step_009", + "step_010", + "step_011", + "step_012", + "step_013", + "step_014" + ], + "fields": [], + "params": [] + }, + { + "id": "fields-step_015", + "title": "1 field: note", + "title_source": "fields", + "kind": "fields", + "steps": [ + "step_015", + "step_016" + ], + "fields": [ + "note" + ], + "params": [ + "note" + ] + }, + { + "id": "save-step_017", + "title": "Save as new message", + "title_source": "ui", + "kind": "save", + "steps": [ + "step_017" + ], + "fields": [], + "params": [] + } + ], + "compiled": [ + { + "id": "fields-step_000", + "title": "1 field: text", + "title_source": "fields", + "kind": "fields", + "steps": [ + "step_000", + "step_001", + "step_002", + "step_003", + "step_004", + "step_005", + "step_006", + "step_007" + ], + "fields": [ + "text" + ], + "params": [] + }, + { + "id": "save-step_008", + "title": "+ Add", + "title_source": "ui", + "kind": "save", + "steps": [ + "step_008", + "step_009", + "step_010", + "step_011", + "step_012", + "step_013", + "step_014" + ], + "fields": [], + "params": [] + }, + { + "id": "fields-step_015", + "title": "1 field: note", + "title_source": "fields", + "kind": "fields", + "steps": [ + "step_015", + "step_016" + ], + "fields": [ + "note" + ], + "params": [ + "note" + ] + }, + { + "id": "save-step_017", + "title": "Save as new message", + "title_source": "ui", + "kind": "save", + "steps": [ + "step_017" + ], + "fields": [], + "params": [] + } + ], + "edits": [], + "context": {} +} diff --git a/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_005.png b/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_005.png new file mode 100644 index 0000000000000000000000000000000000000000..00f680ad61b437886d5bee3bed50cf78aad1ece5 GIT binary patch literal 27538 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 literal 0 HcmV?d00001 diff --git a/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_008.png b/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_008.png new file mode 100644 index 0000000000000000000000000000000000000000..bf04a13952aacf1671e80586bb09fa649d7a5b47 GIT binary patch literal 7647 zcmb_hby!qeyWc|#0!mA(bPEnCDJjy8fV9%m4I_dGh#(-1APv&p5-K7f(%mIPcf(zx z-#Ons=iKi;_j&F=^USOrv-Z2+^{W-6tSEzvO^yvg5U!l8q$&iV-T>c;uu#C~*&H<; z1W{$mNs6nx!B(5x@AL(o^KT6Do2)R<(fMl(`#@# zmoc6lW=&r~gMlwUe|L!D2GU~vI?!ZzX2Fjp4r%Bb^nUfa13zsFuM4?#;}oaJhl>$WXYc@qLVfia@H} zmzktV`pahNp}2AkpH0QCW2x$Cj(@hMA){1P(Y#~sBIaC*opJ?@nd7~!Nd^98yc{N0 zY967K8Xa`5hsl4R=94$kgJs+_N(#PQbn7=FU(C!{L2w?S1Q||?b+_lOI0u>MXK#8KnISXxY(s5`F^&-)s)-Y`uPtAQ6NNYn(! zFoT}6x#5+LJY=+jsIE?)&Q0v>r%mrtBXTF-ei%znHI-$sQ5$V;Z^y}QrBYfSgxC3u z%lGy2(ygnGG2!cl3B8>tv~oMGiOJ5gk)6n>QlaLG<&24r+xM{?IrL*^Q}I3*7fy4x z^w9ND0~$R>)jN5f>U=Z`q4~~l zoU~ufOi6RHr3tEf3F}XbGBdxgC~?Gqt}Qv|O<#pJcAYW=RiTvhypgZpu0f{hgT>Ef zpvJ-8H%M6+#Q7mSq0IuKz0Scaa_bUAe`rzrLJ*poCw1Tvz=h~Oe)`PF08610wINl( zq9XjOh7LZ`LFz=4{o}>nwzg+; zI-b%dAz0u!t?^_e^zzEz>46mixg7?K-B(2YS527Olt!icj2!t}*;Zo11SL1zAVWuf zP#JbyGw^ARw-_5hwXG8P=Y2BX-Ivf?8E*{VoG=092ZjNkM^f`A?e?~Cs^bzd{&;;6$rT~lsH{%T`#-M5 zq99)G2o7w#s7tg~a9`8CWDjlp-%baeK|^kj3V}-S2A0Z6?gqu>Y)ZV>d=sVdKrK7dGnD7C{eL*Lzf9h#q{dMz=%(j#bg-!=wz2D< z-d99xCzQ~;!RT>3VR_XR6_NRQpyr++TleU`W=ItU^t<E0{8IT)2?`Vd~rDqy(I>7hwujQuU7)mz|jz3%@iCk8cNW(<2Xu ztE)F&91QtC=nHXaFo;3%E0Af;_+DyvEyA_lSJceNqRXqVDVupHGt;tc(Ap58@xWcl zw&iG|p;GtbIKuDD`;q}euGWexP|ey{<_6hMe+|Yy44B_j6iuouy^ObU;%(|=YfD`gv+cH84?lXK87j|31CNAtWT^7%$nqd)WEI4ZFx-RpD7u z9-Prxb4&s||NY$$H-E&DQABV*-aI++%9w`_5as@Eipr|0s`ZUHKtcPgTR#^GkI${r zB4Jw>+D++$QP*ejf}cnG)Cp&s-w+dT;k2(N`3TxQx<)y5iLAr7_p>!8OFjz|3r%t? z-7+DLg9W1bI#$p3K97ScXGQ7Mpj+3JSsny&bF03L_j{f>(9)Xk$V~tc7NI(PFY5Jc z<36Ha@Hu%L!OT)a4jmm$UfsE$NNIiQJvJq(qpf`~J(UwTtOH#kBFcWBjyl-f+>DQ@ z)YrcX~*^<4YI@>0SY }T!PWlPGs{cZte4M4j_NZTDuw#{5AURm)jx}{ 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 Qjax`gN>Q>v!qD%(00K_1(f|Me literal 0 HcmV?d00001 diff --git a/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_014.png b/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_014.png new file mode 100644 index 0000000000000000000000000000000000000000..5364f293988719436f343f5d135a0719d13e88c4 GIT binary patch literal 8644 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 literal 0 HcmV?d00001 diff --git a/packages/flow/docs/showcase-openemr/bundle/templates/layout_fingerprint.json b/packages/flow/docs/showcase-openemr/bundle/templates/layout_fingerprint.json new file mode 100644 index 0000000000..7efc0cd79e --- /dev/null +++ b/packages/flow/docs/showcase-openemr/bundle/templates/layout_fingerprint.json @@ -0,0 +1,1373 @@ +{ + "scale_landmarks": { + "salt": "bb3807cda1af2c351335c9a37ae817c9", + "steps": { + "step_000": [ + [ + "53a1804f7dbd272e", + 659.5, + 197.0, + 117, + 36 + ], + [ + "7b7c2bec3951786f", + 640.0, + 294.0, + 282, + 28 + ], + [ + "7d60863fb47efc3f", + 641.0, + 323.5, + 340, + 23 + ], + [ + "054335a855f21b42", + 640.0, + 354.5, + 278, + 23 + ], + [ + "7adb465e50e1788d", + 493.5, + 513.5, + 77, + 25 + ], + [ + "10936cd3d9d7275a", + 708.0, + 509.0, + 210, + 24 + ], + [ + "1da31ede23d9a4f4", + 640.0, + 567.0, + 48, + 24 + ], + [ + "220ab5efc43935cc", + 641.0, + 611.5, + 264, + 17 + ] + ], + "step_002": [ + [ + "53a1804f7dbd272e", + 659.5, + 197.0, + 117, + 36 + ], + [ + "7b7c2bec3951786f", + 640.0, + 294.0, + 282, + 28 + ], + [ + "7d60863fb47efc3f", + 641.0, + 323.5, + 340, + 23 + ], + [ + "054335a855f21b42", + 639.5, + 354.5, + 277, + 23 + ], + [ + "b015c5882dd86535", + 494.5, + 404.5, + 81, + 25 + ], + [ + "70f57380a411a4d8", + 625.0, + 404.0, + 50, + 22 + ], + [ + "7adb465e50e1788d", + 493.5, + 513.5, + 77, + 25 + ], + [ + "10936cd3d9d7275a", + 708.0, + 509.0, + 210, + 24 + ], + [ + "1da31ede23d9a4f4", + 640.0, + 567.0, + 48, + 24 + ], + [ + "220ab5efc43935cc", + 641.0, + 611.5, + 264, + 17 + ] + ], + "step_004": [ + [ + "53a1804f7dbd272e", + 659.5, + 197.0, + 117, + 36 + ], + [ + "7b7c2bec3951786f", + 640.0, + 294.0, + 282, + 28 + ], + [ + "7d60863fb47efc3f", + 641.0, + 323.5, + 340, + 23 + ], + [ + "054335a855f21b42", + 639.5, + 354.5, + 277, + 23 + ], + [ + "b015c5882dd86535", + 494.5, + 404.5, + 81, + 25 + ], + [ + "70f57380a411a4d8", + 624.5, + 404.0, + 51, + 22 + ], + [ + "657e119c8311a639", + 493.0, + 458.0, + 76, + 20 + ], + [ + "7adb465e50e1788d", + 493.0, + 513.5, + 78, + 25 + ], + [ + "10936cd3d9d7275a", + 708.5, + 510.0, + 207, + 20 + ], + [ + "1da31ede23d9a4f4", + 640.0, + 567.0, + 48, + 24 + ], + [ + "220ab5efc43935cc", + 641.0, + 611.5, + 264, + 17 + ] + ], + "step_005": [ + [ + "1677f5e2c754311c", + 544.5, + 31.0, + 991, + 22 + ], + [ + "7cd7b8195acab3ae", + 1121.5, + 32.0, + 153, + 22 + ], + [ + "80869c7ff9366539", + 69.0, + 126.5, + 64, + 17 + ], + [ + "956f52f28fd1c1e2", + 259.0, + 126.5, + 156, + 17 + ], + [ + "69f2e8851af4f2ce", + 517.0, + 170.5, + 274, + 29 + ], + [ + "0185b8f808058a34", + 1223.0, + 170.5, + 48, + 23 + ], + [ + "64f0aed699ebdb79", + 819.5, + 213.0, + 81, + 20 + ], + [ + "f32e4f3eda3a5e29", + 292.5, + 393.5, + 35, + 15 + ], + [ + "306e615b3acd952f", + 402.0, + 398.5, + 174, + 25 + ], + [ + "e0c114688c9dadbd", + 292.5, + 413.0, + 37, + 18 + ], + [ + "40e4bca9bc444d9b", + 47.5, + 425.5, + 73, + 21 + ], + [ + "170de2352353716a", + 292.0, + 433.0, + 38, + 18 + ], + [ + "8495f9b08dcfdb54", + 60.5, + 453.5, + 69, + 21 + ], + [ + "8b574777a644e62b", + 292.5, + 453.0, + 37, + 18 + ], + [ + "682a3db0e6b6fc3a", + 70.5, + 473.5, + 89, + 21 + ], + [ + "9fd9094faab8b3d3", + 292.5, + 473.0, + 37, + 18 + ], + [ + "a6ac9c15d4eda0a9", + 402.0, + 479.5, + 172, + 25 + ], + [ + "7ef3675f1a74d196", + 68.5, + 492.5, + 85, + 21 + ], + [ + "468103917a121ed6", + 292.5, + 493.5, + 37, + 19 + ], + [ + "4fe15e048482f544", + 71.0, + 511.5, + 90, + 21 + ], + [ + "9f2fa0ba578fb3ef", + 292.5, + 514.5, + 37, + 19 + ], + [ + "ffee08e56fdcb485", + 292.5, + 534.0, + 37, + 18 + ], + [ + "ed83e9ca9462e5da", + 292.5, + 554.0, + 37, + 18 + ], + [ + "492950966cf06387", + 378.5, + 558.0, + 127, + 22 + ], + [ + "bee99d679f35404b", + 111.0, + 576.5, + 90, + 23 + ], + [ + "cfb07034e7ada233", + 292.5, + 574.0, + 37, + 18 + ], + [ + "d48473165b31ca81", + 356.0, + 575.0, + 78, + 20 + ], + [ + "e37c384c207c6fbe", + 292.5, + 594.0, + 37, + 18 + ], + [ + "17a53554383d5b25", + 292.5, + 615.5, + 35, + 15 + ], + [ + "56510da7830225f6", + 292.5, + 635.5, + 39, + 17 + ], + [ + "755b381e21e23ecc", + 292.5, + 655.5, + 39, + 17 + ], + [ + "26ecb99ae5c1ce04", + 292.5, + 675.5, + 39, + 17 + ] + ], + "step_008": [ + [ + "1677f5e2c754311c", + 497.0, + 31.0, + 992, + 22 + ], + [ + "cabf57ed43dd0dfd", + 1005.0, + 31.0, + 36, + 18 + ], + [ + "b497fc64468ee106", + 142.5, + 126.5, + 281, + 17 + ], + [ + "4f2b5a9008c22648", + 383.0, + 126.5, + 128, + 17 + ], + [ + "c7dd287d91934bfd", + 54.5, + 191.5, + 109, + 23 + ], + [ + "042fbd9f2e640116", + 210.0, + 191.0, + 148, + 20 + ], + [ + "8671ff7cf8316860", + 32.5, + 256.0, + 65, + 26 + ], + [ + "6192e224859b4ebb", + 151.0, + 257.0, + 118, + 20 + ], + [ + "fa8df429b3a4f4fe", + 88.0, + 293.0, + 54, + 22 + ], + [ + "28929be4d4e862e1", + 1088.0, + 293.0, + 62, + 24 + ], + [ + "d327d2e506cd7cb9", + 54.0, + 346.0, + 108, + 20 + ], + [ + "a584933894add8d6", + 337.5, + 347.0, + 173, + 20 + ], + [ + "e7ffbcef37172935", + 569.5, + 345.5, + 115, + 23 + ], + [ + "48c5a443e3139ea6", + 863.0, + 346.0, + 168, + 20 + ], + [ + "a93939858215ef46", + 1120.5, + 346.0, + 157, + 20 + ], + [ + "0cb8400a1aae2ae0", + 27.5, + 399.0, + 55, + 24 + ], + [ + "f83063f16fd132a2", + 288.0, + 399.5, + 106, + 23 + ], + [ + "d2126cc4b4c4b150", + 815.0, + 399.5, + 104, + 23 + ], + [ + "f4bf983b8713beef", + 1072.0, + 399.5, + 88, + 21 + ], + [ + "b763bbc04b6c5a5f", + 31.5, + 443.5, + 63, + 21 + ], + [ + "6924139615e397e4", + 288.0, + 443.5, + 102, + 17 + ], + [ + "a6776f7d8b03b4e6", + 542.5, + 444.0, + 85, + 20 + ], + [ + "2487c0eed7cb17dd", + 808.0, + 444.0, + 86, + 20 + ], + [ + "4d2129a88f7a3447", + 192.5, + 482.5, + 385, + 23 + ], + [ + "245c7b8ccc09a1c8", + 182.0, + 521.0, + 362, + 20 + ], + [ + "0823239da9ef6759", + 1145.0, + 520.0, + 70, + 24 + ], + [ + "0559598e813a7649", + 1251.5, + 520.0, + 41, + 22 + ] + ], + "step_013": [ + [ + "1677f5e2c754311c", + 497.5, + 31.0, + 993, + 22 + ], + [ + "cabf57ed43dd0dfd", + 1005.5, + 31.5, + 35, + 15 + ], + [ + "6ae712120248d437", + 149.0, + 88.5, + 208, + 33 + ], + [ + "ce346f06146e6042", + 721.0, + 87.0, + 150, + 20 + ], + [ + "b91f05b117d4027d", + 140.0, + 126.0, + 188, + 20 + ], + [ + "196076afbb94116b", + 691.5, + 123.0, + 167, + 20 + ], + [ + "b07e907f5614d8fd", + 62.0, + 184.5, + 120, + 17 + ], + [ + "ec4c770f0b0e9abe", + 291.0, + 183.5, + 338, + 21 + ], + [ + "d1a90e829751aa69", + 546.0, + 184.5, + 106, + 17 + ], + [ + "2c95cf0317f658cc", + 721.5, + 185.0, + 145, + 20 + ], + [ + "2a1f3799edbb93c3", + 47.0, + 215.5, + 94, + 23 + ], + [ + "a49d0f73d0a258c7", + 477.5, + 216.0, + 167, + 20 + ], + [ + "82d1da02d03cc78f", + 935.0, + 235.5, + 138, + 23 + ], + [ + "d7984f36e8ad711d", + 48.0, + 248.0, + 96, + 24 + ], + [ + "54905652996bb3b5", + 902.5, + 282.0, + 49, + 26 + ], + [ + "09f0df70eb032fd3", + 24.5, + 299.5, + 49, + 21 + ], + [ + "bc1441321b8dc29b", + 36.5, + 369.5, + 73, + 31 + ], + [ + "c051f72945e356ba", + 253.0, + 424.5, + 506, + 19 + ], + [ + "49e69c2e8fe609ca", + 68.0, + 608.5, + 136, + 25 + ], + [ + "7ec328752484b51c", + 83.0, + 635.5, + 164, + 17 + ], + [ + "dedde2680264e1ed", + 44.0, + 681.5, + 88, + 27 + ], + [ + "723a1506fa904dd8", + 186.0, + 734.0, + 62, + 20 + ], + [ + "07458fc788a34e28", + 502.0, + 735.5, + 68, + 21 + ], + [ + "5a452bd749085368", + 277.0, + 761.5, + 554, + 21 + ] + ], + "step_014": [ + [ + "1677f5e2c754311c", + 497.0, + 31.0, + 992, + 22 + ], + [ + "cabf57ed43dd0dfd", + 1005.5, + 31.5, + 37, + 19 + ], + [ + "6ae712120248d437", + 149.0, + 88.5, + 208, + 33 + ], + [ + "ce346f06146e6042", + 721.5, + 87.5, + 151, + 25 + ], + [ + "b91f05b117d4027d", + 139.5, + 126.0, + 187, + 20 + ], + [ + "196076afbb94116b", + 691.5, + 123.0, + 167, + 20 + ], + [ + "aa21e82e662b79b9", + 58.0, + 184.0, + 114, + 18 + ], + [ + "bcd2c5c62281b1b1", + 220.0, + 184.0, + 190, + 22 + ], + [ + "4f2b5a9008c22648", + 378.5, + 184.0, + 131, + 22 + ], + [ + "028b32f23c157e38", + 536.0, + 184.5, + 86, + 17 + ], + [ + "2c95cf0317f658cc", + 729.0, + 185.0, + 146, + 20 + ], + [ + "58e84578636ab263", + 278.5, + 236.5, + 425, + 29 + ], + [ + "4bf92fd089f77839", + 204.0, + 281.5, + 134, + 23 + ], + [ + "d7f882ec077611a6", + 333.0, + 281.5, + 84, + 21 + ], + [ + "fa23e64b79fcf4fa", + 446.0, + 281.0, + 106, + 26 + ], + [ + "d5a523154c8f7de7", + 584.0, + 289.5, + 132, + 17 + ], + [ + "b4746fa52617f48f", + 111.5, + 377.5, + 51, + 21 + ], + [ + "96c259549419c5e4", + 205.5, + 376.5, + 103, + 23 + ], + [ + "db906066370a68c6", + 306.0, + 376.0, + 76, + 20 + ], + [ + "d99b3c17eafd995b", + 115.5, + 431.5, + 59, + 21 + ], + [ + "ccb0ed7897c7a2a3", + 256.0, + 431.0, + 52, + 24 + ], + [ + "8ee0d075242b8329", + 433.0, + 433.5, + 42, + 23 + ], + [ + "ec3c8e7c62a98d10", + 551.0, + 431.5, + 62, + 21 + ], + [ + "d076cc2712b27ebc", + 696.0, + 431.5, + 52, + 21 + ], + [ + "c47d214198902180", + 843.0, + 432.0, + 90, + 26 + ], + [ + "53a0aa59157a51e3", + 1037.5, + 432.0, + 77, + 24 + ] + ], + "step_015": [ + [ + "252c77e2758550e5", + 147.5, + 30.5, + 293, + 21 + ], + [ + "cabf57ed43dd0dfd", + 1010.0, + 31.5, + 30, + 19 + ], + [ + "cd8745403afc1970", + 433.5, + 48.0, + 77, + 24 + ], + [ + "6ae712120248d437", + 149.0, + 88.5, + 208, + 33 + ], + [ + "7e329305c7f5bb7d", + 453.0, + 94.5, + 140, + 25 + ], + [ + "b91f05b117d4027d", + 140.0, + 126.0, + 188, + 20 + ], + [ + "8ee0d075242b8329", + 406.5, + 144.5, + 51, + 27 + ], + [ + "aa21e82e662b79b9", + 58.5, + 184.5, + 115, + 17 + ], + [ + "dfa69328a1178e6a", + 204.0, + 185.0, + 158, + 20 + ], + [ + "40efc8161d928af4", + 445.0, + 182.0, + 92, + 24 + ], + [ + "404e38e1bab62c12", + 178.0, + 236.5, + 224, + 29 + ], + [ + "7ef3675f1a74d196", + 442.0, + 268.0, + 86, + 24 + ], + [ + "4bf92fd089f77839", + 203.5, + 281.5, + 133, + 25 + ], + [ + "b4746fa52617f48f", + 111.5, + 377.0, + 49, + 18 + ], + [ + "96c259549419c5e4", + 204.5, + 376.5, + 101, + 23 + ], + [ + "d99b3c17eafd995b", + 116.0, + 432.0, + 58, + 18 + ], + [ + "ccb0ed7897c7a2a3", + 257.0, + 431.0, + 52, + 24 + ], + [ + "53a0aa59157a51e3", + 1036.0, + 431.5, + 76, + 23 + ], + [ + "2e01bf8d564a620d", + 500.0, + 454.0, + 168, + 24 + ], + [ + "087a9545562fe021", + 947.0, + 577.0, + 44, + 22 + ] + ], + "step_017": [ + [ + "252c77e2758550e5", + 147.5, + 30.5, + 293, + 21 + ], + [ + "cabf57ed43dd0dfd", + 1010.0, + 31.5, + 30, + 19 + ], + [ + "cd8745403afc1970", + 433.5, + 48.0, + 77, + 24 + ], + [ + "6ae712120248d437", + 149.0, + 88.5, + 208, + 33 + ], + [ + "7e329305c7f5bb7d", + 453.0, + 95.0, + 140, + 24 + ], + [ + "b91f05b117d4027d", + 140.0, + 126.0, + 188, + 20 + ], + [ + "8ee0d075242b8329", + 406.5, + 144.0, + 49, + 22 + ], + [ + "aa21e82e662b79b9", + 58.5, + 184.5, + 115, + 17 + ], + [ + "dfa69328a1178e6a", + 204.0, + 185.0, + 158, + 20 + ], + [ + "40efc8161d928af4", + 445.0, + 182.0, + 92, + 24 + ], + [ + "404e38e1bab62c12", + 178.0, + 236.5, + 224, + 29 + ], + [ + "7ef3675f1a74d196", + 441.5, + 267.0, + 87, + 26 + ], + [ + "4bf92fd089f77839", + 203.5, + 281.5, + 133, + 25 + ], + [ + "b4746fa52617f48f", + 112.0, + 377.0, + 50, + 18 + ], + [ + "96c259549419c5e4", + 204.0, + 376.5, + 102, + 23 + ], + [ + "d99b3c17eafd995b", + 116.0, + 432.0, + 58, + 18 + ], + [ + "ccb0ed7897c7a2a3", + 256.5, + 431.0, + 53, + 24 + ], + [ + "53a0aa59157a51e3", + 1036.0, + 431.5, + 76, + 23 + ], + [ + "2e01bf8d564a620d", + 500.0, + 454.0, + 168, + 24 + ], + [ + "087a9545562fe021", + 947.0, + 577.0, + 44, + 22 + ] + ] + } + }, + "schema": "openadapt.layout-fingerprints/v1", + "steps": { + "step_000": { + "char_width": 9.875, + "landmarks": {}, + "lines": 12, + "profile_sha256": null, + "text_height": 24.0, + "viewport": [ + 1280, + 800 + ] + }, + "step_002": { + "char_width": 9.8125, + "landmarks": {}, + "lines": 12, + "profile_sha256": null, + "text_height": 23.5, + "viewport": [ + 1280, + 800 + ] + }, + "step_004": { + "char_width": 10.125, + "landmarks": {}, + "lines": 11, + "profile_sha256": null, + "text_height": 23, + "viewport": [ + 1280, + 800 + ] + }, + "step_005": { + "char_width": 7.8, + "landmarks": {}, + "lines": 48, + "profile_sha256": null, + "text_height": 18.0, + "viewport": [ + 1280, + 800 + ] + }, + "step_008": { + "char_width": 9.26388888888889, + "landmarks": {}, + "lines": 28, + "profile_sha256": null, + "text_height": 21.0, + "viewport": [ + 1280, + 800 + ] + }, + "step_013": { + "char_width": 9.831683168316832, + "landmarks": {}, + "lines": 27, + "profile_sha256": null, + "text_height": 21, + "viewport": [ + 1280, + 800 + ] + }, + "step_014": { + "char_width": 9.35, + "landmarks": {}, + "lines": 27, + "profile_sha256": null, + "text_height": 22, + "viewport": [ + 1280, + 800 + ] + }, + "step_015": { + "char_width": 9.5, + "landmarks": {}, + "lines": 23, + "profile_sha256": null, + "text_height": 23, + "viewport": [ + 1280, + 800 + ] + }, + "step_017": { + "char_width": 9.5, + "landmarks": {}, + "lines": 24, + "profile_sha256": null, + "text_height": 23.0, + "viewport": [ + 1280, + 800 + ] + } + } +} diff --git a/packages/flow/docs/showcase-openemr/bundle/workflow.json b/packages/flow/docs/showcase-openemr/bundle/workflow.json index a313c3ac7d..5d3f2621ce 100644 --- a/packages/flow/docs/showcase-openemr/bundle/workflow.json +++ b/packages/flow/docs/showcase-openemr/bundle/workflow.json @@ -1,11 +1,11 @@ { - "schema_version": 1, + "schema_version": 2, "name": "openemr-showcase", "recording_id": "50758ec282e94c4a9f99f69ab7415678", "contains_phi": false, "phi_scrubbed": false, "encrypted": false, - "created_at": "2026-07-14T00:51:06.482608+00:00", + "created_at": "2026-11-06T04:51:24.259258+00:00", "viewport": [ 1280, 800 @@ -47,6 +47,7 @@ "identity_template": null, "identifier_crop": null, "identifier_region": null, + "form_identity": null, "landmarks": [ { "relation": "left_of", @@ -68,7 +69,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -90,11 +96,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": false, - "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)" + "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)", + "identifier_crop_missing_reason": "identity is not armed on this step (see identity_unarmed_reason) — there is no identity evidence to crop", + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_001", @@ -104,7 +123,12 @@ "text": "admin", "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -135,11 +159,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_002", @@ -164,6 +201,7 @@ "identity_template": null, "identifier_crop": null, "identifier_region": null, + "form_identity": null, "landmarks": [ { "relation": "below", @@ -185,7 +223,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -207,11 +250,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": false, - "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)" + "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)", + "identifier_crop_missing_reason": "identity is not armed on this step (see identity_unarmed_reason) — there is no identity evidence to crop", + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_003", @@ -221,7 +277,12 @@ "text": "pass", "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -243,11 +304,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_004", @@ -272,6 +346,7 @@ "identity_template": null, "identifier_crop": null, "identifier_region": null, + "form_identity": null, "landmarks": [ { "relation": "below", @@ -293,7 +368,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -310,11 +390,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": false, - "identity_unarmed_reason": "the only readable row text is the target's own label (mutable evidence, excluded from identity)" + "identity_unarmed_reason": "the only readable row text is the target's own label (mutable evidence, excluded from identity)", + "identifier_crop_missing_reason": "identity is not armed on this step (see identity_unarmed_reason) — there is no identity evidence to crop", + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_005", @@ -338,12 +431,12 @@ "structured_identity": null, "identity_template": { "schema_version": 1, - "salt": "076a9e81b90d6ddda88595bd2dd19e90", - "band_len": 102, + "salt": "d87570c6fc466f1ecb27166c1678853c", + "band_len": 120, "tokens": [ { - "c": "f31da5157c6a4025", - "r": "54173ac4de425e17", + "c": "604fe9a6f95fafdc", + "r": "7efb5b032480dcef", "n": 101, "alpha": true, "name": true, @@ -353,8 +446,41 @@ "gen": false }, { - "c": "c908362266024318", - "r": "c908362266024318", + "c": "d5c99e759afd1d93", + "r": "d5c99e759afd1d93", + "n": 8, + "alpha": true, + "name": true, + "digit": false, + "idsh": false, + "glyph": false, + "gen": false + }, + { + "c": "8ddc1a63df6fb2c4", + "r": "8ddc1a63df6fb2c4", + "n": 3, + "alpha": true, + "name": true, + "digit": false, + "idsh": false, + "glyph": false, + "gen": false + }, + { + "c": "85e2b1bbd8f7ad80", + "r": "85e2b1bbd8f7ad80", + "n": 7, + "alpha": true, + "name": true, + "digit": false, + "idsh": false, + "glyph": false, + "gen": false + }, + { + "c": "04b52a257d1c57ba", + "r": "04b52a257d1c57ba", "n": 1, "alpha": true, "name": true, @@ -368,19 +494,115 @@ { "i": 0, "size": 2, - "c": "c6686c7b4153485a", - "r": "e9642945cf74a524", + "c": "32437f3d54107399", + "r": "e0407dfe77e42213", "digit": false, "name": true, - "n": 102 + "n": 109 + }, + { + "i": 0, + "size": 3, + "c": "5443d007cbe9ec72", + "r": "ef18332e11c267df", + "digit": false, + "name": true, + "n": 112 + }, + { + "i": 0, + "size": 4, + "c": "9c8baec45ba1d6dd", + "r": "e69647c80eb6b0f9", + "digit": false, + "name": true, + "n": 119 + }, + { + "i": 1, + "size": 2, + "c": "067be73eab766c11", + "r": "067be73eab766c11", + "digit": false, + "name": true, + "n": 11 + }, + { + "i": 1, + "size": 3, + "c": "7bd66617954ee928", + "r": "7bd66617954ee928", + "digit": false, + "name": true, + "n": 18 + }, + { + "i": 1, + "size": 4, + "c": "cd5694fae4015b86", + "r": "cd5694fae4015b86", + "digit": false, + "name": true, + "n": 19 + }, + { + "i": 2, + "size": 2, + "c": "6f828a356d7e2f3e", + "r": "6f828a356d7e2f3e", + "digit": false, + "name": true, + "n": 10 + }, + { + "i": 2, + "size": 3, + "c": "be1455d31c15d2f9", + "r": "be1455d31c15d2f9", + "digit": false, + "name": true, + "n": 11 + }, + { + "i": 3, + "size": 2, + "c": "114752e88efa76b7", + "r": "114752e88efa76b7", + "digit": false, + "name": true, + "n": 8 } ], "structured": null, + "signal_hashes": { + "captured_context|exact|": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|unicode_nfkc": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|casefold": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|collapse_whitespace": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|strip_punctuation": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|unicode_nfkc,casefold": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|unicode_nfkc,collapse_whitespace": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|unicode_nfkc,strip_punctuation": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|casefold,collapse_whitespace": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|casefold,strip_punctuation": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|collapse_whitespace,strip_punctuation": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|unicode_nfkc,casefold,collapse_whitespace": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|unicode_nfkc,casefold,strip_punctuation": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|unicode_nfkc,collapse_whitespace,strip_punctuation": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|casefold,collapse_whitespace,strip_punctuation": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|unicode_nfkc,casefold,collapse_whitespace,strip_punctuation": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa" + }, "param_token_indices": {}, "rests_on_confusable_identifier": false }, - "identifier_crop": null, - "identifier_region": null, + "identifier_crop": "templates/identifiers/step_005.png", + "identifier_region": [ + 49, + 20, + 1176, + 22 + ], + "form_identity": null, "landmarks": [ { "relation": "below", @@ -402,7 +624,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -424,11 +651,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": true, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_006", @@ -438,7 +678,12 @@ "text": "Phil", "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -469,11 +714,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_007", @@ -483,7 +741,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": "Enter", + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -514,11 +777,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "submission/deletion key may edit locally or commit application state", + "risk_review_required": true, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_008", @@ -542,13 +818,13 @@ "structured_identity": null, "identity_template": { "schema_version": 1, - "salt": "076a9e81b90d6ddda88595bd2dd19e90", + "salt": "d87570c6fc466f1ecb27166c1678853c", "band_len": 32, "tokens": [ { - "c": "a2929a25be2ec74e", - "r": "1a87697ed567a355", - "n": 12, + "c": "8238994f99152d25", + "r": "16c1a02262b5e5a3", + "n": 9, "alpha": false, "name": false, "digit": true, @@ -557,30 +833,30 @@ "gen": false }, { - "c": "8bd929d424f946de", - "r": "c14f3daa06b03b31", - "n": 9, + "c": "ec4c7c568e6c565a", + "r": "5235ee148f0a33f6", + "n": 10, "alpha": false, "name": false, "digit": true, - "idsh": true, + "idsh": false, "glyph": false, "gen": false }, { - "c": "3eb2a6e306ae66eb", - "r": "960f3c575917b914", - "n": 10, + "c": "850b3075b37d3799", + "r": "ce31d93bb38d3937", + "n": 12, "alpha": false, "name": false, "digit": true, - "idsh": false, + "idsh": true, "glyph": false, "gen": false }, { - "c": "75e7d6b0c8b31579", - "r": "bc89869273be9f2d", + "c": "b3240eb701d23d10", + "r": "79f11b95b96b715f", "n": 1, "alpha": false, "name": false, @@ -594,17 +870,17 @@ { "i": 0, "size": 2, - "c": "ec85fa0ac01cf590", - "r": "d7f444202dcda5ec", + "c": "21fddfe16cb62e5c", + "r": "6ea0d85015b84512", "digit": true, "name": false, - "n": 21 + "n": 19 }, { "i": 0, "size": 3, - "c": "15a4b9c9025d0962", - "r": "99ea03dca99dacd3", + "c": "217c8ea66bb48bc4", + "r": "7c27aa3ef4eb4d22", "digit": true, "name": false, "n": 31 @@ -612,8 +888,8 @@ { "i": 0, "size": 4, - "c": "d2f0b3f456a67420", - "r": "e8810bee45734b17", + "c": "5c5462b5052b2fd2", + "r": "63c7142316050a72", "digit": true, "name": false, "n": 32 @@ -621,37 +897,61 @@ { "i": 1, "size": 2, - "c": "c5acf03e788c6228", - "r": "32163c0ce6b28d4d", + "c": "4c9321292b55fe4d", + "r": "5f33343017a60ec5", "digit": true, "name": false, - "n": 19 + "n": 22 }, { "i": 1, "size": 3, - "c": "8f267bdbe912dc67", - "r": "1697925105c49d5e", + "c": "2426277988080a7d", + "r": "cb48f440843d9bff", "digit": true, "name": false, - "n": 20 + "n": 23 }, { "i": 2, "size": 2, - "c": "c9dd5262c3073063", - "r": "51b932b9fa3730ef", + "c": "fb2023f8f609db56", + "r": "4efe9b2c679592f8", "digit": true, "name": false, - "n": 11 + "n": 13 } ], "structured": null, + "signal_hashes": { + "captured_context|exact|": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|unicode_nfkc": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|casefold": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|collapse_whitespace": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|unicode_nfkc,casefold": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|unicode_nfkc,collapse_whitespace": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|unicode_nfkc,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|casefold,collapse_whitespace": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|casefold,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|collapse_whitespace,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|unicode_nfkc,casefold,collapse_whitespace": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|unicode_nfkc,casefold,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|unicode_nfkc,collapse_whitespace,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|casefold,collapse_whitespace,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|unicode_nfkc,casefold,collapse_whitespace,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc" + }, "param_token_indices": {}, "rests_on_confusable_identifier": false }, - "identifier_crop": null, - "identifier_region": null, + "identifier_crop": "templates/identifiers/step_008.png", + "identifier_region": [ + 237, + 434, + 802, + 20 + ], + "form_identity": null, "landmarks": [ { "relation": "above", @@ -673,7 +973,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -704,11 +1009,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": true, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_009", @@ -718,18 +1036,36 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": 0, "scroll_dy": 400, "expect": [], "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_010", @@ -739,18 +1075,36 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": 0, "scroll_dy": 400, "expect": [], "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_011", @@ -760,18 +1114,36 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": 0, "scroll_dy": 400, "expect": [], "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_012", @@ -781,22 +1153,40 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": 0, "scroll_dy": 400, "expect": [], "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_013", - "intent": "click at (815, 369)", + "intent": "click recorded visual target", "action": "click", "anchor": { "template": "templates/step_013.png", @@ -817,6 +1207,7 @@ "identity_template": null, "identifier_crop": null, "identifier_region": null, + "form_identity": null, "landmarks": [ { "relation": "right_of", @@ -838,7 +1229,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -869,15 +1265,28 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "unlabelled pointer control may be navigation, focus, or a state change", + "risk_review_required": true, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": false, - "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)" + "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)", + "identifier_crop_missing_reason": "identity is not armed on this step (see identity_unarmed_reason) — there is no identity evidence to crop", + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_014", - "intent": "click '+Add [reversible] flow.type_text(params["note"]) - # step_017: click 'Save as new messag' [irreversible] + # step_017: click 'Save as new message' [irreversible] # landmark: 'Smith, Billy' (above, 191px) # landmark: 'Active' (left_of, 234px) flow.click( template="templates/step_017.png", click_point=(489, 452), region=(409, 420, 160, 64), - ocr_text="Save as new messag", + ocr_text="Save as new message", ) # expect region_stable region=(0, 0, 1280, 800) phash='c0d91b9db162c6a7' (tol 16) # expect text_present text='CalendarFinderFlowRecallsMessagesPatientFeesModulesInventoryProceduresAdminReportsMiscellaneousPopups'