Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/BYOC_CONNECTOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Cloud token creation is authenticated and organization-scoped in the dashboard.
bundle binding.
* **Up (to the control plane):** PHI-free status/metrics + a storage *path* into
the customer's own store. **Never** the report body, screenshots, OCR text, or
a patient identifier.
a record identifier.
* **The bundle and report bytes** are read from / written to the **customer's own
storage** (`--storage-root`, a local encrypted volume). Our control plane holds
no URL to them and signs no access.
Expand Down
38 changes: 36 additions & 2 deletions docs/DECISION_DELIVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,48 @@ evidence.
a vocabulary the engine already owns — `Rung`, `ActionKind`, the console's halt
categories, the ARIA/UIA role names, `RecheckKind` — or is a bounded integer or
a boolean. A relay that stores this object is *structurally incapable* of
representing a patient name, an MRN, an observed value, a path, or a workflow
label.
representing a person name, a record identifier, an observed value, a path, or
a workflow label.

That is the same kind of guarantee `HumanDecisionTaskV1` already gives, and it
is checkable the same way: by reading a schema, not by trusting a detector. The
hosted control plane enforces it a third time in Postgres, in the same style as
`human_decision_task_contract_valid`.

## V2: qualification-approved entity wording

V1 uses only domain-neutral wording such as `record` or `item`. A negotiated V2
task can carry one useful entity class that the exact qualification contract
already approved. The remote emitter accepts only the reviewed remote-safe
vocabulary.
The canonical vocabulary and cross-vertical examples are in
[Attended decisions and the halt-learn loop](https://docs.openadapt.ai/concepts/halt-learn-loop/#a-qualified-entity-label-not-a-guessed-domain).

The entity class is optional. A person or a qualification agent can set it once
for a workflow version. A reviewed class such as `insurance claim` can cross the
remote boundary. A custom local class remains inside the bundle; the V2 task
carries only its signed neutral `record` or `item` fallback. With no label, the
task uses the signed neutral `record` class. This presentation choice does not
block qualification, certification, or execution. A label change is a contract
change and requires recertification before the new V2 task can be emitted.

This is not runtime inference. The producer reads the label from the exact
qualified step and binds the task to the qualification project, qualification
revision, qualification contract digest, and bundle digest. The task has no
field for a screenshot, OCR output, parameter, application name, observed
identity value, or model input. A label says what class of entity the workflow
handles; it never says which entity is on screen.

The runner and client use V2 only after explicit schema negotiation. A peer that
does not negotiate V2 receives the byte-compatible V1 task and renders `record`
or `item`. Before any action continues, the customer-controlled runner reads
the live application and revalidates the required identity and effect
contracts.

The V2 producer uses the released `openadapt-types` 0.9 contract. A consumer
must explicitly negotiate that schema. Otherwise, Flow emits the byte-compatible
V1 task.

The one thing this tier gives up is `target_label` — the target control's own
accessible name, which `halt_detail._safe_target_label` releases locally after
six independent proofs. It stays local. The phone therefore says *"OpenAdapt
Expand Down
2 changes: 1 addition & 1 deletion docs/ECOSYSTEM_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ The string values are byte-identical, so a flow `Step` could emit/ingest an
- `Resolution` — which ladder rung resolved the target (`template/…/grounder`), point,
confidence, `elapsed_ms`.
- `IdentityCheck` — the pre-click same-entity verdict (`verified/mismatch/abstain/
unreadable` × `structured/pixel/vlm/context/param`). This is the wrong-patient safety
unreadable` × `structured/pixel/vlm/context/param`). This is the wrong-entity safety
core.
- `HealEvent`, `StepResult`, `RunReport`, `UnarmedStep` — audit/telemetry.
- `risk∈{reversible,irreversible}`, `identity_armed` — the halt-on-uncertainty gates.
Expand Down
99 changes: 99 additions & 0 deletions openadapt_flow/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2464,17 +2464,22 @@ def _cmd_qualify(args: argparse.Namespace) -> int:
QualificationCaseKind,
QualificationCaseResult,
QualificationOutcome,
QualifiedEntityLabel,
RequalificationCondition,
VerificationTier,
add_case,
add_requalification_condition,
certify_project,
entity_label_options,
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,
)
Expand All @@ -2488,6 +2493,62 @@ 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
changed = False
if args.label_cmd == "set":
reviewed_fallback = next(
(
option["fallback"]
for option in entity_label_options()
if option["label"] == args.label
),
None,
)
if (
reviewed_fallback is not None
and args.fallback is not None
and args.fallback != reviewed_fallback
):
raise SystemExit(
f"{args.label!r} uses the reviewed fallback {reviewed_fallback!r}"
)
fallback = reviewed_fallback or args.fallback or "record"
try:
label = QualifiedEntityLabel(
step_id=args.step, label=args.label, fallback=fallback
)
except ValueError as exc:
raise SystemExit(f"invalid entity label: {exc}") from exc
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 "
"<bundle> --evidence-root <path>` before production V2 tasks.",
file=sys.stderr,
)
return 0

if verb == "init":
environment = EnvironmentBoundary(
target_kind=args.target,
Expand Down Expand Up @@ -2524,6 +2585,7 @@ def _cmd_qualify(args: argparse.Namespace) -> int:
else None
),
"report": report.model_dump(mode="json"),
"entity_label_options": entity_label_options(),
}
print(json.dumps(payload, indent=2, sort_keys=True))
else:
Expand Down Expand Up @@ -4300,6 +4362,43 @@ 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="Optionally set, remove, or list presentation-only entity labels",
)
label_sub = q.add_subparsers(dest="label_cmd", required=True)
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(
"--label",
required=True,
help=(
"Local class label, for example: insurance claim. Reviewed labels "
"can cross a remote boundary; other labels use the neutral fallback."
),
)
label.add_argument(
"--fallback",
choices=("record", "item"),
default=None,
help=(
"Neutral remote label for a custom class (default: record). "
"Reviewed labels use their canonical fallback."
),
)
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, 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")
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(
Expand Down
116 changes: 112 additions & 4 deletions openadapt_flow/console/human_decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -389,6 +391,98 @@ def _remote_delivery_tier(
raise AttendedActionRefused(str(exc)) from exc


def _qualified_entity_v2_fields(
run_dir: Path,
*,
step_id: Optional[str],
capability: Any,
) -> Optional[dict[str, Any]]:
"""Read V2 bindings only from the current integrity-sealed project.

This deliberately reads no screenshots, OCR, accessibility observation,
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:
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
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,
remote_entity_label,
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)
entity_payload = remote_entity_label(entity)
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_payload,
}
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,
Expand Down Expand Up @@ -540,8 +634,22 @@ 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,
capability=capability,
)
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


Expand Down Expand Up @@ -605,7 +713,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
Expand Down
10 changes: 10 additions & 0 deletions openadapt_flow/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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


Expand Down
Loading