diff --git a/docs/DECISION_PORTAL.md b/docs/DECISION_PORTAL.md index 25dd942..944522d 100644 --- a/docs/DECISION_PORTAL.md +++ b/docs/DECISION_PORTAL.md @@ -58,6 +58,31 @@ commit recorded in They use synthetic data and a public OpenEMR reference frame. They are not a customer run and do not claim that the complete workflow reached `VERIFIED`. +## Add a human decision during qualification + +A workflow does not need a separate policy author or manual JSON edit. In the +Desktop qualification cockpit, an operator can add a decision before an +existing workflow step. The form records: + +- one reviewed question; +- the roles that can answer it; +- two to four finite answers; +- the exact qualified successor for each answer; +- optional local evidence that an answer requires; +- the answer expiry; and +- the live application check that must still pass before execution continues. + +Desktop sends these fields to Flow's canonical +`openadapt.business-decision/v1` contract. Flow derives the guarded graph +transitions. Saving the change advances the qualification revision and +invalidates the prior certification. The qualification cases must pass again +before production use. + +This decision is optional. A workflow that has no institutional-knowledge +branch does not need one. One signed answer selects one qualified branch for +one run. It does not prove the business effect and it does not change the +workflow policy for later runs. + ## Network boundary | Setting | Default | Meaning | diff --git a/engine/dispatch.py b/engine/dispatch.py index 9bcc8cf..02f5978 100644 --- a/engine/dispatch.py +++ b/engine/dispatch.py @@ -239,6 +239,8 @@ def _register(self) -> None: "bind_qualification_effect": self.bind_qualification_effect, "set_qualification_effect_verification": (self.set_qualification_effect_verification), "set_qualification_minimum_effect_tier": (self.set_qualification_minimum_effect_tier), + "author_qualification_business_decision": (self.author_qualification_business_decision), + "set_qualification_judgment_cases": (self.set_qualification_judgment_cases), "add_qualification_case": self.add_qualification_case, "run_qualification_case": self.run_qualification_case, "import_qualification_results": self.import_qualification_results, @@ -1963,6 +1965,101 @@ def set_qualification_minimum_effect_tier(self, **params: Any) -> dict: except Exception as exc: return {"ok": False, "workflow_id": workflow_id, "error": str(exc)} + def author_qualification_business_decision(self, **params: Any) -> dict: + """Add or update one Flow-owned typed decision from form fields.""" + + from engine.qualification import ( + DEFAULT_QUALIFICATION_POLICY, + author_business_decision, + ) + + workflow_id = str(params.get("workflow_id") or "") + policy = str(params.get("policy") or DEFAULT_QUALIFICATION_POLICY) + raw_roles = params.get("authorized_roles") or [] + raw_options = params.get("options") or [] + raw_requirements = params.get("evidence_requirements") or [] + try: + if not isinstance(raw_roles, list): + raise ValueError("authorized_roles must be a list") + if not isinstance(raw_options, list) or not all( + isinstance(item, dict) for item in raw_options + ): + raise ValueError("options must be a list of decision option objects") + if not isinstance(raw_requirements, list) or not all( + isinstance(item, dict) for item in raw_requirements + ): + raise ValueError("evidence_requirements must be a list of evidence objects") + bundle = self._qualification_bundle_dir(workflow_id) + result = author_business_decision( + bundle, + workflow_id=workflow_id, + graph_id=str(params.get("graph_id") or ""), + state_id=str(params.get("state_id") or ""), + question=str(params.get("question") or ""), + authorized_roles=[str(item) for item in raw_roles], + output_param=str(params.get("output_param") or ""), + options=raw_options, + evidence_requirements=raw_requirements, + expires_after_s=int(params.get("expires_after_s", 3600)), + revalidation_kind=str(params.get("revalidation_kind") or ""), + revalidation_text=( + str(params["revalidation_text"]) + if params.get("revalidation_text") is not None + else None + ), + revalidation_state_id=( + str(params["revalidation_state_id"]) + if params.get("revalidation_state_id") is not None + else None + ), + insert_before_state_id=( + str(params["insert_before_state_id"]) + if params.get("insert_before_state_id") is not None + else None + ), + policy_source=policy, + bundle_key=self._qualification_bundle_key(workflow_id), + ) + self.services.db.update_bundle(workflow_id, status="qualification_pending") + return result + except Exception as exc: + return {"ok": False, "workflow_id": workflow_id, "error": str(exc)} + + def set_qualification_judgment_cases(self, **params: Any) -> dict: + """Save Flow-owned local judgment cases. No runtime decision is created.""" + + from engine.qualification import ( + DEFAULT_QUALIFICATION_POLICY, + set_judgment_cases, + ) + + workflow_id = str(params.get("workflow_id") or "") + policy = str(params.get("policy") or DEFAULT_QUALIFICATION_POLICY) + raw_schemas = params.get("schemas") or [] + raw_cases = params.get("cases") or [] + try: + if not isinstance(raw_schemas, list) or not all( + isinstance(item, dict) for item in raw_schemas + ): + raise ValueError("schemas must be a list of local fact-schema objects") + if not isinstance(raw_cases, list) or not all( + isinstance(item, dict) for item in raw_cases + ): + raise ValueError("cases must be a list of local judgment-case objects") + bundle = self._qualification_bundle_dir(workflow_id) + result = set_judgment_cases( + bundle, + workflow_id=workflow_id, + schemas=raw_schemas, + cases=raw_cases, + policy_source=policy, + bundle_key=self._qualification_bundle_key(workflow_id), + ) + self.services.db.update_bundle(workflow_id, status="qualification_pending") + return result + except Exception as exc: + return {"ok": False, "workflow_id": workflow_id, "error": str(exc)} + def add_qualification_case(self, **params: Any) -> dict: """Add a typed case and keep its optional parameter fixture local.""" diff --git a/engine/qualification.py b/engine/qualification.py index 6a3f20d..35a0909 100644 --- a/engine/qualification.py +++ b/engine/qualification.py @@ -122,6 +122,11 @@ def _flow_api() -> dict[str, Any]: "set_effect_policy": set_effect_policy, "set_identity_policy": set_identity_policy, "set_minimum_effect_tier": set_minimum_effect_tier, + "set_business_decision": getattr(flow_qualification, "set_business_decision", None), + "set_judgment_cases": getattr(flow_qualification, "set_judgment_cases", None), + "evaluate_judgment_case_qualification": getattr( + flow_qualification, "evaluate_judgment_case_qualification", None + ), "workflow_contract_sha256": workflow_contract_sha256, } @@ -393,7 +398,212 @@ def _qualification_controls(workflow, graph: dict[str, Any]) -> dict[str, Any]: for index, effect in enumerate(step.effects) ], } - return {"parameters": parameters, "actions": actions} + return { + "parameters": parameters, + "actions": actions, + "business_decisions": _business_decision_controls(workflow), + "judgment_cases": _judgment_case_controls(workflow), + } + + +def _business_decision_controls(workflow) -> dict[str, Any]: + """Project Flow's exact decision contracts and safe graph choices. + + The projection is unavailable on older frozen Flow builds. It never + invents a Desktop decision schema and it does not expose action anchors or + live values. Authoring remains available for the other qualification + controls when this one capability is absent. + """ + + setter = _flow_api().get("set_business_decision") + try: + from openadapt_flow.ir import StateKind, lift_to_program + + business_kind = StateKind.BUSINESS_DECISION + except (ImportError, AttributeError): + return { + "available": False, + "required_flow_capability": "qualification.set_business_decision", + "graphs": [], + } + if setter is None: + return { + "available": False, + "required_flow_capability": "qualification.set_business_decision", + "graphs": [], + } + + main_program = workflow.program or lift_to_program(workflow) + graphs = [] + for graph_id, program in [("__program__", main_program)] + list(workflow.subflows.items()): + if program is None: + continue + inbound_counts = {state_id: 0 for state_id in program.states} + for state in program.states.values(): + for transition in state.transitions: + if transition.target in inbound_counts: + inbound_counts[transition.target] += 1 + states = [] + for state_id, state in program.states.items(): + if state.kind.value == "action" and state.step is not None: + title = state.step.intent or f"{state.step.action.value} action" + has_anchor = state.step.anchor is not None + elif state.kind is business_kind and state.decision is not None: + title = state.decision.question + has_anchor = False + elif state.kind.value == "terminal": + title = f"{state.outcome or 'terminal'} outcome" + has_anchor = False + else: + title = state.kind.value.replace("_", " ") + has_anchor = False + decision_view = None + if state.kind is business_kind and state.decision is not None: + predicates = list(state.decision.revalidation) + revalidation_view: dict[str, Any] | None = None + if len(predicates) == 1: + predicate = predicates[0] + if predicate.kind.value == "text_present" and predicate.text: + revalidation_view = { + "kind": "text_present", + "text": predicate.text, + "state_id": None, + } + elif predicate.kind.value == "anchor_resolves" and predicate.anchor: + matching_states = [ + candidate_id + for candidate_id, candidate in program.states.items() + if candidate.step is not None + and candidate.step.anchor == predicate.anchor + ] + if len(matching_states) == 1: + revalidation_view = { + "kind": "anchor_resolves", + "text": None, + "state_id": matching_states[0], + } + decision_view = { + "schema_version": state.decision.schema_version, + "question": state.decision.question, + "authorized_roles": list(state.decision.authorized_roles), + "output_param": state.decision.output_param, + "options": [item.model_dump(mode="json") for item in state.decision.options], + "evidence_requirements": [ + item.model_dump(mode="json") + for item in state.decision.evidence_requirements + ], + "expires_after_s": state.decision.expires_after_s, + "revalidation": revalidation_view, + "editable": ( + revalidation_view is not None and 2 <= len(state.decision.options) <= 4 + ), + } + states.append( + { + "id": state_id, + "kind": state.kind.value, + "title": title, + "has_revalidation_anchor": has_anchor, + "can_insert_before": ( + state.kind is not business_kind + and ( + (program.entry == state_id and inbound_counts[state_id] == 0) + or (program.entry != state_id and inbound_counts[state_id] == 1) + ) + ), + "decision": decision_view, + } + ) + graphs.append( + { + "id": graph_id, + "label": "Main workflow" if graph_id == "__program__" else graph_id, + "entry": program.entry, + "states": states, + } + ) + return { + "available": True, + "required_flow_capability": "qualification.set_business_decision", + "graphs": graphs, + } + + +def _judgment_case_controls(workflow) -> dict[str, Any]: + """Project Flow-owned local judgment cases without exposing their artifacts. + + This capability is additive. Older embedded Flow builds retain all direct + decision authoring controls and return a precise upgrade state here. + """ + + api = _flow_api() + setter = api.get("set_judgment_cases") + evaluator = api.get("evaluate_judgment_case_qualification") + if setter is None or evaluator is None or workflow.qualification is None: + return { + "available": False, + "required_flow_capability": "qualification.set_judgment_cases", + "contexts": [], + "report": None, + } + try: + from openadapt_flow.ir import StateKind, lift_to_program + except (ImportError, AttributeError): + return { + "available": False, + "required_flow_capability": "qualification.set_judgment_cases", + "contexts": [], + "report": None, + } + + project = workflow.qualification + schemas = { + (item.graph_id, item.state_id): item.fact_schema + for item in project.judgment_fact_schemas + } + cases = list(project.judgment_cases) + workflow_digest = api["workflow_contract_sha256"](workflow) + main_program = workflow.program or lift_to_program(workflow) + contexts = [] + for graph_id, graph in [("__program__", main_program), *workflow.subflows.items()]: + if graph is None: + continue + for state_id, state in graph.states.items(): + if state.kind is not StateKind.BUSINESS_DECISION or state.decision is None: + continue + schema = schemas.get((graph_id, state_id)) + if schema is None: + continue + contexts.append( + { + "decision": { + "graph_id": graph_id, + "state_id": state_id, + "workflow_contract_sha256": workflow_digest, + "decision_contract_sha256": state.decision.contract_sha256(), + }, + "fact_schema": schema.model_dump(mode="json"), + "fact_schema_sha256": schema.contract_sha256(), + "options": [item.model_dump(mode="json") for item in state.decision.options], + "authorized_roles": list(state.decision.authorized_roles), + "cases": [ + item.model_dump(mode="json") + for item in cases + if item.decision.graph_id == graph_id + and item.decision.state_id == state_id + ], + } + ) + try: + report = evaluator(workflow).model_dump(mode="json") + except (ValueError, TypeError) as exc: + raise QualificationError(f"Cannot evaluate local judgment cases: {exc}") from exc + return { + "available": True, + "required_flow_capability": "qualification.set_judgment_cases", + "contexts": contexts, + "report": report, + } def _capability_coverage( @@ -896,6 +1106,187 @@ def set_project_minimum_effect_tier( ) +def author_business_decision( + bundle_dir: Path, + *, + workflow_id: str, + graph_id: str, + state_id: str, + question: str, + authorized_roles: list[str], + output_param: str, + options: list[dict[str, Any]], + evidence_requirements: list[dict[str, Any]], + expires_after_s: int, + revalidation_kind: str, + revalidation_text: str | None = None, + revalidation_state_id: str | None = None, + insert_before_state_id: str | None = None, + policy_source: str = DEFAULT_QUALIFICATION_POLICY, + bundle_key: str | None = None, +) -> dict: + """Author one typed Flow decision without raw workflow JSON. + + The Desktop request contains only form fields. Flow constructs and + validates the exact IR contract, derives all option transitions, advances + the qualification revision, and invalidates prior certification. + """ + + api = _flow_api() + setter = api.get("set_business_decision") + if setter is None: + raise QualificationError( + "This Desktop build needs a Flow runtime with " + "qualification.set_business_decision before it can author typed " + "business decisions. Other qualification controls remain available." + ) + try: + from openadapt_flow.ir import ( + BusinessDecisionEvidenceRequirement, + BusinessDecisionOption, + BusinessDecisionSpec, + Predicate, + PredicateKind, + lift_to_program, + ) + except (ImportError, AttributeError) as exc: + raise QualificationError( + "The bundled Flow runtime does not include the typed business-decision IR." + ) from exc + + workflow = _load(bundle_dir, key=bundle_key) + if graph_id == "__program__": + graph = workflow.program or lift_to_program(workflow) + else: + graph = workflow.subflows.get(graph_id) + if graph is None: + raise QualificationError(f"Unknown executable graph {graph_id!r}") + + if revalidation_kind == "text_present": + text = (revalidation_text or "").strip() + if not text: + raise QualificationError("Visible revalidation text is required") + revalidation = Predicate( + kind=PredicateKind.TEXT_PRESENT, + text=text, + intent="the reviewed application state remains visible", + ) + elif revalidation_kind == "anchor_resolves": + source_state = graph.states.get(revalidation_state_id or "") + anchor = ( + source_state.step.anchor + if source_state is not None and source_state.step is not None + else None + ) + if anchor is None: + raise QualificationError( + "Choose an action that has a retained target for live revalidation" + ) + revalidation = Predicate( + kind=PredicateKind.ANCHOR_RESOLVES, + anchor=anchor.model_copy(deep=True), + intent="the reviewed target still resolves in the live application", + ) + else: + raise QualificationError("revalidation_kind must be text_present or anchor_resolves") + + try: + requirements = tuple( + BusinessDecisionEvidenceRequirement( + id=str(item.get("id") or "").strip(), + label=str(item.get("label") or "").strip(), + ) + for item in evidence_requirements + ) + exact_options = tuple( + BusinessDecisionOption( + id=str(item.get("id") or "").strip(), + label=str(item.get("label") or "").strip(), + value=str(item.get("value") or "").strip(), + target=str(item.get("target") or "").strip(), + required_evidence=tuple( + str(value).strip() for value in (item.get("required_evidence") or []) + ), + ) + for item in options + ) + decision = BusinessDecisionSpec( + question=question.strip(), + authorized_roles=tuple(role.strip() for role in authorized_roles), + output_param=output_param.strip(), + options=exact_options, + evidence_requirements=requirements, + expires_after_s=expires_after_s, + revalidation=(revalidation,), + ) + authored = setter( + workflow, + graph_id=graph_id, + state_id=state_id.strip(), + decision=decision, + insert_before_state_id=( + insert_before_state_id.strip() if insert_before_state_id else None + ), + ) + _save(authored, bundle_dir, key=bundle_key) + except (QualificationError, ValueError, TypeError) as exc: + raise QualificationError(str(exc)) from exc + return inspect_bundle( + bundle_dir, + workflow_id=workflow_id, + policy_source=policy_source, + bundle_key=bundle_key, + ) + + +def set_judgment_cases( + bundle_dir: Path, + *, + workflow_id: str, + schemas: list[dict[str, Any]], + cases: list[dict[str, Any]], + policy_source: str = DEFAULT_QUALIFICATION_POLICY, + bundle_key: str | None = None, +) -> dict: + """Persist only Flow-validated local judgment cases and fact schemas. + + The caller supplies reviewed local references. This function never accepts + screenshot bytes, record values outside declared facts, a generated rule, + or a runtime answer. + """ + + api = _flow_api() + setter = api.get("set_judgment_cases") + if setter is None: + raise QualificationError( + "This Desktop build needs a Flow runtime with " + "qualification.set_judgment_cases before it can save judgment cases." + ) + try: + from openadapt_flow.judgment_cases import ( + JudgmentCaseV1, + JudgmentFactSchemaBindingV1, + ) + except ImportError as exc: + raise QualificationError( + "The bundled Flow runtime does not include local judgment-case qualification." + ) from exc + try: + workflow = _load(bundle_dir, key=bundle_key) + exact_schemas = [JudgmentFactSchemaBindingV1.model_validate(item) for item in schemas] + exact_cases = [JudgmentCaseV1.model_validate(item) for item in cases] + setter(workflow, schemas=exact_schemas, cases=exact_cases) + _save(workflow, bundle_dir, key=bundle_key) + except (QualificationError, ValueError, TypeError) as exc: + raise QualificationError(str(exc)) from exc + return inspect_bundle( + bundle_dir, + workflow_id=workflow_id, + policy_source=policy_source, + bundle_key=bundle_key, + ) + + def bind_action_effect( bundle_dir: Path, *, diff --git a/src/lib/engine.ts b/src/lib/engine.ts index 3b53a60..2d3444b 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -44,6 +44,9 @@ export const CMD = { "set_qualification_effect_verification", SET_QUALIFICATION_MINIMUM_EFFECT_TIER: "set_qualification_minimum_effect_tier", + AUTHOR_QUALIFICATION_BUSINESS_DECISION: + "author_qualification_business_decision", + SET_QUALIFICATION_JUDGMENT_CASES: "set_qualification_judgment_cases", ADD_QUALIFICATION_CASE: "add_qualification_case", RUN_QUALIFICATION_CASE: "run_qualification_case", IMPORT_QUALIFICATION_RESULTS: "import_qualification_results", diff --git a/src/lib/types.ts b/src/lib/types.ts index 2c6f2b7..87e3ce4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -252,6 +252,149 @@ export interface QualificationNode { badges: string[]; } +export interface QualificationBusinessDecisionOption { + id: string; + label: string; + value: string; + target: string; + required_evidence: string[]; +} + +export interface QualificationBusinessDecisionContract { + schema_version: "openadapt.business-decision/v1"; + question: string; + authorized_roles: string[]; + output_param: string; + options: QualificationBusinessDecisionOption[]; + evidence_requirements: { id: string; label: string }[]; + expires_after_s: number; + revalidation: { + kind: "text_present" | "anchor_resolves"; + text: string | null; + state_id: string | null; + } | null; + editable: boolean; +} + +export interface QualificationBusinessDecisionState { + id: string; + kind: string; + title: string; + has_revalidation_anchor: boolean; + can_insert_before: boolean; + decision: QualificationBusinessDecisionContract | null; +} + +export interface QualificationBusinessDecisionControls { + available: boolean; + required_flow_capability: "qualification.set_business_decision"; + graphs: { + id: string; + label: string; + entry: string; + states: QualificationBusinessDecisionState[]; + }[]; +} + +/** + * Local-only qualification evidence for an institutional-judgment case. + * + * A reference never contains a screenshot, a record value, or free text. The + * Desktop and Flow resolve it inside the customer boundary. + */ +export interface LocalEvidenceRefV1 { + relative_path: string; + sha256: string; + kind: "frame" | "recording" | "report" | "document" | "system_read"; +} + +export type JudgmentFactTypeV1 = + | "boolean" + | "integer" + | "number" + | "string" + | "enum"; + +export interface JudgmentFactFieldV1 { + type: JudgmentFactTypeV1; + allowed_values?: string[]; +} + +export interface JudgmentFactSchemaV1 { + schema_version: "openadapt.judgment-fact-schema/v1"; + fields: Record; +} + +export interface JudgmentFactSchemaBindingV1 { + graph_id: string; + state_id: string; + fact_schema: JudgmentFactSchemaV1; +} + +export interface JudgmentCaseProvenanceV1 { + source: string; + source_ref_sha256: string; + reviewer_role: string; + reviewer_principal_ref_sha256: string; +} + +export interface JudgmentDecisionBindingV1 { + graph_id: string; + state_id: string; + workflow_contract_sha256: string; + decision_contract_sha256: string; +} + +export type JudgmentDispositionV1 = + | "automatic_rule" + | "human_node" + | "more_evidence_required"; + +/** The exact local qualification payload owned and validated by Flow. */ +export interface JudgmentCaseV1 { + id: string; + decision: JudgmentDecisionBindingV1; + fact_schema_sha256: string; + facts: Record; + local_evidence: LocalEvidenceRefV1[]; + review_note_ref?: LocalEvidenceRefV1 | null; + provenance: JudgmentCaseProvenanceV1; + disposition: JudgmentDispositionV1; + reviewed_rule_id?: string | null; + option_id?: string | null; + contrast_case_ids: string[]; +} + +/** + * Flow supplies this local read model. It is intentionally not a portable + * decision-task type and it cannot authorize a runtime answer. + */ +export interface JudgmentCaseCaptureContextV1 { + decision: JudgmentDecisionBindingV1; + fact_schema: JudgmentFactSchemaV1; + fact_schema_sha256: string; + options: { id: string; label: string }[]; + authorized_roles: string[]; + cases: JudgmentCaseV1[]; +} + +export interface JudgmentCaseQualificationReportV1 { + schema_version: "openadapt.judgment-case-report/v1"; + workflow_contract_sha256: string; + passed: boolean; + case_count: number; + automatic_case_count: number; + retained_human_authority_count: number; + findings: { code: string; case_id?: string | null; message: string }[]; +} + +export interface QualificationJudgmentCaseControls { + available: boolean; + required_flow_capability: "qualification.set_judgment_cases"; + contexts: JudgmentCaseCaptureContextV1[]; + report: JudgmentCaseQualificationReportV1 | null; +} + export interface QualificationViolation { rule: string; step_id?: string | null; @@ -387,6 +530,8 @@ export interface QualificationProject { controls: { parameters: QualificationParameter[]; actions: Record; + business_decisions: QualificationBusinessDecisionControls; + judgment_cases: QualificationJudgmentCaseControls; }; } diff --git a/src/screens/Qualification.test.tsx b/src/screens/Qualification.test.tsx index 998e9e3..8225523 100644 --- a/src/screens/Qualification.test.tsx +++ b/src/screens/Qualification.test.tsx @@ -206,4 +206,91 @@ describe("Qualification effect requirements", () => { ), ); }); + + it("saves an atomic reciprocal automatic-rule pair through the Flow-owned case command", async () => { + const project = projectWithTiers({ review: 3, submit: 2 }); + (project.controls as Record).judgment_cases = { + available: true, + required_flow_capability: "qualification.set_judgment_cases", + report: null, + contexts: [{ + decision: { + graph_id: "__program__", + state_id: "review_decision", + workflow_contract_sha256: "a".repeat(64), + decision_contract_sha256: "b".repeat(64), + }, + fact_schema: { + schema_version: "openadapt.judgment-fact-schema/v1", + fields: { urgent: { type: "boolean" } }, + }, + fact_schema_sha256: "c".repeat(64), + options: [ + { id: "priority_review", label: "Priority review" }, + { id: "supervisor", label: "Supervisor" }, + ], + authorized_roles: ["supervisor"], + cases: [], + }], + }; + mockedEngineInvoke.mockResolvedValue(project); + + render( {}} />); + + await screen.findByText("Capture reviewed examples before you automate a choice"); + fireEvent.change(screen.getByLabelText("Local source SHA-256"), { + target: { value: "d".repeat(64) }, + }); + fireEvent.change(screen.getByLabelText("Local reviewer reference SHA-256"), { + target: { value: "e".repeat(64) }, + }); + fireEvent.click(screen.getByRole("button", { name: "Rule candidate" })); + fireEvent.change(screen.getByLabelText("Qualified branch for a rule candidate"), { + target: { value: "priority_review" }, + }); + fireEvent.change(screen.getByLabelText("Reviewed rule id"), { + target: { value: "urgent_policy" }, + }); + fireEvent.click(screen.getByLabelText("Add a contrasting reviewed case now")); + fireEvent.change(screen.getAllByLabelText("urgent")[1], { target: { value: "true" } }); + fireEvent.change(screen.getByLabelText("Qualified branch for contrasting case"), { + target: { value: "supervisor" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add local evidence" })); + fireEvent.change(screen.getByLabelText("Evidence reference 1 local path"), { + target: { value: "evidence/policy.pdf" }, + }); + fireEvent.change(screen.getByLabelText("Evidence reference 1 SHA-256"), { + target: { value: "f".repeat(64) }, + }); + fireEvent.click(screen.getByTestId("capture-judgment-case")); + + await waitFor(() => + expect(mockedEngineInvoke).toHaveBeenCalledWith( + CMD.SET_QUALIFICATION_JUDGMENT_CASES, + expect.objectContaining({ + workflow_id: "wf-1", + schemas: [expect.objectContaining({ state_id: "review_decision" })], + cases: [ + expect.objectContaining({ + disposition: "automatic_rule", + option_id: "priority_review", + contrast_case_ids: [expect.any(String)], + }), + expect.objectContaining({ + disposition: "automatic_rule", + option_id: "supervisor", + contrast_case_ids: [expect.any(String)], + }), + ], + }), + ), + ); + const call = mockedEngineInvoke.mock.calls.find( + ([command]) => command === CMD.SET_QUALIFICATION_JUDGMENT_CASES, + ); + const cases = call?.[1]?.cases as { id: string; contrast_case_ids: string[] }[]; + expect(cases[0].contrast_case_ids).toEqual([cases[1].id]); + expect(cases[1].contrast_case_ids).toEqual([cases[0].id]); + }); }); diff --git a/src/screens/Qualification.tsx b/src/screens/Qualification.tsx index bf7056e..5fec29a 100644 --- a/src/screens/Qualification.tsx +++ b/src/screens/Qualification.tsx @@ -15,6 +15,8 @@ import type { } from "../lib/types"; import { Button, Callout, Card, CardHead, Pill } from "../ui/primitives"; import { QualificationJourney } from "../ui/QualificationJourney"; +import { BusinessDecisionAuthoring } from "../ui/BusinessDecisionAuthoring"; +import { JudgmentCaseCapture } from "../ui/JudgmentCaseCapture"; import { QualificationLifecycle } from "./QualificationLifecycle"; const POLICY = "clinical-write"; @@ -1016,6 +1018,56 @@ export function Qualification({ + {project.project && ( + + )} + + {project.project && project.controls.judgment_cases?.available && ( + <> + {project.controls.judgment_cases.contexts.map((context) => ( + { + const schemas = project.controls.judgment_cases.contexts.map((item) => ({ + graph_id: item.decision.graph_id, + state_id: item.decision.state_id, + fact_schema: item.fact_schema, + })); + const cases = [ + ...project.controls.judgment_cases.contexts.flatMap((item) => item.cases), + ...caseItems, + ]; + const response = await engineInvoke( + CMD.SET_QUALIFICATION_JUDGMENT_CASES, + { + workflow_id: workflowId, + policy: project.policy, + schemas, + cases, + }, + ); + if (!response.ok) throw new Error(response.error); + setProject(response); + }} + /> + ))} + {project.controls.judgment_cases.contexts.length === 0 && ( + + + + )} + + )} + { + const original = await importOriginal(); + return { ...original, engineInvoke: vi.fn() }; +}); + +const mockedEngineInvoke = vi.mocked(engineInvoke); + +function project(): QualificationProject { + return { + ok: true, + workflow_id: "wf-1", + policy: "clinical-write", + project: { revision: 3 }, + controls: { + parameters: [], + actions: {}, + business_decisions: { + available: true, + required_flow_capability: "qualification.set_business_decision", + graphs: [ + { + id: "__program__", + label: "Main workflow", + entry: "prepare", + states: [ + { + id: "prepare", + kind: "action", + title: "Prepare item", + has_revalidation_anchor: true, + can_insert_before: true, + decision: null, + }, + { + id: "approved", + kind: "action", + title: "Continue approved path", + has_revalidation_anchor: true, + can_insert_before: true, + decision: null, + }, + { + id: "manual_review", + kind: "terminal", + title: "Send for manual review", + has_revalidation_anchor: false, + can_insert_before: false, + decision: null, + }, + ], + }, + ], + }, + }, + } as unknown as QualificationProject; +} + +describe("BusinessDecisionAuthoring", () => { + beforeEach(() => mockedEngineInvoke.mockReset()); + afterEach(cleanup); + + it("submits one finite branch contract through the Flow-owned authoring command", async () => { + const current = project(); + mockedEngineInvoke.mockResolvedValue(current); + const onProject = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Question for the operator"), { + target: { value: "Should this item continue on the approved path?" }, + }); + const labels = screen.getAllByLabelText("Answer shown to the operator"); + const values = screen.getAllByLabelText("Recorded value"); + const targets = screen.getAllByLabelText("Qualified next step"); + fireEvent.change(labels[0], { target: { value: "Continue" } }); + fireEvent.change(values[0], { target: { value: "approved" } }); + fireEvent.change(targets[0], { target: { value: "approved" } }); + fireEvent.change(labels[1], { target: { value: "Send for review" } }); + fireEvent.change(values[1], { target: { value: "manual_review" } }); + fireEvent.change(targets[1], { target: { value: "manual_review" } }); + fireEvent.change(screen.getByLabelText("Check"), { + target: { value: "text_present" }, + }); + fireEvent.change(screen.getByLabelText("Visible text"), { + target: { value: "Ready for review" }, + }); + fireEvent.click(screen.getByTestId("save-business-decision")); + + await waitFor(() => + expect(mockedEngineInvoke).toHaveBeenCalledWith( + CMD.AUTHOR_QUALIFICATION_BUSINESS_DECISION, + expect.objectContaining({ + graph_id: "__program__", + state_id: "review_decision", + insert_before_state_id: "prepare", + authorized_roles: ["operator", "supervisor"], + revalidation_kind: "text_present", + revalidation_text: "Ready for review", + options: [ + expect.objectContaining({ + label: "Continue", + value: "approved", + target: "approved", + }), + expect.objectContaining({ + label: "Send for review", + value: "manual_review", + target: "manual_review", + }), + ], + }), + ), + ); + expect(onProject).toHaveBeenCalledWith(current); + }); +}); diff --git a/src/ui/BusinessDecisionAuthoring.tsx b/src/ui/BusinessDecisionAuthoring.tsx new file mode 100644 index 0000000..189fd3e --- /dev/null +++ b/src/ui/BusinessDecisionAuthoring.tsx @@ -0,0 +1,705 @@ +import { useEffect, useMemo, useState } from "react"; +import { CMD, engineInvoke } from "../lib/engine"; +import type { + QualificationBusinessDecisionContract, + QualificationBusinessDecisionOption, + QualificationProject, + QualificationResponse, +} from "../lib/types"; +import { Button, Callout, Card, CardHead, Pill } from "./primitives"; + +interface OptionDraft { + key: string; + label: string; + value: string; + target: string; + requiredEvidence: string[]; +} + +interface EvidenceDraft { + key: string; + id: string; + label: string; +} + +const blankOptions = (): OptionDraft[] => [ + { + key: crypto.randomUUID(), + label: "", + value: "", + target: "", + requiredEvidence: [], + }, + { + key: crypto.randomUUID(), + label: "", + value: "", + target: "", + requiredEvidence: [], + }, +]; + +function optionDraft(option: QualificationBusinessDecisionOption): OptionDraft { + return { + key: option.id, + label: option.label, + value: option.value, + target: option.target, + requiredEvidence: [...option.required_evidence], + }; +} + +function safeId(value: string, fallback: string): string { + const id = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._:-]+/g, "_") + .replace(/^[_:.-]+|[_:.-]+$/g, "") + .slice(0, 128); + return id || fallback; +} + +function decisionKey(graphId: string, stateId: string): string { + return JSON.stringify([graphId, stateId]); +} + +export function BusinessDecisionAuthoring({ + workflowId, + project, + onProject, +}: { + workflowId: string; + project: QualificationProject; + onProject: (project: QualificationProject) => void; +}) { + const controls = project.controls.business_decisions || { + available: false, + required_flow_capability: "qualification.set_business_decision" as const, + graphs: [], + }; + const [graphId, setGraphId] = useState(controls.graphs[0]?.id || ""); + const [editingStateId, setEditingStateId] = useState(""); + const [insertBefore, setInsertBefore] = useState(""); + const [stateId, setStateId] = useState("review_decision"); + const [question, setQuestion] = useState(""); + const [roles, setRoles] = useState("operator, supervisor"); + const [outputParam, setOutputParam] = useState("review_outcome"); + const [expiryMinutes, setExpiryMinutes] = useState(60); + const [revalidationKind, setRevalidationKind] = useState< + "anchor_resolves" | "text_present" + >("anchor_resolves"); + const [revalidationState, setRevalidationState] = useState(""); + const [revalidationText, setRevalidationText] = useState(""); + const [options, setOptions] = useState(blankOptions); + const [evidence, setEvidence] = useState([]); + const [editingIsEditable, setEditingIsEditable] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const graph = useMemo( + () => controls.graphs.find((item) => item.id === graphId), + [controls.graphs, graphId], + ); + const insertionStates = useMemo( + () => graph?.states.filter((state) => state.can_insert_before) || [], + [graph], + ); + const targetStates = useMemo( + () => graph?.states.filter((state) => state.id !== editingStateId) || [], + [editingStateId, graph], + ); + const anchorStates = useMemo( + () => graph?.states.filter((state) => state.has_revalidation_anchor) || [], + [graph], + ); + const existingDecisions = useMemo( + () => + controls.graphs.flatMap((candidateGraph) => + candidateGraph.states + .filter((state) => state.decision) + .map((state) => ({ graph: candidateGraph, state })), + ), + [controls.graphs], + ); + const normalizedEvidenceIds = evidence.map((item) => safeId(item.id, "")); + const invalidEvidence = + evidence.some((item) => !item.id.trim() || !item.label.trim()) || + new Set(normalizedEvidenceIds).size !== normalizedEvidenceIds.length; + + useEffect(() => { + if (!insertionStates.some((state) => state.id === insertBefore)) { + setInsertBefore(insertionStates[0]?.id || ""); + } + if (!anchorStates.some((state) => state.id === revalidationState)) { + setRevalidationState(anchorStates[0]?.id || ""); + } + setOptions((current) => + current.map((option, index) => ({ + ...option, + target: targetStates.some((state) => state.id === option.target) + ? option.target + : targetStates[index]?.id || targetStates[0]?.id || "", + })), + ); + }, [anchorStates, insertBefore, insertionStates, revalidationState, targetStates]); + + function loadDecision( + nextGraphId: string, + nextStateId: string, + decision: QualificationBusinessDecisionContract, + ) { + setGraphId(nextGraphId); + setEditingStateId(nextStateId); + setStateId(nextStateId); + setQuestion(decision.question); + setRoles(decision.authorized_roles.join(", ")); + setOutputParam(decision.output_param); + setExpiryMinutes(Math.max(1, Math.round(decision.expires_after_s / 60))); + setOptions(decision.options.map(optionDraft)); + setEvidence( + decision.evidence_requirements.map((item) => ({ + key: item.id, + id: item.id, + label: item.label, + })), + ); + setEditingIsEditable(decision.editable); + if (decision.revalidation?.kind === "text_present") { + setRevalidationKind("text_present"); + setRevalidationText(decision.revalidation.text || ""); + } else { + setRevalidationKind("anchor_resolves"); + setRevalidationState(decision.revalidation?.state_id || ""); + } + setError(decision.editable ? "" : "This decision uses a revalidation contract that this form cannot safely replace."); + } + + function startNew() { + setEditingStateId(""); + setStateId("review_decision"); + setQuestion(""); + setRoles("operator, supervisor"); + setOutputParam("review_outcome"); + setExpiryMinutes(60); + setOptions(blankOptions()); + setEvidence([]); + setEditingIsEditable(true); + setError(""); + } + + function updateOption(key: string, update: Partial) { + setOptions((current) => + current.map((option) => (option.key === key ? { ...option, ...update } : option)), + ); + } + + async function save() { + if (!graph) return; + setBusy(true); + setError(""); + try { + const response = await engineInvoke( + CMD.AUTHOR_QUALIFICATION_BUSINESS_DECISION, + { + workflow_id: workflowId, + policy: project.policy, + graph_id: graph.id, + state_id: stateId, + insert_before_state_id: editingStateId ? undefined : insertBefore, + question, + authorized_roles: roles + .split(",") + .map((role) => role.trim()) + .filter(Boolean), + output_param: outputParam, + expires_after_s: expiryMinutes * 60, + evidence_requirements: evidence.map((item, index) => ({ + id: safeId(item.id, `evidence_${index + 1}`), + label: item.label, + })), + options: options.map((option, index) => ({ + id: safeId(option.value || option.label, `option_${index + 1}`), + label: option.label, + value: option.value, + target: option.target, + required_evidence: option.requiredEvidence.map((key) => { + const item = evidence.find((candidate) => candidate.key === key); + return item ? safeId(item.id, "evidence") : key; + }), + })), + revalidation_kind: revalidationKind, + revalidation_state_id: + revalidationKind === "anchor_resolves" ? revalidationState : undefined, + revalidation_text: + revalidationKind === "text_present" ? revalidationText : undefined, + }, + ); + if (!response.ok) { + setError(response.error); + return; + } + onProject(response); + setEditingStateId(stateId); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + } + + if (!controls.available) { + return ( + + + + Update the embedded OpenAdapt Flow runtime to author typed decisions. Risk, + identity, effect, case, certification, and deployment controls remain + available in this build. + + + ); + } + + return ( + + + + + One signed answer selects one declared successor for this run. The runner + checks the live application again before it continues. The successor still + needs its normal authorization, identity, postcondition, and effect checks. + An answer does not change future workflow policy. + + + {existingDecisions.length > 0 && ( +
+ +
+ + {editingStateId && } +
+
+ )} + +
+
+ + +
+
+ + {editingStateId ? ( + + ) : ( + + )} +
+
+ +
+ +