diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 6b4b7f994d..6d2f5b3079 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -316,7 +316,7 @@ improvising or retrying when something is off. | Each package installs on its own within a size budget, checked in CI (privacy without desktop, capture without plotting) | On main ([#1270](https://github.com/OpenAdaptAI/OpenAdapt/pull/1270), [#1271](https://github.com/OpenAdaptAI/OpenAdapt/pull/1271), [#1279](https://github.com/OpenAdaptAI/OpenAdapt/pull/1279)) | CI | `.github/workflows/packages.yml` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](packages/flow/docs/SECTIONS.md) | -| Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume, undo, visualize, explain, emit-skill, for-each, sections and check mark read them in memory through one artifact reader and refuse without the key | On main | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md) | +| Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants and route read them in memory through one artifact reader and refuse without the key | On main | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md) | | Encryption at rest under a configured or locally generated key (0600): durable checkpoints by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | diff --git a/llms-full.txt b/llms-full.txt index 4ee9c6415f..b67e506089 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -719,7 +719,7 @@ improvising or retrying when something is off. | Each package installs on its own within a size budget, checked in CI (privacy without desktop, capture without plotting) | On main ([#1270](https://github.com/OpenAdaptAI/OpenAdapt/pull/1270), [#1271](https://github.com/OpenAdaptAI/OpenAdapt/pull/1271), [#1279](https://github.com/OpenAdaptAI/OpenAdapt/pull/1279)) | CI | `.github/workflows/packages.yml` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SECTIONS.md) | -| Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume, undo, visualize, explain, emit-skill, for-each, sections and check mark read them in memory through one artifact reader and refuse without the key | On main | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md) | +| Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants and route read them in memory through one artifact reader and refuse without the key | On main | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md) | | Encryption at rest under a configured or locally generated key (0600): durable checkpoints by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index 76a2ec343e..575bb703f8 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -223,10 +223,11 @@ takes tens to hundreds of milliseconds, so encryption adds about 1% or less. `record` (including `--with-check`), `compile`, `induce`, `scaffold-verifier`, `seal`, `certify`, `qualify`, `admit`, `run`, `replay`, -`approve`, `resume`, `undo`, `visualize`, `explain`, `emit-skill`, -`for-each`, `sections` and `check mark` read sealed bundles, run directories -and encrypted recordings in memory, given the key, and keep what they write -inside a sealed set sealed. Each reads artifact files through one reader, +`approve`, `resume`, `undo`, `visualize` (including a process contract), +`explain`, `emit-skill`, `for-each`, `sections`, `check mark`, `compose`, +`process`, `variants` and `route` read sealed bundles, run directories and +encrypted recordings in memory, given the key, and keep what they write inside +a sealed set sealed. Each reads artifact files through one reader, `openadapt_flow.artifacts`: - `compile` of an encrypted recording writes a sealed bundle, under @@ -254,6 +255,12 @@ inside a sealed set sealed. Each reads artifact files through one reader, mode refuses them before they run. - `sections` and `check mark` write `sections.json` and `check.json` back into the sealed bundle or recording, encrypted. +- `compose` copies sealed child bundles into the composition as ciphertext. + `process`, `variants` and `route` read sealed child bundles and their + admission envelopes in place. The parent file each writes + (`composition.json`, `process-contract.json`, `variant-set.json`) holds + names, digests and pointers; by default it is named on stderr and in the + ledger, and strict mode refuses the command before it runs. Without the key, each of these refuses before it reads anything and names the variable to set. Any other command given a sealed path refuses with a message diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 63af7ba7f3..daafabec9b 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -13124,6 +13124,11 @@ def _qualify_default_form(argv: list[str]) -> list[str]: "openadapt_flow.__main__:_cmd_explain", "openadapt_flow.__main__:_cmd_emit_skill", "openadapt_flow.__main__:_cmd_for_each", + "openadapt_flow.__main__:_cmd_visualize_process", + "openadapt_flow.__main__:_cmd_compose", + "openadapt_flow.__main__:_cmd_process", + "openadapt_flow.__main__:_cmd_variants", + "openadapt_flow.__main__:_cmd_route", "openadapt_flow.cli_sections:cmd_sections", "openadapt_flow.cli_check:cmd_check_mark", "openadapt_flow.cli_admit:cmd_admit_run", @@ -13352,7 +13357,8 @@ def _sealed_input_refusal(args: argparse.Namespace) -> Optional[str]: "command. Commands that read sealed input: record, compile, " "induce, scaffold-verifier, seal, certify, qualify, admit, run, " "replay, approve, resume, undo, visualize, explain, emit-skill, " - "for-each, sections and check mark." + "for-each, sections, check mark, compose, process, variants and " + "route." ) for root in sealed: kind = sealed_set.header_kind(root) diff --git a/packages/flow/openadapt_flow/admitted_composition.py b/packages/flow/openadapt_flow/admitted_composition.py index 2bfe27e1c2..ed716e825d 100644 --- a/packages/flow/openadapt_flow/admitted_composition.py +++ b/packages/flow/openadapt_flow/admitted_composition.py @@ -359,14 +359,16 @@ def save(self, parent_dir: Path | str) -> Path: @classmethod def load(cls, parent_dir: Path | str) -> "ProcessContract": + from openadapt_flow import artifacts + parent = Path(parent_dir) path = parent / PROCESS_CONTRACT_FILENAME - if not path.is_file(): + if not artifacts.is_file(path): raise ProcessContractError( f"{parent} is not a process-contract artifact " f"(no {PROCESS_CONTRACT_FILENAME})" ) - return cls.model_validate_json(path.read_text(encoding="utf-8")) + return cls.model_validate_json(artifacts.read_text(path)) class ProcessContractV1(ProcessContract): @@ -416,8 +418,10 @@ def load_child_envelope( ) -> QualificationAdmissionEnvelope: """Load the child's v1 admission envelope from its pointer.""" + from openadapt_flow import artifacts + path = resolve_pointer(parent_dir, child.envelope) - if not path.is_file(): + if not artifacts.is_file(path): raise ProcessContractError( f"child {child.name!r} has no QualificationAdmissionEnvelope at " f"{path}; a compiled recording (including a compose child under " @@ -425,7 +429,7 @@ def load_child_envelope( ) try: return QualificationAdmissionEnvelope.model_validate_json( - path.read_text(encoding="utf-8") + artifacts.read_text(path) ) except Exception as exc: raise ProcessContractError( @@ -500,9 +504,10 @@ def _pointer_for(src: Path, parent: Path) -> str: def _load_admitted_bundle(name: str, bundle: Path) -> Workflow: - if ( - not (bundle / "workflow.json").is_file() - and not (bundle / "workflow.json.enc").is_file() + from openadapt_flow import artifacts + + if not artifacts.is_file(bundle / "workflow.json") and not artifacts.is_file( + bundle / "workflow.json.enc" ): raise ProcessContractError( f"child {name!r} at {bundle} is not an admitted workflow bundle" @@ -531,6 +536,8 @@ def author_process_contract( The parent does not copy recordings. Default sequencing is that order. """ + from openadapt_flow import artifacts + if len(children) < 2: raise ProcessContractError( "process needs at least two independently admitted children" @@ -560,7 +567,7 @@ def author_process_contract( for child_name, envelope_src, bundle_src in children: envelope_path = Path(envelope_src) bundle_path = Path(bundle_src) - if not envelope_path.is_file(): + if not artifacts.is_file(envelope_path): errors.append( f"child {child_name!r} has no QualificationAdmissionEnvelope at " f"{envelope_path}; a compiled recording (including a compose " @@ -570,7 +577,7 @@ def author_process_contract( continue try: envelope = QualificationAdmissionEnvelope.model_validate_json( - envelope_path.read_text(encoding="utf-8") + artifacts.read_text(envelope_path) ) except Exception as exc: errors.append( diff --git a/packages/flow/openadapt_flow/compiler/compose_authoring.py b/packages/flow/openadapt_flow/compiler/compose_authoring.py index b245532c3f..a2c75e9eb5 100644 --- a/packages/flow/openadapt_flow/compiler/compose_authoring.py +++ b/packages/flow/openadapt_flow/compiler/compose_authoring.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Mapping, Optional, Sequence +from openadapt_flow import artifacts from openadapt_flow.composition import ( CHILDREN_DIR, TERMINAL_OUTCOMES, @@ -123,9 +124,8 @@ def author_composition( for child_name, src in children: src_path = Path(src) - if ( - not (src_path / "workflow.json").is_file() - and not (src_path / "workflow.json.enc").is_file() + if not artifacts.is_file(src_path / "workflow.json") and not artifacts.is_file( + src_path / "workflow.json.enc" ): errors.append( f"child {child_name!r} at {src_path} is not a workflow bundle" diff --git a/packages/flow/openadapt_flow/composition.py b/packages/flow/openadapt_flow/composition.py index b7e142ba8e..7ce2cbd67f 100644 --- a/packages/flow/openadapt_flow/composition.py +++ b/packages/flow/openadapt_flow/composition.py @@ -183,13 +183,15 @@ def save(self, parent_dir: Path | str) -> Path: @classmethod def load(cls, parent_dir: Path | str) -> "Composition": + from openadapt_flow import artifacts + parent = Path(parent_dir) path = parent / COMPOSITION_FILENAME - if not path.is_file(): + if not artifacts.is_file(path): raise CompositionError( f"{parent} is not a composition artifact (no {COMPOSITION_FILENAME})" ) - return cls.model_validate_json(path.read_text(encoding="utf-8")) + return cls.model_validate_json(artifacts.read_text(path)) def is_composition_artifact(path: Path | str) -> bool: diff --git a/packages/flow/openadapt_flow/variants.py b/packages/flow/openadapt_flow/variants.py index 642d4b4f9d..7e3f06ef1f 100644 --- a/packages/flow/openadapt_flow/variants.py +++ b/packages/flow/openadapt_flow/variants.py @@ -257,14 +257,16 @@ def save(self, parent_dir: Path | str) -> Path: @classmethod def load(cls, parent_dir: Path | str) -> "VariantSet": + from openadapt_flow import artifacts + parent = Path(parent_dir) path = parent / VARIANT_SET_FILENAME - if not path.is_file(): + if not artifacts.is_file(path): raise VariantSetError( f"{parent} is not a variant-set artifact (no {VARIANT_SET_FILENAME})" ) try: - return cls.model_validate_json(path.read_text(encoding="utf-8")) + return cls.model_validate_json(artifacts.read_text(path)) except ValueError as exc: raise VariantSetError(f"variant set at {path} is invalid: {exc}") from exc @@ -272,7 +274,9 @@ def load(cls, parent_dir: Path | str) -> "VariantSet": def is_variant_set_artifact(path: Path | str) -> bool: """True when ``path`` is a variant-set directory.""" - return (Path(path) / VARIANT_SET_FILENAME).is_file() + from openadapt_flow import artifacts + + return artifacts.is_file(Path(path) / VARIANT_SET_FILENAME) class VariantInput(BaseModel): @@ -290,14 +294,16 @@ class VariantInput(BaseModel): def _admitted_spec(item: VariantInput, out_dir: Path) -> VariantSpec: """Build a variant spec, refusing anything that is not admitted as is.""" - if not item.admission.is_file(): + from openadapt_flow import artifacts + + if not artifacts.is_file(item.admission): raise VariantSetError( f"variant {item.name!r} has no QualificationAdmissionEnvelope at " f"{item.admission}; each variant keeps its own admission" ) try: envelope = QualificationAdmissionEnvelope.model_validate_json( - item.admission.read_text(encoding="utf-8") + artifacts.read_text(item.admission) ) except Exception as exc: raise VariantSetError( @@ -340,13 +346,14 @@ def _admitted_spec(item: VariantInput, out_dir: Path) -> VariantSpec: def _repair_candidate(bundle: Path) -> tuple[Optional[str], Optional[str]]: """Read the detached repair-candidate record a healed bundle carries.""" + from openadapt_flow import artifacts from openadapt_flow.repair.registration import detached_candidate_path path = detached_candidate_path(bundle) - if not path.is_file(): + if not artifacts.is_file(path): return None, None try: - record = json.loads(path.read_text(encoding="utf-8")) + record = json.loads(artifacts.read_text(path)) except (OSError, ValueError) as exc: raise VariantSetError( f"repair candidate record at {path} is unreadable" @@ -415,16 +422,17 @@ def add_variant(parent_dir: Path | str, item: VariantInput) -> VariantSet: def _check_lineage(bundle: Path, base: VariantSpec, spec: VariantSpec) -> None: """A promoted variant must carry the repair candidate that produced it.""" + from openadapt_flow import artifacts from openadapt_flow.repair.registration import detached_candidate_path path = detached_candidate_path(bundle) - if not path.is_file(): + if not artifacts.is_file(path): raise VariantSetError( f"--derived-from needs the repair candidate record the heal or teach " f"wrote ({path}); without it the lineage is unproven" ) try: - record = json.loads(path.read_text(encoding="utf-8")) + record = json.loads(artifacts.read_text(path)) except (OSError, ValueError) as exc: raise VariantSetError( f"repair candidate record at {path} is unreadable" diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index e775f7de01..99f302cc8e 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -55,7 +55,36 @@ "narration.py": ("read_events", "load_narration"), "cli_sections.py": ("cmd_sections",), "cli_check.py": ("cmd_check_mark", "guided_check"), + # compose, process, variants, route and visualize of a process contract. + "cli_compose.py": ("cmd_compose",), + "compiler/compose_authoring.py": ("author_composition",), + "cli_process.py": ("cmd_process",), + "cli_variants.py": ("cmd_variants", "cmd_route", "first_screen_probe"), + "variants.py": ( + "load", + "is_variant_set_artifact", + "_admitted_spec", + "_repair_candidate", + "_check_lineage", + "author_variant_set", + ), + "runtime/variant_router.py": ("route_variant", "_admitted_variant"), + "visualize/admitted_composition.py": ("build_process_graph",), } +CONVERTED["__main__.py"] += ( + "_cmd_visualize_process", + "_cmd_compose", + "_cmd_process", + "_cmd_variants", + "_cmd_route", +) +CONVERTED["composition.py"] += ("load",) +CONVERTED["admitted_composition.py"] += ( + "load", + "load_child_envelope", + "_load_admitted_bundle", + "author_process_contract", +) #: Receivers whose reads are sealed-aware. _READERS = frozenset({"artifacts", "_artifacts", "sealed_set", "_sealed"}) @@ -68,6 +97,37 @@ "_cmd_record", '(Path(args.out) / "events.jsonl").is_file()', ): "gates the look-up proposal, which runs on plaintext recordings only", + ( + "compiler/compose_authoring.py", + "author_composition", + "out_dir.exists()", + ): "the new composition directory, not an artifact", + ( + "admitted_composition.py", + "author_process_contract", + "out_dir.exists()", + ): "the new process-contract directory, not an artifact", + ( + "variants.py", + "author_variant_set", + "out_dir.exists()", + ): "the new variant-set directory, not an artifact", + ( + "cli_process.py", + "cmd_process", + 'Path(spec_path).read_text(encoding="utf-8")', + ): "the v1 contract spec a person passes in", + ("cli_process.py", "cmd_process", "output.exists()"): "the new output directory", + ( + "cli_variants.py", + "cmd_variants", + 'spec_path.read_text(encoding="utf-8")', + ): "the variant spec a person passes in", + ( + "cli_variants.py", + "first_screen_probe", + "Path(explicit).read_bytes()", + ): "the --first-screen PNG a person passes in", } @@ -237,3 +297,135 @@ def test_record_with_check_marks_a_sealed_recording( assert any("type" in line for line in shown) _assert_ciphertext_only(rec) assert load_check(rec) is not None + + +# -- compose, process, variants and route over sealed children ---------------- + + +def _sealed_admitted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, same_surface: bool = False +) -> dict[str, tuple[Path, Path]]: + """Two sealed child bundles, each with its admission envelope beside it.""" + from openadapt_flow.admitted_composition import live_bundle_content_digest + from openadapt_flow.qualification_admission import sign_qualification_admission + from tests.test_admitted_composition_authoring import ( + INTAKE_ADMISSION, + INTAKE_VERSION, + POSTING_ADMISSION, + POSTING_VERSION, + _reader, + _save, + _writer, + ) + from tests.test_qualification_admission import _payload, _private_key + + monkeypatch.setenv(crypto.ENV_KEY, KEY) + children: dict[str, tuple[Path, Path]] = {} + for name, workflow, admission, version in ( + ("intake", _writer(), INTAKE_ADMISSION, INTAKE_VERSION), + ( + "posting", + _writer("posting") if same_surface else _reader(), + POSTING_ADMISSION, + POSTING_VERSION, + ), + ): + plain = _save(tmp_path, workflow, f"{name}-plain") + bundle = tmp_path / name + assert main(["seal", str(plain), "--out", str(bundle)]) == 0 + digest = live_bundle_content_digest(Workflow.load(bundle), bundle) + envelope = sign_qualification_admission( + _payload( + admission_id=admission, + workflow_version_id=version, + bundle_content_digest=digest, + ), + _private_key(), + ) + path = tmp_path / f"{name}.admission.json" + path.write_text(envelope.model_dump_json(indent=2), encoding="utf-8") + children[name] = (bundle, path) + return children + + +def test_compose_copies_sealed_children_sealed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from openadapt_flow.composition import Composition, child_bundle_path + + _ready("openadapt_flow.__main__:_cmd_compose") + children = _sealed_admitted(tmp_path, monkeypatch) + before = {name: _files(bundle) for name, (bundle, _) in children.items()} + out = tmp_path / "composed" + args = ["compose", "--out", str(out)] + for name, (bundle, _) in children.items(): + args += ["--child", f"{name}={bundle}"] + args += ["--handoff", "intake.patient_id=posting.patient_id"] + assert main(args) == 0 + composition = Composition.load(out) + for child in composition.children: + copy = child_bundle_path(out, child) + assert sealed_set.is_sealed(copy) + _assert_ciphertext_only(copy) + assert Workflow.load(copy, key=KEY).name == child.name + assert {name: _files(b) for name, (b, _) in children.items()} == before + + +def test_process_and_its_visualization_read_sealed_children( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from openadapt_flow.admitted_composition import ProcessContract + + _ready("openadapt_flow.__main__:_cmd_process") + _ready("openadapt_flow.__main__:_cmd_visualize_process") + children = _sealed_admitted(tmp_path, monkeypatch) + out = tmp_path / "process" + args = ["process", "--out", str(out), "--name", "two-step"] + for name, (bundle, envelope) in children.items(): + args += ["--child", f"{name}={bundle}", "--admission", f"{name}={envelope}"] + args += ["--handoff", "intake.patient_id=posting.patient_id"] + assert main(args) == 0 + assert [c.name for c in ProcessContract.load(out).children] == [ + "intake", + "posting", + ] + graph = tmp_path / "process.json" + assert main(["visualize", str(out), "--format", "json", "--out", str(graph)]) == 0 + assert "two-step" in graph.read_text() + for bundle, _ in children.values(): + _assert_ciphertext_only(bundle) + + +def test_variants_and_route_read_sealed_variants( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _ready("openadapt_flow.__main__:_cmd_variants") + _ready("openadapt_flow.__main__:_cmd_route") + children = _sealed_admitted(tmp_path, monkeypatch, same_surface=True) + payers = {"intake": "commercial", "posting": "medicare"} + spec = tmp_path / "variants.json" + spec.write_text( + json.dumps( + { + "name": "by-payer", + "variants": [ + { + "name": name, + "bundle": str(bundle), + "admission": str(envelope), + "when": {"inputs": {"payer": [payers[name]]}}, + } + for name, (bundle, envelope) in children.items() + ], + } + ) + ) + out = tmp_path / "set" + assert main(["variants", "author", "--spec", str(spec), "--out", str(out)]) == 0 + capsys.readouterr() + assert main(["route", str(out), "--param", "payer=medicare"]) == 0 + assert json.loads(capsys.readouterr().out)["variant"] == "posting" + for bundle, _ in children.values(): + _assert_ciphertext_only(bundle)