From da8d4271c0e5355f899176ffd65cdbce47f83867 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 30 Jul 2026 12:02:07 -0400 Subject: [PATCH] fix: keep entity labels optional in decision v2 --- openadapt_flow/__main__.py | 20 ++++++------ openadapt_flow/console/human_decisions.py | 26 ++++++++-------- openadapt_flow/qualification.py | 11 +++++-- tests/test_attended_actions.py | 37 +++++++++++------------ tests/test_qualification_project.py | 29 ++++++++++++++++-- 5 files changed, 76 insertions(+), 47 deletions(-) diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index 467f3bca..0195a2a4 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -2095,8 +2095,13 @@ def _cmd_qualify(args: argparse.Namespace) -> int: return 0 changed = False if args.label_cmd == "set": + fallback = next( + option["fallback"] + for option in entity_label_options() + if option["label"] == args.label + ) label = QualifiedEntityLabel( - step_id=args.step, label=args.label, fallback=args.fallback + step_id=args.step, label=args.label, fallback=fallback ) assert workflow.qualification is not None changed = workflow.qualification.entity_labels.get(args.step) != label @@ -3920,12 +3925,15 @@ def build_parser() -> argparse.ArgumentParser: q = qsub.add_parser( "label", - help="Set, remove, or list qualification-owned entity labels", + help="Optionally set, remove, or list presentation-only entity labels", ) from openadapt_flow.qualification import REMOTE_SAFE_ENTITY_LABELS label_sub = q.add_subparsers(dest="label_cmd", required=True) - label = label_sub.add_parser("set", help="Set a label for one qualified step") + label = label_sub.add_parser( + "set", + help="Set an optional presentation label for one qualified step", + ) label.add_argument("bundle", help="Workflow bundle directory") label.add_argument("--step", required=True, help="Exact qualified workflow step ID") label.add_argument( @@ -3934,12 +3942,6 @@ def build_parser() -> argparse.ArgumentParser: choices=REMOTE_SAFE_ENTITY_LABELS, help="Qualification-approved class label, for example: insurance claim", ) - label.add_argument( - "--fallback", - choices=("record", "item"), - required=True, - help="Neutral fallback if a consumer cannot render the class label", - ) label.set_defaults(func=_cmd_qualify) label = label_sub.add_parser("remove", help="Remove a step entity label") label.add_argument("bundle", help="Workflow bundle directory") diff --git a/openadapt_flow/console/human_decisions.py b/openadapt_flow/console/human_decisions.py index ba3e766d..12bf3aed 100644 --- a/openadapt_flow/console/human_decisions.py +++ b/openadapt_flow/console/human_decisions.py @@ -397,12 +397,14 @@ def _qualified_entity_v2_fields( step_id: Optional[str], capability: Any, ) -> Optional[dict[str, Any]]: - """Read a V2 entity only from the current integrity-sealed project. + """Read V2 bindings only from the current integrity-sealed project. This deliberately reads no screenshots, OCR, accessibility observation, - parameters, application name, or model output. Any missing, unreadable, - or mismatched capability/bundle binding falls back to V1. This gate - requires both bundle integrity and current qualification certification. + parameters, application name, or model output. A missing optional domain + label uses the reviewed neutral ``record`` class without weakening the V2 + qualification and step bindings. Any missing, unreadable, or mismatched + capability/bundle binding falls back to V1. This gate requires both bundle + integrity and current qualification certification. """ if step_id is None or step_id != capability.step_id: @@ -448,21 +450,19 @@ def _qualified_entity_v2_fields( ): return None entity = project.entity_labels.get(step_id) - if ( - entity is None - or { - "label": entity.label, - "fallback": entity.fallback, - } - not in entity_label_options() - ): + entity_payload = ( + entity.model_dump(mode="json", exclude={"step_id"}) + if entity is not None + else {"label": "record", "fallback": "record"} + ) + if entity_payload not in entity_label_options(): return None return { "qualification_project_id": project.project_id, "qualification_revision_id": f"qualification_revision_{project.revision}", "qualification_contract_digest": "sha256:" + project.contract_sha256(), "qualification_step_id": step_id, - "entity": entity.model_dump(mode="json", exclude={"step_id"}), + "entity": entity_payload, } except (OSError, ValueError, TypeError, AttendedActionRefused): return None diff --git a/openadapt_flow/qualification.py b/openadapt_flow/qualification.py index 8fbc662c..c0fc5854 100644 --- a/openadapt_flow/qualification.py +++ b/openadapt_flow/qualification.py @@ -741,7 +741,11 @@ def _default_fault_cases() -> list[QualificationCase]: class QualifiedEntityLabel(BaseModel): - """A safe presentation label explicitly approved for one qualified step.""" + """An optional safe presentation label for one qualified step. + + Qualification and execution do not require this label. A consumer uses + the neutral ``record`` or ``item`` presentation when it is absent. + """ model_config = ConfigDict(extra="forbid", frozen=True) @@ -801,8 +805,9 @@ class QualificationProject(BaseModel): ) identity_policies: dict[str, IdentityPolicy] = Field(default_factory=dict) effect_policies: list[EffectVerificationPolicy] = Field(default_factory=list) - #: Presentation-only names chosen during qualification. They are not - #: observations and must never be inferred from a runtime artifact. + #: Optional presentation-only names selected during qualification. They are + #: not safety evidence, observations, or runtime-derived values. An empty + #: mapping is valid and does not affect qualification admission. entity_labels: dict[str, "QualifiedEntityLabel"] = Field(default_factory=dict) cases: list[QualificationCase] = Field(default_factory=_default_fault_cases) exclusions: list[str] = Field(default_factory=list) diff --git a/tests/test_attended_actions.py b/tests/test_attended_actions.py index 0a742c26..f9e8231b 100644 --- a/tests/test_attended_actions.py +++ b/tests/test_attended_actions.py @@ -3190,7 +3190,7 @@ def test_remote_projection_is_explicit_aal2_phi_free_and_exactly_bound(tmp_path) assert protected not in serialized -def _v2_candidate_workflow() -> Workflow: +def _v2_candidate_workflow(*, with_entity_label: bool = True) -> Workflow: workflow = Workflow(name="attended-v2", steps=[_step("humanstep", "A")]) project = init_project( workflow, @@ -3202,12 +3202,13 @@ def _v2_candidate_workflow() -> Workflow: runtime_version="1.26.0", ), ) - set_entity_label( - workflow, - QualifiedEntityLabel( - step_id="humanstep", label="patient record", fallback="record" - ), - ) + if with_entity_label: + set_entity_label( + workflow, + QualifiedEntityLabel( + step_id="humanstep", label="patient record", fallback="record" + ), + ) policy = load_policy("permissive") project.last_certification = QualificationCertification( project_revision=project.revision, @@ -3262,18 +3263,11 @@ def test_remote_v2_requires_explicit_peer_negotiation_and_exact_label_binding( ) -def test_remote_v2_falls_back_when_the_exact_failed_step_has_no_label(tmp_path): - workflow = Workflow(name="attended-v2", steps=[_step("humanstep", "A")]) - init_project( - workflow, - environment=EnvironmentBoundary( - target_kind="web", - application="qualified-app", - application_version="1", - environment_digest="a" * 64, - runtime_version="1.26.0", - ), - ) +def test_remote_v2_uses_neutral_entity_when_optional_label_is_absent( + tmp_path, monkeypatch +): + _accept_current_certification(monkeypatch) + workflow = _v2_candidate_workflow(with_entity_label=False) workflow, _bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) item = attention_item(run.parent, run) assert item is not None @@ -3284,7 +3278,10 @@ def test_remote_v2_falls_back_when_the_exact_failed_step_has_no_label(tmp_path): peer_task_schemas=["openadapt.human-decision-task/v2"] ), ) - assert projection.task.schema_version == "openadapt.human-decision-task/v1" + assert projection.task.schema_version == "openadapt.human-decision-task/v2" + assert projection.task.entity.label == "record" + assert projection.task.entity.fallback.value == "record" + assert projection.task.qualification_step_id == "humanstep" def test_remote_v2_falls_back_without_a_current_certification(tmp_path, monkeypatch): diff --git a/tests/test_qualification_project.py b/tests/test_qualification_project.py index 91791e52..1a2cea12 100644 --- a/tests/test_qualification_project.py +++ b/tests/test_qualification_project.py @@ -221,6 +221,33 @@ def test_entity_labels_are_qualification_contract_and_invalidate_certification() assert project.entity_labels == {} +def test_entity_label_is_optional_for_certification(tmp_path: Path) -> None: + """Presentation metadata must never become a safety admission gate.""" + + workflow = _workflow() + bundle = tmp_path / "bundle" + (bundle / "templates").mkdir(parents=True) + (bundle / "templates" / "save.png").write_bytes( + _qualification_visual_fixture()[1] + ) + workflow.save(bundle) + workflow = Workflow.load(bundle) + _configure(workflow, tier=VerificationTier.INDEPENDENT_SYSTEM) + assert workflow.qualification is not None + assert workflow.qualification.entity_labels == {} + evidence_root = tmp_path / "evidence" + _record_passing_campaign(workflow, evidence_root) + + report = certify_project( + workflow, + policy=load_policy("clinical-write"), + evidence_root=evidence_root, + ) + + assert report.passed + assert workflow.qualification.entity_labels == {} + + def test_entity_label_options_are_ordered_and_derive_every_public_label() -> None: options = entity_label_options() assert options == [ @@ -2977,8 +3004,6 @@ def test_cli_entity_label_notice_tracks_real_mutations( "save", "--label", "insurance claim", - "--fallback", - "item", ] invalid_args = [*set_args] invalid_args[invalid_args.index("insurance claim")] = "jane smith"