From 07924c930fb863aaf13001b91917a2238aaca94d Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 29 Jul 2026 18:03:56 -0400 Subject: [PATCH 1/7] feat: emit qualified entity decision tasks v2 --- openadapt_flow/__main__.py | 50 +++++++++++++ openadapt_flow/console/human_decisions.py | 82 ++++++++++++++++++++-- openadapt_flow/deployment.py | 10 +++ openadapt_flow/qualification.py | 78 ++++++++++++++++++++ openadapt_flow/runtime/durable/attended.py | 24 ++++++- pyproject.toml | 16 ++--- tests/test_attended_actions.py | 74 ++++++++++++++++++- tests/test_qualification_project.py | 34 +++++++++ uv.lock | 12 ++-- 9 files changed, 358 insertions(+), 22 deletions(-) diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index 21f1db38..fc6b780c 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -2054,17 +2054,21 @@ def _cmd_qualify(args: argparse.Namespace) -> int: QualificationCaseKind, QualificationCaseResult, QualificationOutcome, + QualifiedEntityLabel, RequalificationCondition, VerificationTier, add_case, add_requalification_condition, certify_project, init_project, + list_entity_labels, project_schema, record_case_results, + remove_entity_label, save_qualified_workflow, set_action_classification, set_effect_policy, + set_entity_label, set_identity_policy, set_trusted_runner_key, ) @@ -2076,6 +2080,33 @@ def _cmd_qualify(args: argparse.Namespace) -> int: workflow = _qualification_workflow(args) + if verb == "label": + if args.label_cmd == "list": + print( + json.dumps( + [ + label.model_dump(mode="json") + for label in list_entity_labels(workflow) + ], + indent=2, + ) + ) + return 0 + if args.label_cmd == "set": + set_entity_label( + workflow, + QualifiedEntityLabel( + step_id=args.step, label=args.label, fallback=args.fallback + ), + ) + elif args.label_cmd == "remove": + remove_entity_label(workflow, args.step) + else: # pragma: no cover - argparse requires a known command. + raise SystemExit(f"unknown qualification label command {args.label_cmd!r}") + save_qualified_workflow(workflow, args.bundle) + print(workflow.qualification.model_dump_json(indent=2)) + return 0 + if verb == "init": environment = EnvironmentBoundary( target_kind=args.target, @@ -3876,6 +3907,25 @@ def build_parser() -> argparse.ArgumentParser: q = qsub.add_parser("schema", help="Print the qualification-project JSON Schema") q.set_defaults(func=_cmd_qualify) + q = qsub.add_parser( + "label", + help="Set, remove, or list qualification-owned 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.add_argument("bundle", help="Workflow bundle directory") + label.add_argument("--step", required=True) + label.add_argument("--label", required=True) + label.add_argument("--fallback", choices=("record", "item"), required=True) + 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") + label.add_argument("--step", required=True) + label.set_defaults(func=_cmd_qualify) + label = label_sub.add_parser("list", help="List qualification-owned entity labels") + label.add_argument("bundle", help="Workflow bundle directory") + label.set_defaults(func=_cmd_qualify) + q = qsub.add_parser("init", help="Initialize a bundle's qualification project") q.add_argument("bundle", help="Workflow bundle directory") q.add_argument( diff --git a/openadapt_flow/console/human_decisions.py b/openadapt_flow/console/human_decisions.py index 1e10884e..f1c41678 100644 --- a/openadapt_flow/console/human_decisions.py +++ b/openadapt_flow/console/human_decisions.py @@ -33,8 +33,10 @@ from openadapt_types import ( HUMAN_DECISION_TASK_SCHEMA, + HUMAN_DECISION_TASK_V2_SCHEMA, HumanDecisionReceiptV1, HumanDecisionTaskV1, + HumanDecisionTaskV2, ) from pydantic import BaseModel, ConfigDict, Field @@ -232,7 +234,7 @@ class RemoteDecisionProjection(BaseModel): schema_version: Literal["openadapt.remote-decision-projection/v1"] = ( "openadapt.remote-decision-projection/v1" ) - task: HumanDecisionTaskV1 + task: HumanDecisionTaskV1 | HumanDecisionTaskV2 task_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") phase: Literal["paused"] = "paused" event_sequence: int = Field(ge=1) @@ -389,6 +391,65 @@ def _remote_delivery_tier( raise AttendedActionRefused(str(exc)) from exc +def _qualified_entity_v2_fields( + run_dir: Path, + *, + step_id: Optional[str], +) -> Optional[dict[str, Any]]: + """Read a V2 entity only from the sealed project and its exact step. + + This deliberately reads no screenshots, OCR, accessibility observation, + parameters, application name, or model output. Any missing, unreadable, + or mismatched binding falls back to V1. + """ + + if step_id is None: + return None + try: + from openadapt_flow.runtime.durable.checkpoint import CheckpointStore + + manifest = CheckpointStore(run_dir).read_manifest() + if manifest is None: + return None + workflow, _ = data.load_workflow_safe(Path(manifest.bundle_dir)) + project = workflow.qualification if workflow is not None else None + if project is None: + return None + entity = project.entity_labels.get(step_id) + if entity is None: + 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"}), + } + except (OSError, ValueError, TypeError, AttendedActionRefused): + return None + + +def _peer_negotiated_v2(deployment: Optional[DeploymentConfig]) -> bool: + """Return true only for an explicit authenticated-peer V2 declaration.""" + + return bool( + deployment is not None + and deployment.human_decisions.remote.enabled + and HUMAN_DECISION_TASK_V2_SCHEMA + in deployment.human_decisions.remote.peer_task_schemas + ) + + +def _task_model( + task: dict[str, Any], +) -> type[HumanDecisionTaskV1 | HumanDecisionTaskV2]: + return ( + HumanDecisionTaskV2 + if task.get("schema_version") == HUMAN_DECISION_TASK_V2_SCHEMA + else HumanDecisionTaskV1 + ) + + def _task_and_presentation( run_dir: Path, item: AttentionItem, @@ -540,8 +601,21 @@ def _task_and_presentation( ), "signature_algorithm": "hmac-sha256", } - task = AttendedActionStore(run_dir).seal_human_decision_task(unsigned) - task_digest = HumanDecisionTaskV1.model_validate(task).digest + qualified_v2 = ( + _qualified_entity_v2_fields( + run_dir, + step_id=failed.step_id if failed is not None else None, + ) + if _peer_negotiated_v2(deployment) + else None + ) + if qualified_v2 is not None: + unsigned.update(qualified_v2) + unsigned["schema_version"] = HUMAN_DECISION_TASK_V2_SCHEMA + task = AttendedActionStore(run_dir).seal_human_decision_task_v2(unsigned) + else: + task = AttendedActionStore(run_dir).seal_human_decision_task(unsigned) + task_digest = _task_model(task).model_validate(task).digest return task, task_digest, presentation @@ -605,7 +679,7 @@ def portable_remote_decision_task( raise AttendedActionRefused( "the run has no current signed human decision task or pause capability" ) - task = HumanDecisionTaskV1.model_validate(task_raw) + task = _task_model(task_raw).model_validate(task_raw) # The rest of `presentation` -- the screenshot artifact ids, the composed # question, the gated control label inside `halt` -- is still discarded. # Only the closed-vocabulary re-projection of `halt` may cross, and only at diff --git a/openadapt_flow/deployment.py b/openadapt_flow/deployment.py index 91c4ee2e..8051ed3b 100644 --- a/openadapt_flow/deployment.py +++ b/openadapt_flow/deployment.py @@ -446,6 +446,14 @@ class RemoteHumanDecisionConfig(BaseModel): context_tier: Literal["remote_closed_context", "remote_identifiers"] = ( "remote_closed_context" ) + #: Exact schemas the authenticated remote peer advertised for this + #: deployment. Empty means no V2 negotiation occurred, so Flow emits V1. + peer_task_schemas: list[ + Literal[ + "openadapt.human-decision-task/v1", + "openadapt.human-decision-task/v2", + ] + ] = Field(default_factory=list) @model_validator(mode="after") def _require_exact_remote_scope(self) -> "RemoteHumanDecisionConfig": @@ -454,6 +462,8 @@ def _require_exact_remote_scope(self) -> "RemoteHumanDecisionConfig": "human_decisions.remote.enabled requires exact tenant_id and " "runner_id bindings" ) + if len(self.peer_task_schemas) != len(set(self.peer_task_schemas)): + raise ValueError("human decision peer task schemas must be unique") return self diff --git a/openadapt_flow/qualification.py b/openadapt_flow/qualification.py index 5fc209b8..c18b65d6 100644 --- a/openadapt_flow/qualification.py +++ b/openadapt_flow/qualification.py @@ -57,6 +57,7 @@ _ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") _PARAM_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$") _CONTEXT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$") +_QUALIFIED_ENTITY_LABEL_RE = re.compile(r"^[a-z][a-z0-9]*(?:[ _-][a-z0-9]+){0,3}$") def _qualification_actuation_path( @@ -708,6 +709,25 @@ def _default_fault_cases() -> list[QualificationCase]: ] +class QualifiedEntityLabel(BaseModel): + """A safe presentation label explicitly approved for one qualified step.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + step_id: str = Field(pattern=_ID_RE.pattern) + label: str = Field(min_length=1, max_length=63) + fallback: Literal["record", "item"] + + @field_validator("label") + @classmethod + def _safe_label(cls, value: str) -> str: + if _QUALIFIED_ENTITY_LABEL_RE.fullmatch(value) is None: + raise ValueError( + "entity label must use the qualified neutral-label vocabulary" + ) + return value + + class QualificationProject(BaseModel): """Versioned qualification configuration sealed inside a Flow bundle.""" @@ -730,6 +750,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. + 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) requalification_conditions: list[RequalificationCondition] = Field( @@ -753,6 +776,12 @@ def _consistent_keys(self) -> "QualificationProject": f"action classification key {key!r} does not match step_id " f"{classification.step_id!r}" ) + for key, entity in self.entity_labels.items(): + if key != entity.step_id: + raise ValueError( + f"entity label key {key!r} does not match step_id " + f"{entity.step_id!r}" + ) for key_kind, keys in ( ("runner", self.trusted_runner_keys), ("fault driver", self.trusted_fault_driver_keys), @@ -1268,6 +1297,55 @@ def set_minimum_effect_tier( return project +def set_entity_label( + workflow: "Workflow", label: QualifiedEntityLabel +) -> QualificationProject: + """Set one qualification-owned entity label and invalidate certification.""" + + project = workflow.qualification + if project is None: + raise QualificationError( + "initialize qualification before setting an entity label" + ) + if label.step_id not in _steps_by_id(workflow): + raise QualificationError(f"unknown step id {label.step_id!r}") + if project.entity_labels.get(label.step_id) == label: + return project + previous = project.revision_digest() + project.entity_labels[label.step_id] = label + _touch(project, previous) + _invalidate_certification(workflow) + return project + + +def remove_entity_label(workflow: "Workflow", step_id: str) -> QualificationProject: + """Remove one qualification-owned entity label and invalidate certification.""" + + project = workflow.qualification + if project is None: + raise QualificationError( + "initialize qualification before removing an entity label" + ) + if step_id not in project.entity_labels: + raise QualificationError(f"no entity label is set for step id {step_id!r}") + previous = project.revision_digest() + del project.entity_labels[step_id] + _touch(project, previous) + _invalidate_certification(workflow) + return project + + +def list_entity_labels(workflow: "Workflow") -> list[QualifiedEntityLabel]: + """Return qualification-owned labels in stable step-id order.""" + + project = workflow.qualification + if project is None: + raise QualificationError( + "initialize qualification before listing entity labels" + ) + return [project.entity_labels[key] for key in sorted(project.entity_labels)] + + def set_identity_policy( workflow: "Workflow", policy: IdentityPolicy, diff --git a/openadapt_flow/runtime/durable/attended.py b/openadapt_flow/runtime/durable/attended.py index 47d38933..b0a39392 100644 --- a/openadapt_flow/runtime/durable/attended.py +++ b/openadapt_flow/runtime/durable/attended.py @@ -1101,12 +1101,32 @@ def seal_human_decision_task(self, unsigned: dict[str, Any]) -> dict[str, Any]: ) from exc return task.model_dump(mode="json") + def seal_human_decision_task_v2(self, unsigned: dict[str, Any]) -> dict[str, Any]: + """Sign the negotiated V2 task under its distinct Types domain.""" + try: + from openadapt_types import sign_human_decision_task_v2_hmac + + task = sign_human_decision_task_v2_hmac( + key=self._key(create=False), + fields=unsigned, + ) + except (ImportError, ValueError) as exc: + raise AttendedActionRefused( + "the shared human decision V2 contract is unavailable or invalid" + ) from exc + return task.model_dump(mode="json") + def verify_human_decision_task(self, task: dict[str, Any]) -> bool: """Verify a projected task without treating it as pause authority.""" try: - from openadapt_types import HumanDecisionTaskV1 + from openadapt_types import HumanDecisionTaskV1, HumanDecisionTaskV2 - validated = HumanDecisionTaskV1.model_validate(task) + model = ( + HumanDecisionTaskV2 + if task.get("schema_version") == "openadapt.human-decision-task/v2" + else HumanDecisionTaskV1 + ) + validated = model.model_validate(task) return validated.verify_hmac(self._key(create=False)) except (ImportError, ValueError, AttendedActionRefused): return False diff --git a/pyproject.toml b/pyproject.toml index 3e1652ea..c55d3c39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,12 +63,10 @@ dev = [ # uvicorn for the console's boot smoke test. "fastapi>=0.110", "uvicorn>=0.29", - # FLOOR is load-bearing, not cosmetic. The attended `reconcile` action and - # `reconciled_and_resumed` receipt pair are named symbols that only exist - # from 0.7.0; a resolver satisfied by 0.6.x fails at IMPORT, not at resolve, - # and does so inside the operator decision path. Raise this with every new - # symbol taken from the contract. - "openadapt-types>=0.7.0,<0.8.0", + # V2 uses a separately signed entity contract from Types 0.8.0. A peer + # must still explicitly negotiate it; the dependency alone never upgrades + # an existing V1 decision surface. + "openadapt-types>=0.8.0,<0.9.0", # Engineering-hygiene gates (lint+format, type-check, coverage). Pinned to # majors so CI and local dev run the same checkers. "ruff==0.15.22", @@ -91,7 +89,7 @@ grounding = ["openadapt-grounding>=0.1.0"] console = [ "fastapi>=0.110", "uvicorn>=0.29", - "openadapt-types>=0.7.0,<0.8.0", + "openadapt-types>=0.8.0,<0.9.0", ] # WindowsBackend: HTTP client for the WAA (Windows Agent Arena) server. windows = ["requests>=2.31"] @@ -155,8 +153,8 @@ capture = ["openadapt-capture>=1.2.0"] # producer and attended-decision portal are field-exact against the released # 0.7.x schemas. The # `interop-types` CI job type-checks and tests both boundaries against the real -# package; raise the ceiling only after validating the next minor. -interop = ["openadapt-types>=0.7.0,<0.8.0"] +# package; V2 is consumed only by an explicitly negotiated peer. +interop = ["openadapt-types>=0.8.0,<0.9.0"] [project.scripts] openadapt-flow = "openadapt_flow.__main__:main" diff --git a/tests/test_attended_actions.py b/tests/test_attended_actions.py index b6f5b7ef..13aa0c10 100644 --- a/tests/test_attended_actions.py +++ b/tests/test_attended_actions.py @@ -51,8 +51,10 @@ from openadapt_flow.qualification import ( ActionRiskClassification, EnvironmentBoundary, + QualifiedEntityLabel, init_project, set_action_classification, + set_entity_label, workflow_contract_sha256, ) from openadapt_flow.runtime.authorization import ( @@ -317,7 +319,7 @@ def _request(capability, action="continue", key="request-key-0001"): ) -def _remote_deployment() -> DeploymentConfig: +def _remote_deployment(**remote: object) -> DeploymentConfig: return DeploymentConfig.model_validate( { "human_decisions": { @@ -325,6 +327,7 @@ def _remote_deployment() -> DeploymentConfig: "enabled": True, "tenant_id": "tenant_exact_01", "runner_id": "runner_exact_01", + **remote, } } } @@ -3185,6 +3188,75 @@ def test_remote_projection_is_explicit_aal2_phi_free_and_exactly_bound(tmp_path) assert protected not in serialized +def test_remote_v2_requires_explicit_peer_negotiation_and_exact_label_binding(tmp_path): + workflow = Workflow(name="attended-v2", steps=[_step("humanstep", "A")]) + workflow, bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) + init_project( + workflow, + environment=EnvironmentBoundary( + target_kind="web", + application="qualified-app", + application_version="1", + environment_digest="a" * 64, + runtime_version="1.26.0", + ), + ) + set_entity_label( + workflow, + QualifiedEntityLabel( + step_id="humanstep", label="service record", fallback="record" + ), + ) + workflow.save(bundle) + item = attention_item(run.parent, run) + assert item is not None + + v1 = portable_remote_decision_task(run, item, deployment=_remote_deployment()) + assert v1.task.schema_version == "openadapt.human-decision-task/v1" + + v2 = portable_remote_decision_task( + run, + item, + deployment=_remote_deployment( + peer_task_schemas=["openadapt.human-decision-task/v2"] + ), + ) + assert v2.task.schema_version == "openadapt.human-decision-task/v2" + assert v2.task.entity.label == "service record" + assert v2.task.entity.fallback.value == "record" + assert v2.task.qualification_step_id == "humanstep" + assert v2.task.qualification_project_id == workflow.qualification.project_id + assert v2.task.qualification_contract_digest == ( + "sha256:" + workflow.qualification.contract_sha256() + ) + + +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")]) + workflow, bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) + init_project( + workflow, + environment=EnvironmentBoundary( + target_kind="web", + application="qualified-app", + application_version="1", + environment_digest="a" * 64, + runtime_version="1.26.0", + ), + ) + workflow.save(bundle) + item = attention_item(run.parent, run) + assert item is not None + projection = portable_remote_decision_task( + run, + item, + deployment=_remote_deployment( + peer_task_schemas=["openadapt.human-decision-task/v2"] + ), + ) + assert projection.task.schema_version == "openadapt.human-decision-task/v1" + + def test_remote_response_refuses_scope_or_binding_drift(tmp_path): _workflow, _bundle, run, _store, capability = _paused(tmp_path) item = attention_item(run.parent, run) diff --git a/tests/test_qualification_project.py b/tests/test_qualification_project.py index fe9e3209..a51e5b13 100644 --- a/tests/test_qualification_project.py +++ b/tests/test_qualification_project.py @@ -73,6 +73,7 @@ QualificationError, QualificationOutcome, QualificationRefusalCode, + QualifiedEntityLabel, RequalificationCondition, VerificationTier, add_case, @@ -81,11 +82,14 @@ current_certification_matches, evaluate_qualification, init_project, + list_entity_labels, qualification_action_requirements, record_case_results, + remove_entity_label, set_action_classification, set_case_scope, set_effect_policy, + set_entity_label, set_identity_policy, set_minimum_effect_tier, set_trusted_fault_driver_key, @@ -184,6 +188,36 @@ def _environment() -> EnvironmentBoundary: ) +def test_entity_labels_are_qualification_contract_and_invalidate_certification(): + workflow = _workflow() + project = init_project(workflow, environment=_environment()) + before = project.contract_sha256() + certification = QualificationCertification( + project_revision=project.revision, + project_contract_sha256=before, + workflow_contract_sha256="a" * 64, + environment_contract_sha256="c" * 64, + policy_name="clinical-write", + policy_contract_sha256="b" * 64, + passed=True, + report_sha256="d" * 64, + certified_at="2026-07-01T00:00:00+00:00", + ) + project.last_certification = certification + + set_entity_label( + workflow, + QualifiedEntityLabel(step_id="save", label="service record", fallback="record"), + ) + assert project.entity_labels["save"].label == "service record" + assert project.contract_sha256() != before + assert project.last_certification is None + assert list_entity_labels(workflow) == [project.entity_labels["save"]] + + remove_entity_label(workflow, "save") + assert project.entity_labels == {} + + def test_api_only_qualification_uses_only_executable_targets() -> None: effect = Effect( kind=EffectKind.FIELD_EQUALS, diff --git a/uv.lock b/uv.lock index 94aeb234..14a8d98b 100644 --- a/uv.lock +++ b/uv.lock @@ -2243,9 +2243,9 @@ requires-dist = [ { name = "openadapt-capture", marker = "extra == 'capture'", specifier = ">=1.2.0" }, { name = "openadapt-grounding", marker = "extra == 'grounding'", specifier = ">=0.1.0" }, { name = "openadapt-privacy", extras = ["presidio"], marker = "extra == 'privacy'", specifier = ">=1.0.0" }, - { name = "openadapt-types", marker = "extra == 'console'", specifier = ">=0.7.0,<0.8.0" }, - { name = "openadapt-types", marker = "extra == 'dev'", specifier = ">=0.7.0,<0.8.0" }, - { name = "openadapt-types", marker = "extra == 'interop'", specifier = ">=0.7.0,<0.8.0" }, + { name = "openadapt-types", marker = "extra == 'console'", specifier = ">=0.8.0,<0.9.0" }, + { name = "openadapt-types", marker = "extra == 'dev'", specifier = ">=0.8.0,<0.9.0" }, + { name = "openadapt-types", marker = "extra == 'interop'", specifier = ">=0.8.0,<0.9.0" }, { name = "opencv-python-headless", specifier = ">=4.9" }, { name = "pillow", specifier = ">=10.0" }, { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.44" }, @@ -2310,14 +2310,14 @@ presidio = [ [[package]] name = "openadapt-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/bf/39ec24f545b3fc09bdff8cc61ca448bb968450c43a99f8a6808e538a3c57/openadapt_types-0.7.0.tar.gz", hash = "sha256:80a470aa4e8870d2a2b2645f2dbfac04ab9f158fb6d21b314d9eff0296a0ee2f", size = 126130, upload-time = "2026-07-29T19:32:31.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/48/b0c75be18f1532461ac1d1026b91d07e5fca919dbea2d3308e1051e38c73/openadapt_types-0.8.0.tar.gz", hash = "sha256:ad595cbf829e60db4fb967139ec37d98f97c1dfe8de1ef8058588b71ef4d9a60", size = 129628 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/39/b78fcd51096939884db01ea10bb4594a9e88aaa7d40c3eff537cfba8d971/openadapt_types-0.7.0-py3-none-any.whl", hash = "sha256:4805f2f97dbe46c15874d0fbf88dc9bf94a430b2b9a2f3e7ed24cddef013b7f2", size = 81743, upload-time = "2026-07-29T19:32:29.584Z" }, + { url = "https://files.pythonhosted.org/packages/41/f9/a888c50eab410f165357934c9b36bd3a4e4fd430ca5443e36deb1e75854d/openadapt_types-0.8.0-py3-none-any.whl", hash = "sha256:8598526691a7d40bd570563a595cd763acdbb45d25675f6e8674238f339d7172", size = 85722 }, ] [[package]] From f33094cb16b6de60d72323d948d98b9cb4f709f7 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 29 Jul 2026 18:09:40 -0400 Subject: [PATCH 2/7] fix: bind decision task v2 to pause authority --- openadapt_flow/console/human_decisions.py | 12 +++- tests/test_attended_actions.py | 81 ++++++++++++++++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/openadapt_flow/console/human_decisions.py b/openadapt_flow/console/human_decisions.py index f1c41678..356a638f 100644 --- a/openadapt_flow/console/human_decisions.py +++ b/openadapt_flow/console/human_decisions.py @@ -395,22 +395,27 @@ def _qualified_entity_v2_fields( run_dir: Path, *, step_id: Optional[str], + capability: Any, ) -> Optional[dict[str, Any]]: - """Read a V2 entity only from the sealed project and its exact step. + """Read a V2 entity 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 binding falls back to V1. + or mismatched capability/bundle binding falls back to V1. This gate proves + integrity sealing, not qualification certification. """ - if step_id is None: + if step_id is None or step_id != capability.step_id: return None try: from openadapt_flow.runtime.durable.checkpoint import CheckpointStore + from openadapt_flow.runtime.durable.program_checkpoint import bundle_version manifest = CheckpointStore(run_dir).read_manifest() if manifest is None: return None + if bundle_version(manifest.bundle_dir) != capability.bundle_version: + return None workflow, _ = data.load_workflow_safe(Path(manifest.bundle_dir)) project = workflow.qualification if workflow is not None else None if project is None: @@ -605,6 +610,7 @@ def _task_and_presentation( _qualified_entity_v2_fields( run_dir, step_id=failed.step_id if failed is not None else None, + capability=capability, ) if _peer_negotiated_v2(deployment) else None diff --git a/tests/test_attended_actions.py b/tests/test_attended_actions.py index 13aa0c10..968565ca 100644 --- a/tests/test_attended_actions.py +++ b/tests/test_attended_actions.py @@ -3190,7 +3190,6 @@ def test_remote_projection_is_explicit_aal2_phi_free_and_exactly_bound(tmp_path) def test_remote_v2_requires_explicit_peer_negotiation_and_exact_label_binding(tmp_path): workflow = Workflow(name="attended-v2", steps=[_step("humanstep", "A")]) - workflow, bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) init_project( workflow, environment=EnvironmentBoundary( @@ -3207,7 +3206,7 @@ def test_remote_v2_requires_explicit_peer_negotiation_and_exact_label_binding(tm step_id="humanstep", label="service record", fallback="record" ), ) - workflow.save(bundle) + workflow, _bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) item = attention_item(run.parent, run) assert item is not None @@ -3233,7 +3232,6 @@ def test_remote_v2_requires_explicit_peer_negotiation_and_exact_label_binding(tm 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")]) - workflow, bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) init_project( workflow, environment=EnvironmentBoundary( @@ -3244,6 +3242,83 @@ def test_remote_v2_falls_back_when_the_exact_failed_step_has_no_label(tmp_path): runtime_version="1.26.0", ), ) + workflow, _bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) + item = attention_item(run.parent, run) + assert item is not None + projection = portable_remote_decision_task( + run, + item, + deployment=_remote_deployment( + peer_task_schemas=["openadapt.human-decision-task/v2"] + ), + ) + assert projection.task.schema_version == "openadapt.human-decision-task/v1" + + +def test_remote_v2_falls_back_when_report_step_differs_from_capability(tmp_path): + workflow = Workflow( + name="attended-v2", + steps=[_step("humanstep", "A"), _step("otherstep", "B")], + ) + init_project( + workflow, + environment=EnvironmentBoundary( + target_kind="web", + application="qualified-app", + application_version="1", + environment_digest="a" * 64, + runtime_version="1.26.0", + ), + ) + for step_id in ("humanstep", "otherstep"): + set_entity_label( + workflow, + QualifiedEntityLabel( + step_id=step_id, label="service record", fallback="record" + ), + ) + _workflow, _bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) + report_path = run / "report.json" + report_payload = json.loads(report_path.read_text(encoding="utf-8")) + report_payload["results"][0]["step_id"] = "otherstep" + report_path.write_text(json.dumps(report_payload), encoding="utf-8") + item = attention_item(run.parent, run) + assert item is not None + projection = portable_remote_decision_task( + run, + item, + deployment=_remote_deployment( + peer_task_schemas=["openadapt.human-decision-task/v2"] + ), + ) + assert projection.task.schema_version == "openadapt.human-decision-task/v1" + + +def test_remote_v2_falls_back_after_the_paused_bundle_changes(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", + ), + ) + set_entity_label( + workflow, + QualifiedEntityLabel( + step_id="humanstep", label="service record", fallback="record" + ), + ) + workflow, bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) + set_entity_label( + workflow, + QualifiedEntityLabel( + step_id="humanstep", label="insurance claim", fallback="item" + ), + ) workflow.save(bundle) item = attention_item(run.parent, run) assert item is not None From 48a5c1f93d7ac446ad2caa215485f1af4ec09cd6 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 29 Jul 2026 18:24:29 -0400 Subject: [PATCH 3/7] feat: require current qualification for decision task v2 --- openadapt_flow/console/human_decisions.py | 28 +++++- tests/test_attended_actions.py | 112 +++++++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/openadapt_flow/console/human_decisions.py b/openadapt_flow/console/human_decisions.py index 356a638f..ef544489 100644 --- a/openadapt_flow/console/human_decisions.py +++ b/openadapt_flow/console/human_decisions.py @@ -418,7 +418,33 @@ def _qualified_entity_v2_fields( return None workflow, _ = data.load_workflow_safe(Path(manifest.bundle_dir)) project = workflow.qualification if workflow is not None else None - if project is None: + certification = project.last_certification if project is not None else None + if ( + workflow is None + or project is None + or certification is None + or not certification.passed + ): + return None + from openadapt_flow.qualification import ( + current_certification_matches, + workflow_contract_sha256, + ) + + # Certification is the existing authority for qualification approval. + # Check its direct bindings before asking it to independently recompute + # the signed-case and policy decision from this exact sealed workflow. + if ( + certification.project_revision != project.revision + or certification.project_contract_sha256 != project.contract_sha256() + or certification.workflow_contract_sha256 + != workflow_contract_sha256(workflow) + or certification.policy_contract_sha256 is None + or not current_certification_matches( + workflow, + policy_contract_digest=certification.policy_contract_sha256, + ) + ): return None entity = project.entity_labels.get(step_id) if entity is None: diff --git a/tests/test_attended_actions.py b/tests/test_attended_actions.py index 968565ca..ee91fd5a 100644 --- a/tests/test_attended_actions.py +++ b/tests/test_attended_actions.py @@ -47,10 +47,12 @@ Transition, Workflow, ) +from openadapt_flow.policy import load_policy, policy_contract_sha256 from openadapt_flow.privacy import reset_scrubbers, set_image_scrubber from openadapt_flow.qualification import ( ActionRiskClassification, EnvironmentBoundary, + QualificationCertification, QualifiedEntityLabel, init_project, set_action_classification, @@ -3188,9 +3190,9 @@ def test_remote_projection_is_explicit_aal2_phi_free_and_exactly_bound(tmp_path) assert protected not in serialized -def test_remote_v2_requires_explicit_peer_negotiation_and_exact_label_binding(tmp_path): +def _v2_candidate_workflow() -> Workflow: workflow = Workflow(name="attended-v2", steps=[_step("humanstep", "A")]) - init_project( + project = init_project( workflow, environment=EnvironmentBoundary( target_kind="web", @@ -3206,6 +3208,36 @@ def test_remote_v2_requires_explicit_peer_negotiation_and_exact_label_binding(tm step_id="humanstep", label="service record", fallback="record" ), ) + policy = load_policy("permissive") + project.last_certification = QualificationCertification( + project_revision=project.revision, + project_contract_sha256=project.contract_sha256(), + workflow_contract_sha256=workflow_contract_sha256(workflow), + environment_contract_sha256=project.environment.contract_sha256(), + policy_name=policy.name, + policy_contract_sha256=policy_contract_sha256(policy), + policy_contract=policy.model_dump(mode="json"), + passed=True, + report_sha256="a" * 64, + case_evidence_contract_sha256="b" * 64, + ) + return workflow + + +def _accept_current_certification(monkeypatch) -> None: + monkeypatch.setattr( + "openadapt_flow.qualification.current_certification_matches", + lambda _workflow, *, policy=None, policy_contract_digest=None: ( + policy is None and policy_contract_digest is not None + ), + ) + + +def test_remote_v2_requires_explicit_peer_negotiation_and_exact_label_binding( + tmp_path, monkeypatch +): + _accept_current_certification(monkeypatch) + workflow = _v2_candidate_workflow() workflow, _bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) item = attention_item(run.parent, run) assert item is not None @@ -3255,6 +3287,82 @@ def test_remote_v2_falls_back_when_the_exact_failed_step_has_no_label(tmp_path): assert projection.task.schema_version == "openadapt.human-decision-task/v1" +def test_remote_v2_falls_back_without_a_current_certification(tmp_path, monkeypatch): + _accept_current_certification(monkeypatch) + workflow = _v2_candidate_workflow() + assert workflow.qualification is not None + workflow.qualification.last_certification = None + _workflow, _bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) + item = attention_item(run.parent, run) + assert item is not None + projection = portable_remote_decision_task( + run, + item, + deployment=_remote_deployment( + peer_task_schemas=["openadapt.human-decision-task/v2"] + ), + ) + assert projection.task.schema_version == "openadapt.human-decision-task/v1" + + +@pytest.mark.parametrize("field", ("project_revision", "project_contract_sha256")) +def test_remote_v2_falls_back_for_a_stale_certification(tmp_path, field, monkeypatch): + _accept_current_certification(monkeypatch) + workflow = _v2_candidate_workflow() + assert workflow.qualification is not None + certification = workflow.qualification.last_certification + assert certification is not None + if field == "project_revision": + certification.project_revision += 1 + else: + certification.project_contract_sha256 = "0" * 64 + _workflow, _bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) + item = attention_item(run.parent, run) + assert item is not None + projection = portable_remote_decision_task( + run, + item, + deployment=_remote_deployment( + peer_task_schemas=["openadapt.human-decision-task/v2"] + ), + ) + assert projection.task.schema_version == "openadapt.human-decision-task/v1" + + +def test_remote_v2_falls_back_when_a_current_certification_has_new_bundle_bytes( + tmp_path, monkeypatch +): + _accept_current_certification(monkeypatch) + workflow = _v2_candidate_workflow() + workflow, bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) + assert workflow.qualification is not None + certification = workflow.qualification.last_certification + assert ( + certification is not None and certification.policy_contract_sha256 is not None + ) + # `certified_at` is not an input to qualification evaluation. This changes + # sealed bundle bytes while leaving the existing certification current. + certification.certified_at = "2026-07-30T00:00:00+00:00" + workflow.save(bundle) + current = Workflow.load(bundle) + from openadapt_flow import qualification + + assert qualification.current_certification_matches( + current, + policy_contract_digest=certification.policy_contract_sha256, + ) + item = attention_item(run.parent, run) + assert item is not None + projection = portable_remote_decision_task( + run, + item, + deployment=_remote_deployment( + peer_task_schemas=["openadapt.human-decision-task/v2"] + ), + ) + assert projection.task.schema_version == "openadapt.human-decision-task/v1" + + def test_remote_v2_falls_back_when_report_step_differs_from_capability(tmp_path): workflow = Workflow( name="attended-v2", From 55f8459a538cf6b503158bb180489c01fd0133b7 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 29 Jul 2026 18:26:55 -0400 Subject: [PATCH 4/7] docs: clarify decision task v2 certification gate --- openadapt_flow/console/human_decisions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openadapt_flow/console/human_decisions.py b/openadapt_flow/console/human_decisions.py index ef544489..e5f1d248 100644 --- a/openadapt_flow/console/human_decisions.py +++ b/openadapt_flow/console/human_decisions.py @@ -401,8 +401,8 @@ def _qualified_entity_v2_fields( 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 proves - integrity sealing, not qualification certification. + 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: From 3433c41b827ab7fc55c2cbd0b526a6e6aa10f601 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 29 Jul 2026 18:34:37 -0400 Subject: [PATCH 5/7] feat: guide label changes through recertification --- openadapt_flow/__main__.py | 36 ++++++++++++++++++++------- tests/test_qualification_project.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index fc6b780c..81ea0358 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -2092,19 +2092,28 @@ def _cmd_qualify(args: argparse.Namespace) -> int: ) ) return 0 + changed = False if args.label_cmd == "set": - set_entity_label( - workflow, - QualifiedEntityLabel( - step_id=args.step, label=args.label, fallback=args.fallback - ), + label = QualifiedEntityLabel( + step_id=args.step, label=args.label, fallback=args.fallback ) + assert workflow.qualification is not None + changed = workflow.qualification.entity_labels.get(args.step) != label + set_entity_label(workflow, label) elif args.label_cmd == "remove": + assert workflow.qualification is not None + changed = args.step in workflow.qualification.entity_labels remove_entity_label(workflow, args.step) else: # pragma: no cover - argparse requires a known command. raise SystemExit(f"unknown qualification label command {args.label_cmd!r}") save_qualified_workflow(workflow, args.bundle) print(workflow.qualification.model_dump_json(indent=2)) + if changed: + print( + "Certification invalidated. Run `openadapt-flow qualify certify " + " --evidence-root ` before production V2 tasks.", + file=sys.stderr, + ) return 0 if verb == "init": @@ -3914,13 +3923,22 @@ def build_parser() -> argparse.ArgumentParser: 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.add_argument("bundle", help="Workflow bundle directory") - label.add_argument("--step", required=True) - label.add_argument("--label", required=True) - label.add_argument("--fallback", choices=("record", "item"), required=True) + label.add_argument("--step", required=True, help="Exact qualified workflow step ID") + label.add_argument( + "--label", + required=True, + 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") - label.add_argument("--step", required=True) + label.add_argument("--step", required=True, help="Exact qualified workflow step ID") label.set_defaults(func=_cmd_qualify) label = label_sub.add_parser("list", help="List qualification-owned entity labels") label.add_argument("bundle", help="Workflow bundle directory") diff --git a/tests/test_qualification_project.py b/tests/test_qualification_project.py index a51e5b13..64d7f9c7 100644 --- a/tests/test_qualification_project.py +++ b/tests/test_qualification_project.py @@ -2908,6 +2908,44 @@ def test_cli_initializes_project_without_raw_manifest_editing( assert '"representative_case_missing"' in payload +def test_cli_entity_label_notice_tracks_real_mutations( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + workflow = _workflow() + bundle = tmp_path / "bundle" + workflow.save(bundle) + init_project(workflow, environment=_environment()) + workflow.save(bundle) + + set_args = [ + "qualify", + "label", + "set", + str(bundle), + "--step", + "save", + "--label", + "insurance claim", + "--fallback", + "item", + ] + assert main(set_args) == 0 + first = capsys.readouterr() + assert json.loads(first.out)["entity_labels"]["save"]["label"] == "insurance claim" + assert first.err + + assert main(set_args) == 0 + unchanged = capsys.readouterr() + assert json.loads(unchanged.out)["entity_labels"]["save"]["fallback"] == "item" + assert not unchanged.err + + assert main(["qualify", "label", "remove", str(bundle), "--step", "save"]) == 0 + removed = capsys.readouterr() + assert json.loads(removed.out)["entity_labels"] == {} + assert removed.err + + def test_cli_identity_extract_pattern_round_trips_exactly(tmp_path: Path) -> None: workflow = _workflow() bundle = tmp_path / "bundle" From f8cc0688739d60a0489d689b6716cfea98190f4d Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 29 Jul 2026 18:44:10 -0400 Subject: [PATCH 6/7] feat: restrict decision entities to reviewed classes --- openadapt_flow/__main__.py | 5 ++++ openadapt_flow/console/human_decisions.py | 3 +- openadapt_flow/qualification.py | 25 +++++++++++++++- tests/test_attended_actions.py | 35 ++++++++++++++++++++--- tests/test_qualification_project.py | 33 +++++++++++++++++++-- 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index 81ea0358..fbb3fc61 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -2045,6 +2045,7 @@ def _cmd_qualify(args: argparse.Namespace) -> int: from pydantic import TypeAdapter from openadapt_flow.qualification import ( + REMOTE_SAFE_ENTITY_LABELS, ActionRiskClass, ActionRiskClassification, EnvironmentBoundary, @@ -2152,6 +2153,7 @@ def _cmd_qualify(args: argparse.Namespace) -> int: else None ), "report": report.model_dump(mode="json"), + "remote_safe_entity_labels": list(REMOTE_SAFE_ENTITY_LABELS), } print(json.dumps(payload, indent=2, sort_keys=True)) else: @@ -3920,6 +3922,8 @@ def build_parser() -> argparse.ArgumentParser: "label", help="Set, remove, or list qualification-owned 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.add_argument("bundle", help="Workflow bundle directory") @@ -3927,6 +3931,7 @@ def build_parser() -> argparse.ArgumentParser: label.add_argument( "--label", required=True, + choices=REMOTE_SAFE_ENTITY_LABELS, help="Qualification-approved class label, for example: insurance claim", ) label.add_argument( diff --git a/openadapt_flow/console/human_decisions.py b/openadapt_flow/console/human_decisions.py index e5f1d248..5cc1def2 100644 --- a/openadapt_flow/console/human_decisions.py +++ b/openadapt_flow/console/human_decisions.py @@ -427,6 +427,7 @@ def _qualified_entity_v2_fields( ): return None from openadapt_flow.qualification import ( + REMOTE_SAFE_ENTITY_LABELS, current_certification_matches, workflow_contract_sha256, ) @@ -447,7 +448,7 @@ def _qualified_entity_v2_fields( ): return None entity = project.entity_labels.get(step_id) - if entity is None: + if entity is None or entity.label not in REMOTE_SAFE_ENTITY_LABELS: return None return { "qualification_project_id": project.project_id, diff --git a/openadapt_flow/qualification.py b/openadapt_flow/qualification.py index c18b65d6..8d8f2df1 100644 --- a/openadapt_flow/qualification.py +++ b/openadapt_flow/qualification.py @@ -59,6 +59,23 @@ _CONTEXT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$") _QUALIFIED_ENTITY_LABEL_RE = re.compile(r"^[a-z][a-z0-9]*(?:[ _-][a-z0-9]+){0,3}$") +#: The only qualification-approved class labels that a remote decision task +#: may carry. These are class names, never observed identities or identifiers. +REMOTE_SAFE_ENTITY_LABELS: Final[tuple[str, ...]] = ( + "patient record", + "member record", + "insurance claim", + "loan application", + "customer account", + "service request", + "case", + "order", + "invoice", + "document", + "record", + "item", +) + def _qualification_actuation_path( value: Optional[str], @@ -715,7 +732,11 @@ class QualifiedEntityLabel(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) step_id: str = Field(pattern=_ID_RE.pattern) - label: str = Field(min_length=1, max_length=63) + label: str = Field( + min_length=1, + max_length=63, + json_schema_extra={"enum": list(REMOTE_SAFE_ENTITY_LABELS)}, + ) fallback: Literal["record", "item"] @field_validator("label") @@ -725,6 +746,8 @@ def _safe_label(cls, value: str) -> str: raise ValueError( "entity label must use the qualified neutral-label vocabulary" ) + if value not in REMOTE_SAFE_ENTITY_LABELS: + raise ValueError("entity label is not a reviewed remote-safe class") return value diff --git a/tests/test_attended_actions.py b/tests/test_attended_actions.py index ee91fd5a..0a742c26 100644 --- a/tests/test_attended_actions.py +++ b/tests/test_attended_actions.py @@ -3205,7 +3205,7 @@ def _v2_candidate_workflow() -> Workflow: set_entity_label( workflow, QualifiedEntityLabel( - step_id="humanstep", label="service record", fallback="record" + step_id="humanstep", label="patient record", fallback="record" ), ) policy = load_policy("permissive") @@ -3253,7 +3253,7 @@ def test_remote_v2_requires_explicit_peer_negotiation_and_exact_label_binding( ), ) assert v2.task.schema_version == "openadapt.human-decision-task/v2" - assert v2.task.entity.label == "service record" + assert v2.task.entity.label == "patient record" assert v2.task.entity.fallback.value == "record" assert v2.task.qualification_step_id == "humanstep" assert v2.task.qualification_project_id == workflow.qualification.project_id @@ -3363,6 +3363,33 @@ def test_remote_v2_falls_back_when_a_current_certification_has_new_bundle_bytes( assert projection.task.schema_version == "openadapt.human-decision-task/v1" +def test_remote_v2_falls_back_for_a_legacy_unreviewed_entity_label( + tmp_path, monkeypatch +): + _accept_current_certification(monkeypatch) + workflow = _v2_candidate_workflow() + workflow, _bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) + assert workflow.qualification is not None + entity = workflow.qualification.entity_labels["humanstep"] + # Simulate a pre-vocabulary project object that bypassed current model + # validation. The persisted bundle remains valid for the version check. + object.__setattr__(entity, "label", "legacy unknown") + monkeypatch.setattr( + "openadapt_flow.console.human_decisions.data.load_workflow_safe", + lambda _bundle: (workflow, None), + ) + item = attention_item(run.parent, run) + assert item is not None + projection = portable_remote_decision_task( + run, + item, + deployment=_remote_deployment( + peer_task_schemas=["openadapt.human-decision-task/v2"] + ), + ) + assert projection.task.schema_version == "openadapt.human-decision-task/v1" + + def test_remote_v2_falls_back_when_report_step_differs_from_capability(tmp_path): workflow = Workflow( name="attended-v2", @@ -3382,7 +3409,7 @@ def test_remote_v2_falls_back_when_report_step_differs_from_capability(tmp_path) set_entity_label( workflow, QualifiedEntityLabel( - step_id=step_id, label="service record", fallback="record" + step_id=step_id, label="patient record", fallback="record" ), ) _workflow, _bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) @@ -3417,7 +3444,7 @@ def test_remote_v2_falls_back_after_the_paused_bundle_changes(tmp_path): set_entity_label( workflow, QualifiedEntityLabel( - step_id="humanstep", label="service record", fallback="record" + step_id="humanstep", label="patient record", fallback="record" ), ) workflow, bundle, run, _store, _capability = _paused(tmp_path, workflow=workflow) diff --git a/tests/test_qualification_project.py b/tests/test_qualification_project.py index 64d7f9c7..94105acb 100644 --- a/tests/test_qualification_project.py +++ b/tests/test_qualification_project.py @@ -58,6 +58,7 @@ policy_contract_sha256, ) from openadapt_flow.qualification import ( + REMOTE_SAFE_ENTITY_LABELS, ActionRiskClass, ActionRiskClassification, EnvironmentBoundary, @@ -207,9 +208,9 @@ def test_entity_labels_are_qualification_contract_and_invalidate_certification() set_entity_label( workflow, - QualifiedEntityLabel(step_id="save", label="service record", fallback="record"), + QualifiedEntityLabel(step_id="save", label="patient record", fallback="record"), ) - assert project.entity_labels["save"].label == "service record" + assert project.entity_labels["save"].label == "patient record" assert project.contract_sha256() != before assert project.last_certification is None assert list_entity_labels(workflow) == [project.entity_labels["save"]] @@ -218,6 +219,23 @@ def test_entity_labels_are_qualification_contract_and_invalidate_certification() assert project.entity_labels == {} +@pytest.mark.parametrize("label", REMOTE_SAFE_ENTITY_LABELS) +def test_entity_label_accepts_each_reviewed_remote_safe_class(label: str) -> None: + entity = QualifiedEntityLabel(step_id="save", label=label, fallback="record") + assert entity.label == label + + +@pytest.mark.parametrize( + "label", + ("jane smith", "patient 004219", "account 1234", "unknown class"), +) +def test_entity_label_refuses_identity_identifier_and_unknown_classes( + label: str, +) -> None: + with pytest.raises(ValueError, match="reviewed remote-safe class"): + QualifiedEntityLabel(step_id="save", label=label, fallback="record") + + def test_api_only_qualification_uses_only_executable_targets() -> None: effect = Effect( kind=EffectKind.FIELD_EQUALS, @@ -2906,6 +2924,9 @@ def test_cli_initializes_project_without_raw_manifest_editing( assert main(["qualify", "explain", str(bundle), "--json"]) == 2 payload = capsys.readouterr().out assert '"representative_case_missing"' in payload + assert set(json.loads(payload)["remote_safe_entity_labels"]) == set( + REMOTE_SAFE_ENTITY_LABELS + ) def test_cli_entity_label_notice_tracks_real_mutations( @@ -2930,6 +2951,14 @@ def test_cli_entity_label_notice_tracks_real_mutations( "--fallback", "item", ] + invalid_args = [*set_args] + invalid_args[invalid_args.index("insurance claim")] = "jane smith" + with pytest.raises(SystemExit): + main(invalid_args) + rejected = capsys.readouterr() + assert rejected.err + assert Workflow.load(bundle).qualification.entity_labels == {} + assert main(set_args) == 0 first = capsys.readouterr() assert json.loads(first.out)["entity_labels"]["save"]["label"] == "insurance claim" From 3665fe392b23ac3c03e6f411aa34ca08e0a5ff83 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 29 Jul 2026 18:50:49 -0400 Subject: [PATCH 7/7] feat: expose canonical entity label options --- openadapt_flow/__main__.py | 4 +- openadapt_flow/console/human_decisions.py | 11 ++++- openadapt_flow/qualification.py | 58 +++++++++++++++++------ tests/test_qualification_project.py | 43 ++++++++++++++--- 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/openadapt_flow/__main__.py b/openadapt_flow/__main__.py index fbb3fc61..467f3bca 100644 --- a/openadapt_flow/__main__.py +++ b/openadapt_flow/__main__.py @@ -2045,7 +2045,6 @@ def _cmd_qualify(args: argparse.Namespace) -> int: from pydantic import TypeAdapter from openadapt_flow.qualification import ( - REMOTE_SAFE_ENTITY_LABELS, ActionRiskClass, ActionRiskClassification, EnvironmentBoundary, @@ -2061,6 +2060,7 @@ def _cmd_qualify(args: argparse.Namespace) -> int: add_case, add_requalification_condition, certify_project, + entity_label_options, init_project, list_entity_labels, project_schema, @@ -2153,7 +2153,7 @@ def _cmd_qualify(args: argparse.Namespace) -> int: else None ), "report": report.model_dump(mode="json"), - "remote_safe_entity_labels": list(REMOTE_SAFE_ENTITY_LABELS), + "entity_label_options": entity_label_options(), } print(json.dumps(payload, indent=2, sort_keys=True)) else: diff --git a/openadapt_flow/console/human_decisions.py b/openadapt_flow/console/human_decisions.py index 5cc1def2..ba3e766d 100644 --- a/openadapt_flow/console/human_decisions.py +++ b/openadapt_flow/console/human_decisions.py @@ -427,8 +427,8 @@ def _qualified_entity_v2_fields( ): return None from openadapt_flow.qualification import ( - REMOTE_SAFE_ENTITY_LABELS, current_certification_matches, + entity_label_options, workflow_contract_sha256, ) @@ -448,7 +448,14 @@ def _qualified_entity_v2_fields( ): return None entity = project.entity_labels.get(step_id) - if entity is None or entity.label not in REMOTE_SAFE_ENTITY_LABELS: + if ( + entity is None + or { + "label": entity.label, + "fallback": entity.fallback, + } + not in entity_label_options() + ): return None return { "qualification_project_id": project.project_id, diff --git a/openadapt_flow/qualification.py b/openadapt_flow/qualification.py index 8d8f2df1..8fbc662c 100644 --- a/openadapt_flow/qualification.py +++ b/openadapt_flow/qualification.py @@ -59,21 +59,35 @@ _CONTEXT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$") _QUALIFIED_ENTITY_LABEL_RE = re.compile(r"^[a-z][a-z0-9]*(?:[ _-][a-z0-9]+){0,3}$") -#: The only qualification-approved class labels that a remote decision task -#: may carry. These are class names, never observed identities or identifiers. -REMOTE_SAFE_ENTITY_LABELS: Final[tuple[str, ...]] = ( - "patient record", - "member record", - "insurance claim", - "loan application", - "customer account", - "service request", - "case", - "order", - "invoice", - "document", - "record", - "item", + +def entity_label_options() -> list[dict[str, str]]: + """Return the reviewed remote-safe class labels and their fallbacks. + + This is the public, JSON-serializable source for Desktop and Flow. The + values are presentation classes only; no identity value or identifier may + enter this API. + """ + + return [ + {"label": "patient record", "fallback": "record"}, + {"label": "member record", "fallback": "record"}, + {"label": "insurance claim", "fallback": "item"}, + {"label": "loan application", "fallback": "item"}, + {"label": "customer account", "fallback": "record"}, + {"label": "service request", "fallback": "item"}, + {"label": "case", "fallback": "item"}, + {"label": "order", "fallback": "item"}, + {"label": "invoice", "fallback": "item"}, + {"label": "document", "fallback": "item"}, + {"label": "record", "fallback": "record"}, + {"label": "item", "fallback": "item"}, + ] + + +#: Derived compatibility view of :func:`entity_label_options` for callers that +#: need only labels. Do not add an independent list of remote-safe classes. +REMOTE_SAFE_ENTITY_LABELS: Final[tuple[str, ...]] = tuple( + option["label"] for option in entity_label_options() ) @@ -750,6 +764,20 @@ def _safe_label(cls, value: str) -> str: raise ValueError("entity label is not a reviewed remote-safe class") return value + @model_validator(mode="after") + def _canonical_fallback(self) -> "QualifiedEntityLabel": + expected = next( + ( + option["fallback"] + for option in entity_label_options() + if option["label"] == self.label + ), + None, + ) + if self.fallback != expected: + raise ValueError("entity fallback must match the reviewed class mapping") + return self + class QualificationProject(BaseModel): """Versioned qualification configuration sealed inside a Flow bundle.""" diff --git a/tests/test_qualification_project.py b/tests/test_qualification_project.py index 94105acb..91791e52 100644 --- a/tests/test_qualification_project.py +++ b/tests/test_qualification_project.py @@ -81,9 +81,11 @@ add_requalification_condition, certify_project, current_certification_matches, + entity_label_options, evaluate_qualification, init_project, list_entity_labels, + project_schema, qualification_action_requirements, record_case_results, remove_entity_label, @@ -219,10 +221,39 @@ def test_entity_labels_are_qualification_contract_and_invalidate_certification() assert project.entity_labels == {} -@pytest.mark.parametrize("label", REMOTE_SAFE_ENTITY_LABELS) -def test_entity_label_accepts_each_reviewed_remote_safe_class(label: str) -> None: - entity = QualifiedEntityLabel(step_id="save", label=label, fallback="record") - assert entity.label == label +def test_entity_label_options_are_ordered_and_derive_every_public_label() -> None: + options = entity_label_options() + assert options == [ + {"label": "patient record", "fallback": "record"}, + {"label": "member record", "fallback": "record"}, + {"label": "insurance claim", "fallback": "item"}, + {"label": "loan application", "fallback": "item"}, + {"label": "customer account", "fallback": "record"}, + {"label": "service request", "fallback": "item"}, + {"label": "case", "fallback": "item"}, + {"label": "order", "fallback": "item"}, + {"label": "invoice", "fallback": "item"}, + {"label": "document", "fallback": "item"}, + {"label": "record", "fallback": "record"}, + {"label": "item", "fallback": "item"}, + ] + assert REMOTE_SAFE_ENTITY_LABELS == tuple(option["label"] for option in options) + assert project_schema()["$defs"]["QualifiedEntityLabel"]["properties"]["label"][ + "enum" + ] == list(REMOTE_SAFE_ENTITY_LABELS) + + +@pytest.mark.parametrize("option", entity_label_options()) +def test_entity_label_accepts_each_reviewed_remote_safe_class( + option: dict[str, str], +) -> None: + entity = QualifiedEntityLabel(step_id="save", **option) + assert entity.model_dump() == {"step_id": "save", **option} + + +def test_entity_label_refuses_a_noncanonical_fallback() -> None: + with pytest.raises(ValueError, match="fallback"): + QualifiedEntityLabel(step_id="save", label="insurance claim", fallback="record") @pytest.mark.parametrize( @@ -2924,9 +2955,7 @@ def test_cli_initializes_project_without_raw_manifest_editing( assert main(["qualify", "explain", str(bundle), "--json"]) == 2 payload = capsys.readouterr().out assert '"representative_case_missing"' in payload - assert set(json.loads(payload)["remote_safe_entity_labels"]) == set( - REMOTE_SAFE_ENTITY_LABELS - ) + assert json.loads(payload)["entity_label_options"] == entity_label_options() def test_cli_entity_label_notice_tracks_real_mutations(