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 02a8677..87e3ce4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -296,6 +296,105 @@ 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: "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; @@ -432,6 +531,7 @@ export interface QualificationProject { 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 f2f5c03..5fec29a 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), + ...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 && ( + + + + )} + + )} + { + it("captures reviewed typed facts and keeps the optional note as a local evidence reference", () => { + const onCapture = vi.fn(); + 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.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.click(screen.getByRole("button", { name: "Add local evidence" })); + fireEvent.change(screen.getByLabelText("Evidence reference 1 local path"), { + target: { value: "evidence/retained-frame.png" }, + }); + fireEvent.change(screen.getByLabelText("Evidence reference 1 SHA-256"), { + target: { value: "1".repeat(64) }, + }); + 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: null, + disposition: "human_node", + review_note_ref: expect.objectContaining({ + relative_path: "evidence/review-note.txt", + sha256: "e".repeat(64), + }), + provenance: { + source: "demonstration", + source_ref_sha256: "f".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.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")); + + 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 new file mode 100644 index 0000000..d9ae472 --- /dev/null +++ b/src/ui/JudgmentCaseCapture.tsx @@ -0,0 +1,559 @@ +import { useMemo, useState } from "react"; +import type { + JudgmentFactFieldV1, + JudgmentCaseCaptureContextV1, + JudgmentCaseV1, + JudgmentDispositionV1, + LocalEvidenceRefV1, +} from "../lib/types"; +import { Button, Callout, Card, CardHead, Pill, SegControl } from "./primitives"; + +type FactValue = boolean | number | string; + +type JudgmentSource = "demonstration" | "counterfactual" | "policy_review" | "fault"; + +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(facts: Record): string { + return JSON.stringify( + Object.entries(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: (caseItems: 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 [addContrast, setAddContrast] = useState(false); + const [contrastFacts, setContrastFacts] = useState>(() => + initialFacts(context), + ); + const [contrastOptionId, setContrastOptionId] = 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>(); + for (const item of context.cases) { + if (!item.option_id) continue; + const options = groups.get(factFingerprint(item.facts)) || new Set(); + options.add(item.option_id); + groups.set(factFingerprint(item.facts), 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; + } + + 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; + } + 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; + } + if (!evidence.length) { + setError("Add at least one local evidence reference for this reviewed case."); + 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; + } + 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 { + 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, + 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 { + setBusy(false); + } + } + + 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. + + +
+
+ + +
+
+ + +
+
+ + setReviewerPrincipalRef(event.target.value)} + placeholder="Digest of the authenticated local reviewer reference" + /> +
+
+ + setSourceRefSha256(event.target.value)} + placeholder="Digest of the local demo, shadow run, or counterfactual source" + /> + The source stays local. Flow records only this reference. +
+
+ + +
+
+ +
+ 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? +
+ { + setDisposition(next); + if (next !== "automatic_rule") setAddContrast(false); + }} + options={dispositionOptions} + /> +

+ {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."} +

+ {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 && ( +
+ + {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 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, + 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 })} /> +
+
+ + +
+
+ +
+ ); +}