From 7bed8ee2f6a28af8a897c29c322336970e539dd4 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 8 Aug 2026 22:19:28 +0200 Subject: [PATCH 1/5] feat(qualification): add local judgment case capture UI --- src/lib/types.ts | 79 +++++++ src/ui/JudgmentCaseCapture.test.tsx | 96 ++++++++ src/ui/JudgmentCaseCapture.tsx | 338 ++++++++++++++++++++++++++++ 3 files changed, 513 insertions(+) create mode 100644 src/ui/JudgmentCaseCapture.test.tsx create mode 100644 src/ui/JudgmentCaseCapture.tsx diff --git a/src/lib/types.ts b/src/lib/types.ts index 02a8677..7094ab4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -296,6 +296,85 @@ export interface QualificationBusinessDecisionControls { }[]; } +/** + * 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: string; +} + +export type JudgmentFactTypeV1 = + | "boolean" + | "integer" + | "number" + | "string" + | "enum"; + +export interface JudgmentFactFieldV1 { + type: JudgmentFactTypeV1; + allowed_values?: string[]; +} + +export interface JudgmentFactSchemaV1 { + fields: Record; +} + +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 }[]; + reviewer: { + role: string; + principal_ref_sha256: string; + }; + allowed_sources: string[]; + cases: JudgmentCaseV1[]; +} + export interface QualificationViolation { rule: string; step_id?: string | null; diff --git a/src/ui/JudgmentCaseCapture.test.tsx b/src/ui/JudgmentCaseCapture.test.tsx new file mode 100644 index 0000000..9886ce9 --- /dev/null +++ b/src/ui/JudgmentCaseCapture.test.tsx @@ -0,0 +1,96 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { JudgmentCaseCaptureContextV1 } from "../lib/types"; +import { JudgmentCaseCapture } from "./JudgmentCaseCapture"; + +const digest = "a".repeat(64); + +function context(): JudgmentCaseCaptureContextV1 { + return { + decision: { + graph_id: "main", + state_id: "routing_review", + workflow_contract_sha256: digest, + decision_contract_sha256: "b".repeat(64), + }, + fact_schema: { + fields: { + service_level: { type: "enum", allowed_values: ["standard", "urgent"] }, + reserved_capacity_available: { type: "boolean" }, + days_waiting: { type: "integer" }, + }, + }, + fact_schema_sha256: "c".repeat(64), + options: [ + { id: "priority_review", label: "Priority review" }, + { id: "standard_review", label: "Standard review" }, + { id: "supervisor", label: "Supervisor" }, + ], + reviewer: { role: "scheduling_lead", principal_ref_sha256: "d".repeat(64) }, + allowed_sources: ["historical_case", "counterfactual"], + cases: [], + }; +} + +afterEach(cleanup); + +describe("JudgmentCaseCapture", () => { + it("captures reviewed typed facts and keeps the optional note as a local evidence reference", () => { + const onCapture = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText("Reviewed branch"), { + target: { value: "priority_review" }, + }); + fireEvent.change(screen.getByLabelText("service level"), { + target: { value: "urgent" }, + }); + fireEvent.change(screen.getByLabelText("reserved capacity available"), { + target: { value: "true" }, + }); + fireEvent.change(screen.getByLabelText("days waiting"), { target: { value: "4" } }); + fireEvent.click(screen.getByRole("button", { name: "Add local review note" })); + fireEvent.change(screen.getByLabelText("Optional local review note local path"), { + target: { value: "evidence/review-note.txt" }, + }); + fireEvent.change(screen.getByLabelText("Optional local review note SHA-256"), { + target: { value: "e".repeat(64) }, + }); + fireEvent.click(screen.getByTestId("capture-judgment-case")); + + expect(onCapture).toHaveBeenCalledWith( + expect.objectContaining({ + decision: expect.objectContaining({ state_id: "routing_review" }), + fact_schema_sha256: "c".repeat(64), + facts: { + service_level: "urgent", + reserved_capacity_available: true, + days_waiting: 4, + }, + option_id: "priority_review", + disposition: "human_node", + review_note_ref: expect.objectContaining({ + relative_path: "evidence/review-note.txt", + sha256: "e".repeat(64), + }), + provenance: { + source: "historical_case", + source_ref_sha256: "b".repeat(64), + reviewer_role: "scheduling_lead", + reviewer_principal_ref_sha256: "d".repeat(64), + }, + }), + ); + }); + + it("does not allow an automatic rule candidate without a selected qualified branch", () => { + const onCapture = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Rule candidate" })); + fireEvent.click(screen.getByTestId("capture-judgment-case")); + + expect(onCapture).not.toHaveBeenCalled(); + expect(screen.getByText("Select the branch that this reviewed case supports.")).toBeTruthy(); + }); +}); diff --git a/src/ui/JudgmentCaseCapture.tsx b/src/ui/JudgmentCaseCapture.tsx new file mode 100644 index 0000000..4e61ede --- /dev/null +++ b/src/ui/JudgmentCaseCapture.tsx @@ -0,0 +1,338 @@ +import { useMemo, useState } from "react"; +import type { + JudgmentCaseCaptureContextV1, + JudgmentCaseV1, + JudgmentDispositionV1, + LocalEvidenceRefV1, +} from "../lib/types"; +import { Button, Callout, Card, CardHead, Pill, SegControl } from "./primitives"; + +type FactValue = boolean | number | string; + +const dispositionOptions: { value: JudgmentDispositionV1; label: string }[] = [ + { value: "automatic_rule", label: "Rule candidate" }, + { value: "human_node", label: "Keep human decision" }, + { value: "more_evidence_required", label: "More examples" }, +]; + +function caseId(): string { + return `judgment_case_${crypto.randomUUID()}`; +} + +function initialFacts(context: JudgmentCaseCaptureContextV1): Record { + return Object.fromEntries( + Object.entries(context.fact_schema.fields).map(([name, field]) => [ + name, + field.type === "boolean" ? false : field.type === "integer" || field.type === "number" ? 0 : "", + ]), + ); +} + +function factFingerprint(caseItem: JudgmentCaseV1): string { + return JSON.stringify( + Object.entries(caseItem.facts).sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function newRef(): LocalEvidenceRefV1 { + return { relative_path: "", sha256: "", kind: "document" }; +} + +/** + * Collect reviewed examples and counterfactuals for a single existing Flow + * decision. This component has no runner, signer, or remote-delivery path. + */ +export function JudgmentCaseCapture({ + context, + onCapture, +}: { + context: JudgmentCaseCaptureContextV1; + onCapture: (caseItem: JudgmentCaseV1) => void; +}) { + const [source, setSource] = useState(context.allowed_sources[0] || "demonstration"); + const [facts, setFacts] = useState>(() => initialFacts(context)); + const [optionId, setOptionId] = useState(""); + const [disposition, setDisposition] = + useState("human_node"); + const [evidence, setEvidence] = useState([]); + const [note, setNote] = useState(null); + const [contrastCaseIds, setContrastCaseIds] = useState([]); + const [error, setError] = useState(""); + + const conflicts = useMemo(() => { + const groups = new Map>(); + for (const item of context.cases) { + if (!item.option_id) continue; + const options = groups.get(factFingerprint(item)) || new Set(); + options.add(item.option_id); + groups.set(factFingerprint(item), options); + } + return [...groups.values()].filter((options) => options.size > 1).length; + }, [context.cases]); + const missingContrast = useMemo( + () => contrastCaseIds.filter((id) => !context.cases.some((item) => item.id === id)), + [context.cases, contrastCaseIds], + ); + + function validateReference(reference: LocalEvidenceRefV1, label: string): string | null { + if (!reference.relative_path.trim() || !reference.sha256.trim() || !reference.kind.trim()) { + return `${label} needs a local path, SHA-256, and kind.`; + } + if (!/^[a-f0-9]{64}$/i.test(reference.sha256.trim())) { + return `${label} SHA-256 must contain 64 hexadecimal characters.`; + } + return null; + } + + function capture() { + setError(""); + if (disposition !== "more_evidence_required" && !optionId) { + setError("Select the branch that this reviewed case supports."); + return; + } + for (const [index, reference] of evidence.entries()) { + const invalid = validateReference(reference, `Evidence reference ${index + 1}`); + if (invalid) return setError(invalid); + } + if (note) { + const invalid = validateReference(note, "The local review note"); + if (invalid) return setError(invalid); + } + if (missingContrast.length) { + setError("A contrast case must refer to a saved local case."); + return; + } + onCapture({ + id: caseId(), + decision: context.decision, + fact_schema_sha256: context.fact_schema_sha256, + facts, + local_evidence: evidence, + review_note_ref: note, + provenance: { + source, + source_ref_sha256: context.decision.decision_contract_sha256, + reviewer_role: context.reviewer.role, + reviewer_principal_ref_sha256: context.reviewer.principal_ref_sha256, + }, + disposition, + option_id: optionId || null, + contrast_case_ids: contrastCaseIds, + }); + setFacts(initialFacts(context)); + setOptionId(""); + setEvidence([]); + setNote(null); + setContrastCaseIds([]); + } + + return ( + + + + A saved case can support a reviewed rule candidate. It cannot change a production + branch. A qualified reviewer must approve the exact rule after coverage and fault + checks pass. Otherwise, Flow keeps the human decision node. + + +
+
+ + ({ + value, + label: value.replace(/_/g, " "), + }))} + /> +
+
+ + +
+
+ +
+ Reviewed typed facts +
+ {Object.entries(context.fact_schema.fields).map(([name, field]) => ( +
+ + {field.type === "boolean" ? ( + + ) : field.type === "enum" ? ( + + ) : ( + + setFacts((current) => ({ + ...current, + [name]: field.type === "integer" || field.type === "number" + ? Number(event.target.value) + : event.target.value, + })) + } + /> + )} + {field.type} +
+ ))} +
+
+ +
+ Local evidence and optional review note +

+ Store raw screenshots, record values, and free text on this device. This form only + sends local content references to Flow. It does not send the content to Cloud. +

+ {evidence.map((reference, index) => ( + setEvidence((current) => current.map((item, itemIndex) => itemIndex === index ? next : item))} + onRemove={() => setEvidence((current) => current.filter((_, itemIndex) => itemIndex !== index))} + /> + ))} + +
+ {note ? ( + setNote(null)} + /> + ) : ( + + )} +
+
+ +
+ What should qualification do with this case? +
+ +

+ {disposition === "automatic_rule" && "This requests a rule candidate for review. It does not enable automatic execution."} + {disposition === "human_node" && "This preserves a permanent human decision node for this case."} + {disposition === "more_evidence_required" && "This records that the current facts do not justify a branch. Add a contrast case."} +

+
+ {context.cases.length > 0 && ( +
+ + {context.cases.map((item) => ( + + ))} +
+ )} +
+ +
+
+ Coverage and conflicts + + {conflicts ? `${conflicts} conflict${conflicts === 1 ? "" : "s"}` : "No conflicts"} +
+

+ {context.cases.length} saved local case{context.cases.length === 1 ? "" : "s"}. A conflict means identical reviewed facts support different branches. Qualification must keep the decision human or request a missing fact. +

+
+ + {error && {error}} +
+ + Flow seals the case into the next qualification revision. This does not create a runtime task. +
+
+ ); +} + +function EvidenceReference({ + label, + value, + onChange, + onRemove, +}: { + label: string; + value: LocalEvidenceRefV1; + onChange: (value: LocalEvidenceRefV1) => void; + onRemove: () => void; +}) { + return ( +
+
+
+ + onChange({ ...value, relative_path: event.target.value })} /> +
+
+ + onChange({ ...value, sha256: event.target.value })} /> +
+
+ + onChange({ ...value, kind: event.target.value })} /> +
+
+ +
+ ); +} From c0592d3735b8d2c5c1ba0054d24c41e467c92af1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 8 Aug 2026 22:21:21 +0200 Subject: [PATCH 2/5] fix(qualification): bind judgment case source evidence --- src/ui/JudgmentCaseCapture.test.tsx | 8 +++++++- src/ui/JudgmentCaseCapture.tsx | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/ui/JudgmentCaseCapture.test.tsx b/src/ui/JudgmentCaseCapture.test.tsx index 9886ce9..39d4471 100644 --- a/src/ui/JudgmentCaseCapture.test.tsx +++ b/src/ui/JudgmentCaseCapture.test.tsx @@ -39,6 +39,9 @@ describe("JudgmentCaseCapture", () => { const onCapture = vi.fn(); render(); + fireEvent.change(screen.getByLabelText("Local source SHA-256"), { + target: { value: "f".repeat(64) }, + }); fireEvent.change(screen.getByLabelText("Reviewed branch"), { target: { value: "priority_review" }, }); @@ -75,7 +78,7 @@ describe("JudgmentCaseCapture", () => { }), provenance: { source: "historical_case", - source_ref_sha256: "b".repeat(64), + source_ref_sha256: "f".repeat(64), reviewer_role: "scheduling_lead", reviewer_principal_ref_sha256: "d".repeat(64), }, @@ -87,6 +90,9 @@ describe("JudgmentCaseCapture", () => { const onCapture = vi.fn(); render(); + fireEvent.change(screen.getByLabelText("Local source SHA-256"), { + target: { value: "f".repeat(64) }, + }); fireEvent.click(screen.getByRole("button", { name: "Rule candidate" })); fireEvent.click(screen.getByTestId("capture-judgment-case")); diff --git a/src/ui/JudgmentCaseCapture.tsx b/src/ui/JudgmentCaseCapture.tsx index 4e61ede..84cfa01 100644 --- a/src/ui/JudgmentCaseCapture.tsx +++ b/src/ui/JudgmentCaseCapture.tsx @@ -50,6 +50,7 @@ export function JudgmentCaseCapture({ onCapture: (caseItem: JudgmentCaseV1) => void; }) { const [source, setSource] = useState(context.allowed_sources[0] || "demonstration"); + const [sourceRefSha256, setSourceRefSha256] = useState(""); const [facts, setFacts] = useState>(() => initialFacts(context)); const [optionId, setOptionId] = useState(""); const [disposition, setDisposition] = @@ -86,6 +87,10 @@ export function JudgmentCaseCapture({ function capture() { setError(""); + if (!/^[a-f0-9]{64}$/i.test(sourceRefSha256.trim())) { + setError("The local source reference needs a SHA-256 digest."); + return; + } if (disposition !== "more_evidence_required" && !optionId) { setError("Select the branch that this reviewed case supports."); return; @@ -111,7 +116,7 @@ export function JudgmentCaseCapture({ review_note_ref: note, provenance: { source, - source_ref_sha256: context.decision.decision_contract_sha256, + source_ref_sha256: sourceRefSha256.trim(), reviewer_role: context.reviewer.role, reviewer_principal_ref_sha256: context.reviewer.principal_ref_sha256, }, @@ -151,6 +156,17 @@ export function JudgmentCaseCapture({ }))} /> +
+ + setSourceRefSha256(event.target.value)} + placeholder="Digest of the local demo, shadow run, or counterfactual source" + /> + The source stays local. Flow records only this reference. +
setOptionId(event.target.value)} - disabled={disposition === "more_evidence_required"} + disabled={disposition !== "automatic_rule"} > {context.options.map((option) => ( @@ -281,6 +292,18 @@ export function JudgmentCaseCapture({ {disposition === "human_node" && "This preserves a permanent human decision node for this case."} {disposition === "more_evidence_required" && "This records that the current facts do not justify a branch. Add a contrast case."}

+ {disposition === "automatic_rule" && ( +
+ + setReviewedRuleId(event.target.value)} + placeholder="A reviewed policy identifier, not a natural-language rule" + /> +
+ )}
{context.cases.length > 0 && (
@@ -345,7 +368,23 @@ function EvidenceReference({
- onChange({ ...value, kind: event.target.value })} /> +
From 517c67dc75c9c8f422d4870e9d1d694483ce7a3d Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 8 Aug 2026 22:28:38 +0200 Subject: [PATCH 4/5] feat(qualification): bind Desktop judgment cases to Flow --- engine/dispatch.py | 36 ++++++++ engine/qualification.py | 130 ++++++++++++++++++++++++++++ src/lib/engine.ts | 1 + src/lib/types.ts | 13 ++- src/screens/Qualification.tsx | 43 +++++++++ src/ui/JudgmentCaseCapture.test.tsx | 8 +- src/ui/JudgmentCaseCapture.tsx | 68 ++++++++++++--- 7 files changed, 280 insertions(+), 19 deletions(-) diff --git a/engine/dispatch.py b/engine/dispatch.py index 2a72c21..02f5978 100644 --- a/engine/dispatch.py +++ b/engine/dispatch.py @@ -240,6 +240,7 @@ def _register(self) -> None: "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, @@ -2024,6 +2025,41 @@ def author_qualification_business_decision(self, **params: Any) -> dict: 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 bfc879d..35a0909 100644 --- a/engine/qualification.py +++ b/engine/qualification.py @@ -123,6 +123,10 @@ def _flow_api() -> dict[str, Any]: "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, } @@ -398,6 +402,7 @@ def _qualification_controls(workflow, graph: dict[str, Any]) -> dict[str, Any]: "parameters": parameters, "actions": actions, "business_decisions": _business_decision_controls(workflow), + "judgment_cases": _judgment_case_controls(workflow), } @@ -524,6 +529,83 @@ def _business_decision_controls(workflow) -> dict[str, Any]: } +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( bundle_dir: Path, *, @@ -1157,6 +1239,54 @@ def author_business_decision( ) +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 c74e48f..2d3444b 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -46,6 +46,7 @@ export const CMD = { "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 0f2e403..87e3ce4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -374,10 +374,7 @@ export interface JudgmentCaseCaptureContextV1 { fact_schema: JudgmentFactSchemaV1; fact_schema_sha256: string; options: { id: string; label: string }[]; - reviewer: { - role: string; - principal_ref_sha256: string; - }; + authorized_roles: string[]; cases: JudgmentCaseV1[]; } @@ -391,6 +388,13 @@ export interface JudgmentCaseQualificationReportV1 { 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; @@ -527,6 +531,7 @@ export interface QualificationProject { parameters: QualificationParameter[]; actions: Record; business_decisions: QualificationBusinessDecisionControls; + judgment_cases: QualificationJudgmentCaseControls; }; } diff --git a/src/screens/Qualification.tsx b/src/screens/Qualification.tsx index f2f5c03..5fa1144 100644 --- a/src/screens/Qualification.tsx +++ b/src/screens/Qualification.tsx @@ -16,6 +16,7 @@ import type { 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"; @@ -1025,6 +1026,48 @@ export function Qualification({ /> )} + {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), + caseItem, + ]; + 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 && ( + + + + )} + + )} + { fireEvent.change(screen.getByLabelText("Local source SHA-256"), { target: { value: "f".repeat(64) }, }); + fireEvent.change(screen.getByLabelText("Local reviewer reference SHA-256"), { + target: { value: "d".repeat(64) }, + }); fireEvent.change(screen.getByLabelText("service level"), { target: { value: "urgent" }, }); @@ -97,6 +100,9 @@ describe("JudgmentCaseCapture", () => { fireEvent.change(screen.getByLabelText("Local source SHA-256"), { target: { value: "f".repeat(64) }, }); + fireEvent.change(screen.getByLabelText("Local reviewer reference SHA-256"), { + target: { value: "d".repeat(64) }, + }); fireEvent.click(screen.getByRole("button", { name: "Rule candidate" })); fireEvent.click(screen.getByTestId("capture-judgment-case")); diff --git a/src/ui/JudgmentCaseCapture.tsx b/src/ui/JudgmentCaseCapture.tsx index 8e3fcf5..8b80035 100644 --- a/src/ui/JudgmentCaseCapture.tsx +++ b/src/ui/JudgmentCaseCapture.tsx @@ -49,19 +49,22 @@ export function JudgmentCaseCapture({ onCapture, }: { context: JudgmentCaseCaptureContextV1; - onCapture: (caseItem: JudgmentCaseV1) => void; + onCapture: (caseItem: JudgmentCaseV1) => Promise; }) { const [source, setSource] = useState("demonstration"); const [sourceRefSha256, setSourceRefSha256] = useState(""); const [facts, setFacts] = useState>(() => initialFacts(context)); const [optionId, setOptionId] = useState(""); const [reviewedRuleId, setReviewedRuleId] = useState(""); + const [reviewerRole, setReviewerRole] = useState(context.authorized_roles[0] || ""); + const [reviewerPrincipalRef, setReviewerPrincipalRef] = useState(""); const [disposition, setDisposition] = useState("human_node"); const [evidence, setEvidence] = useState([]); const [note, setNote] = useState(null); const [contrastCaseIds, setContrastCaseIds] = useState([]); const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); const conflicts = useMemo(() => { const groups = new Map>(); @@ -88,12 +91,20 @@ export function JudgmentCaseCapture({ return null; } - function capture() { + async function capture() { setError(""); if (!/^[a-f0-9]{64}$/i.test(sourceRefSha256.trim())) { setError("The local source reference needs a SHA-256 digest."); return; } + if (!reviewerRole || !context.authorized_roles.includes(reviewerRole)) { + setError("Select a reviewer role that the qualified decision permits."); + return; + } + if (!/^[a-f0-9]{64}$/i.test(reviewerPrincipalRef.trim())) { + setError("The local reviewer reference needs a SHA-256 digest."); + return; + } if (disposition === "automatic_rule" && (!optionId || !reviewedRuleId.trim())) { setError("A rule candidate needs a reviewed rule id and a qualified branch."); return; @@ -114,7 +125,9 @@ export function JudgmentCaseCapture({ setError("A contrast case must refer to a saved local case."); return; } - onCapture({ + setBusy(true); + try { + await onCapture({ id: caseId(), decision: context.decision, fact_schema_sha256: context.fact_schema_sha256, @@ -124,20 +137,25 @@ export function JudgmentCaseCapture({ provenance: { source, source_ref_sha256: sourceRefSha256.trim(), - reviewer_role: context.reviewer.role, - reviewer_principal_ref_sha256: context.reviewer.principal_ref_sha256, + reviewer_role: reviewerRole, + reviewer_principal_ref_sha256: reviewerPrincipalRef.trim(), }, disposition, reviewed_rule_id: disposition === "automatic_rule" ? reviewedRuleId.trim() : null, option_id: disposition === "automatic_rule" ? optionId : null, contrast_case_ids: contrastCaseIds, - }); - setFacts(initialFacts(context)); - setOptionId(""); - setReviewedRuleId(""); - setEvidence([]); - setNote(null); - setContrastCaseIds([]); + }); + setFacts(initialFacts(context)); + setOptionId(""); + setReviewedRuleId(""); + setEvidence([]); + setNote(null); + setContrastCaseIds([]); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } } return ( @@ -167,6 +185,28 @@ export function JudgmentCaseCapture({ ]} /> +
+ + +
+
+ + setReviewerPrincipalRef(event.target.value)} + placeholder="Digest of the authenticated local reviewer reference" + /> +
{error}}
- Flow seals the case into the next qualification revision. This does not create a runtime task.
From c27ec1aadf33ec5106919fd600017116fa6db5a1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 8 Aug 2026 22:36:20 +0200 Subject: [PATCH 5/5] feat(qualification): capture reciprocal judgment cases --- src/screens/Qualification.test.tsx | 87 ++++++++++++++ src/screens/Qualification.tsx | 4 +- src/ui/JudgmentCaseCapture.test.tsx | 55 ++++++++- src/ui/JudgmentCaseCapture.tsx | 174 ++++++++++++++++++++++++---- 4 files changed, 292 insertions(+), 28 deletions(-) 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 5fa1144..5fec29a 100644 --- a/src/screens/Qualification.tsx +++ b/src/screens/Qualification.tsx @@ -1032,7 +1032,7 @@ export function Qualification({ { + onCapture={async (caseItems) => { const schemas = project.controls.judgment_cases.contexts.map((item) => ({ graph_id: item.decision.graph_id, state_id: item.decision.state_id, @@ -1040,7 +1040,7 @@ export function Qualification({ })); const cases = [ ...project.controls.judgment_cases.contexts.flatMap((item) => item.cases), - caseItem, + ...caseItems, ]; const response = await engineInvoke( CMD.SET_QUALIFICATION_JUDGMENT_CASES, diff --git a/src/ui/JudgmentCaseCapture.test.tsx b/src/ui/JudgmentCaseCapture.test.tsx index 97990a7..87937ce 100644 --- a/src/ui/JudgmentCaseCapture.test.tsx +++ b/src/ui/JudgmentCaseCapture.test.tsx @@ -68,7 +68,7 @@ describe("JudgmentCaseCapture", () => { }); fireEvent.click(screen.getByTestId("capture-judgment-case")); - expect(onCapture).toHaveBeenCalledWith( + expect(onCapture).toHaveBeenCalledWith([ expect.objectContaining({ decision: expect.objectContaining({ state_id: "routing_review" }), fact_schema_sha256: "c".repeat(64), @@ -90,7 +90,7 @@ describe("JudgmentCaseCapture", () => { reviewer_principal_ref_sha256: "d".repeat(64), }, }), - ); + ]); }); it("does not allow an automatic rule candidate without a selected qualified branch", () => { @@ -109,4 +109,55 @@ describe("JudgmentCaseCapture", () => { expect(onCapture).not.toHaveBeenCalled(); expect(screen.getByText("A rule candidate needs a reviewed rule id and a qualified branch.")).toBeTruthy(); }); + + it("creates a reciprocal contrasting pair for an automatic rule candidate", async () => { + const onCapture = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.change(screen.getByLabelText("Local source SHA-256"), { + target: { value: "f".repeat(64) }, + }); + fireEvent.change(screen.getByLabelText("Local reviewer reference SHA-256"), { + target: { value: "d".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_capacity_policy" }, + }); + fireEvent.click(screen.getByLabelText("Add a contrasting reviewed case now")); + fireEvent.change(screen.getAllByLabelText("service level")[1], { + target: { value: "urgent" }, + }); + 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: "1".repeat(64) }, + }); + fireEvent.click(screen.getByTestId("capture-judgment-case")); + + await Promise.resolve(); + const pair = onCapture.mock.calls[0][0]; + expect(pair).toHaveLength(2); + expect(pair[0]).toEqual(expect.objectContaining({ + disposition: "automatic_rule", + option_id: "priority_review", + reviewed_rule_id: "urgent_capacity_policy", + })); + expect(pair[1]).toEqual(expect.objectContaining({ + disposition: "automatic_rule", + option_id: "supervisor", + provenance: expect.objectContaining({ source: "counterfactual" }), + })); + expect(pair[0].facts).not.toEqual(pair[1].facts); + expect(pair[0].contrast_case_ids).toEqual([pair[1].id]); + expect(pair[1].contrast_case_ids).toEqual([pair[0].id]); + }); }); diff --git a/src/ui/JudgmentCaseCapture.tsx b/src/ui/JudgmentCaseCapture.tsx index 8b80035..d9ae472 100644 --- a/src/ui/JudgmentCaseCapture.tsx +++ b/src/ui/JudgmentCaseCapture.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import type { + JudgmentFactFieldV1, JudgmentCaseCaptureContextV1, JudgmentCaseV1, JudgmentDispositionV1, @@ -30,9 +31,9 @@ function initialFacts(context: JudgmentCaseCaptureContextV1): Record): string { return JSON.stringify( - Object.entries(caseItem.facts).sort(([left], [right]) => left.localeCompare(right)), + Object.entries(facts).sort(([left], [right]) => left.localeCompare(right)), ); } @@ -49,7 +50,7 @@ export function JudgmentCaseCapture({ onCapture, }: { context: JudgmentCaseCaptureContextV1; - onCapture: (caseItem: JudgmentCaseV1) => Promise; + onCapture: (caseItems: JudgmentCaseV1[]) => Promise; }) { const [source, setSource] = useState("demonstration"); const [sourceRefSha256, setSourceRefSha256] = useState(""); @@ -58,6 +59,11 @@ export function JudgmentCaseCapture({ const [reviewedRuleId, setReviewedRuleId] = useState(""); const [reviewerRole, setReviewerRole] = useState(context.authorized_roles[0] || ""); const [reviewerPrincipalRef, setReviewerPrincipalRef] = useState(""); + const [addContrast, setAddContrast] = useState(false); + const [contrastFacts, setContrastFacts] = useState>(() => + initialFacts(context), + ); + const [contrastOptionId, setContrastOptionId] = useState(""); const [disposition, setDisposition] = useState("human_node"); const [evidence, setEvidence] = useState([]); @@ -70,9 +76,9 @@ export function JudgmentCaseCapture({ const groups = new Map>(); for (const item of context.cases) { if (!item.option_id) continue; - const options = groups.get(factFingerprint(item)) || new Set(); + const options = groups.get(factFingerprint(item.facts)) || new Set(); options.add(item.option_id); - groups.set(factFingerprint(item), options); + groups.set(factFingerprint(item.facts), options); } return [...groups.values()].filter((options) => options.size > 1).length; }, [context.cases]); @@ -93,6 +99,7 @@ export function JudgmentCaseCapture({ async function capture() { setError(""); + const pairedRule = disposition === "automatic_rule" && addContrast; if (!/^[a-f0-9]{64}$/i.test(sourceRefSha256.trim())) { setError("The local source reference needs a SHA-256 digest."); return; @@ -125,32 +132,65 @@ export function JudgmentCaseCapture({ setError("A contrast case must refer to a saved local case."); return; } + if (pairedRule && factFingerprint(facts) === factFingerprint(contrastFacts)) { + setError("A contrasting case must change at least one reviewed fact."); + return; + } + if (pairedRule && !contrastOptionId) { + setError("Select the qualified branch for the contrasting case."); + return; + } setBusy(true); try { - await onCapture({ - id: caseId(), - decision: context.decision, - fact_schema_sha256: context.fact_schema_sha256, - facts, - local_evidence: evidence, - review_note_ref: note, - provenance: { - source, - source_ref_sha256: sourceRefSha256.trim(), + const primaryId = caseId(); + const contrastId = pairedRule ? caseId() : null; + const common = { + decision: context.decision, + fact_schema_sha256: context.fact_schema_sha256, + local_evidence: evidence, + review_note_ref: note, + reviewed_rule_id: disposition === "automatic_rule" ? reviewedRuleId.trim() : null, + }; + const primary: JudgmentCaseV1 = { + ...common, + id: primaryId, + facts, + provenance: { + source, + source_ref_sha256: sourceRefSha256.trim(), reviewer_role: reviewerRole, reviewer_principal_ref_sha256: reviewerPrincipalRef.trim(), - }, - disposition, - reviewed_rule_id: disposition === "automatic_rule" ? reviewedRuleId.trim() : null, - option_id: disposition === "automatic_rule" ? optionId : null, - contrast_case_ids: contrastCaseIds, - }); + }, + disposition, + option_id: disposition === "automatic_rule" ? optionId : null, + contrast_case_ids: contrastId ? [...contrastCaseIds, contrastId] : contrastCaseIds, + }; + const pair = contrastId + ? [{ + ...common, + id: contrastId, + facts: contrastFacts, + provenance: { + source: "counterfactual" as const, + source_ref_sha256: sourceRefSha256.trim(), + reviewer_role: reviewerRole, + reviewer_principal_ref_sha256: reviewerPrincipalRef.trim(), + }, + disposition: "automatic_rule" as const, + option_id: contrastOptionId, + contrast_case_ids: [primaryId], + } satisfies JudgmentCaseV1] + : []; + await onCapture([primary, ...pair]); setFacts(initialFacts(context)); setOptionId(""); setReviewedRuleId(""); setEvidence([]); setNote(null); setContrastCaseIds([]); + setAddContrast(false); + setContrastFacts(initialFacts(context)); + setContrastOptionId(""); } catch (reason) { setError(String(reason)); } finally { @@ -324,7 +364,10 @@ export function JudgmentCaseCapture({
{ + setDisposition(next); + if (next !== "automatic_rule") setAddContrast(false); + }} options={dispositionOptions} />

@@ -333,7 +376,8 @@ export function JudgmentCaseCapture({ {disposition === "more_evidence_required" && "This records that the current facts do not justify a branch. Add a contrast case."}

{disposition === "automatic_rule" && ( -
+ <> +
setReviewedRuleId(event.target.value)} placeholder="A reviewed policy identifier, not a natural-language rule" /> -
+
+ + + Flow requires reciprocal contrasting cases before a rule candidate can pass coverage. + + {addContrast && ( +
+ Contrasting case +
+ {Object.entries(context.fact_schema.fields).map(([name, field]) => ( + setContrastFacts((current) => ({ ...current, [name]: next }))} + prefix="contrast" + /> + ))} +
+
+ + +
+
+ )} + )}
{context.cases.length > 0 && ( @@ -384,6 +469,47 @@ export function JudgmentCaseCapture({ ); } +function FactInput({ + name, + field, + facts, + onChange, + prefix, +}: { + name: string; + field: JudgmentFactFieldV1; + facts: Record; + onChange: (value: FactValue) => void; + prefix: string; +}) { + const inputId = `judgment-${prefix}-fact-${name}`; + return ( +
+ + {field.type === "boolean" ? ( + + ) : field.type === "enum" ? ( + + ) : ( + onChange(field.type === "integer" || field.type === "number" ? Number(event.target.value) : event.target.value)} + /> + )} + {field.type} +
+ ); +} + function EvidenceReference({ label, value,