Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions openadapt_flow/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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")
Expand Down
26 changes: 13 additions & 13 deletions openadapt_flow/console/human_decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions openadapt_flow/qualification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
37 changes: 17 additions & 20 deletions tests/test_attended_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
29 changes: 27 additions & 2 deletions tests/test_qualification_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == [
Expand Down Expand Up @@ -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"
Expand Down
Loading