From cacb90f8564c0acdeeee4c28c929ac08e6a7980f Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 8 Aug 2026 21:13:51 +0200 Subject: [PATCH 1/6] feat(qualification): add business decision authoring --- docs/DECISION_PORTAL.md | 25 + engine/dispatch.py | 61 ++ engine/qualification.py | 263 +++++++- src/lib/engine.ts | 2 + src/lib/types.ts | 45 ++ src/screens/Qualification.tsx | 9 + src/styles/app.css | 18 + src/ui/BusinessDecisionAuthoring.test.tsx | 127 ++++ src/ui/BusinessDecisionAuthoring.tsx | 705 ++++++++++++++++++++++ tests/test_engine/test_qualification.py | 61 ++ 10 files changed, 1315 insertions(+), 1 deletion(-) create mode 100644 src/ui/BusinessDecisionAuthoring.test.tsx create mode 100644 src/ui/BusinessDecisionAuthoring.tsx 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..2a72c21 100644 --- a/engine/dispatch.py +++ b/engine/dispatch.py @@ -239,6 +239,7 @@ 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), "add_qualification_case": self.add_qualification_case, "run_qualification_case": self.run_qualification_case, "import_qualification_results": self.import_qualification_results, @@ -1963,6 +1964,66 @@ 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 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..bfc879d 100644 --- a/engine/qualification.py +++ b/engine/qualification.py @@ -122,6 +122,7 @@ 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), "workflow_contract_sha256": workflow_contract_sha256, } @@ -393,7 +394,134 @@ 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), + } + + +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 _capability_coverage( @@ -896,6 +1024,139 @@ 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 bind_action_effect( bundle_dir: Path, *, diff --git a/src/lib/engine.ts b/src/lib/engine.ts index 3b53a60..c74e48f 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -44,6 +44,8 @@ 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", 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..02a8677 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -252,6 +252,50 @@ 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[]; + }[]; +} + export interface QualificationViolation { rule: string; step_id?: string | null; @@ -387,6 +431,7 @@ export interface QualificationProject { controls: { parameters: QualificationParameter[]; actions: Record; + business_decisions: QualificationBusinessDecisionControls; }; } diff --git a/src/screens/Qualification.tsx b/src/screens/Qualification.tsx index bf7056e..f2f5c03 100644 --- a/src/screens/Qualification.tsx +++ b/src/screens/Qualification.tsx @@ -15,6 +15,7 @@ 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 { QualificationLifecycle } from "./QualificationLifecycle"; const POLICY = "clinical-write"; @@ -1016,6 +1017,14 @@ export function Qualification({ + {project.project && ( + + )} + { + 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 ? ( + + ) : ( + + )} +
+
+ +
+ +