feat(FAR-860) [partial]: HITL response contract backend (schema, injection, REST + MCP, audit) - #616
Conversation
…ction, REST + MCP, audit)
… + E2E branching test
farnalabs
left a comment
There was a problem hiding this comment.
CI is failing on head 1f04518 — cannot approve:
- Test (Backend): failure
- Lint (Backend): failure
Other checks pass (Integration tests (changed), BDD (full suite), Frontend and WCAG, Schema freshness, Secret scan, etc.). Please fix the backend test/lint failures and re-push; a fresh review will follow. (Feedback only — formal decision posted by post-decision.)
farnalabs
left a comment
There was a problem hiding this comment.
Code findings from review of the three-dot diff (non-blocking feedback; formal decision follows post-decision):
-
mcp_server.py _dispatch_hitl_action: the error-sentinel check
isinstance(validated_answer, dict) and "error" in validated_answercollides with legit answers — an answer dict that itself contains an "error" key (e.g. {"kind": "approval", "error": "x"}) passes validate_hitl_answer, then is misread as the MCP error dict and returned, silently skipping mgr.approve. Suggest returning a tuple (err, answer) or a sentinel wrapper instead of key-sniffing. -
routes/hitl.py ОтветValidationError: the REST AnswerValidationError (HTTPException subclass) shadows the same class name as the shared ValueError subclass in api/hitl_answer_validation.py — confusing when grepping both files; suggest renaming the REST one (e.g. AnswerValidationError422).
-
api/hitl_answer_validation.py: when resolve_hitl_gate_config returns None (legacy snapshot / graph drift) the helper FAILS OPEN and returns the unvalidated answer. Deliberate, but worth an explicit comment about accepting arbitrary option_id in that state.
-
Coverage: validate_hitl_answer itself has no direct unit test (models/validator/mcp are covered, but the choice/kind-mismatch/option_id-rejection rules are exercised only via callers). One focused unit test on the helper would lock the shared rules.
-
CI: Test (Backend) and Lint (Backend) fail on head 1f04518 — the re-review blocker; see the earlier comment.
modulo-reviewbot
left a comment
There was a problem hiding this comment.
CHANGES_REQUESTED — FAR-860 HITL response_contract
The three-dot diff against main for PR #616 (head SHA 1f0451894884e418deb98c7d154346ac8456c50a) was reviewed. The overall structure is sound: a shared validation helper (api/hitl_answer_validation.py), a Pydantic HitlResponseContract model, graph-validator checks (node-level hitl_config + edge-level hitl_gate_config with dedup), gate-weakening detection in the CRUD layer, audit enrichment, and answer-to-state injection backed by unit + e2e tests (including a fails-without-fix case). Contract round-trip is consistent with the regenerated frontend/src/lib/api/schema.ts.
Blocking findings
-
CI is failing on head.
Test (Backend)andLint (Backend)check-runs are failing on1f04518; merge is currently blocked. The PR cannot be merged until these go green. -
backend/src/modulo/api/mcp_server.py— error-sentinel key-sniffing._dispatch_hitl_actionchecks'error' in validated_answerto detect an MCP error dict. A legit answer dict that happens to contain an'error'key is misread as an MCP error and silently skipsmgr.approve. Return a tuple/sentinel from the validation path instead of sniffing dict keys. -
backend/src/modulo/api/routes/hitl.py— name shadowing. The RESTAnswerValidationError(anHTTPExceptionsubclass) shadows the sharedValueErrorsubclass name inapi/hitl_answer_validation.py. Rename to avoid confusion. -
backend/src/modulo/api/hitl_answer_validation.py— undocumented fail-open. The shared validator fails open (returns the unvalidated answer) whenresolve_hitl_gate_configreturnsNone, meaning an arbitraryoption_idis accepted in the drift case. Deliberate, but it must be documented (and ideally logged). -
Missing focused unit test.
validate_hitl_answeritself has no direct unit test; its rules are only exercised via callers. Add a focused test.
Non-blocking notes
backend/src/modulo/core/pipeline_engine/hitl_context.py: nestedimport json as _jsoninside the function is unusual — move to module imports on next touch.backend/src/modulo/core/hitl_manager/__init__.py: audit enrichment only emitted for non-empty strings; no approvaloption_idleak — good.backend/tests/unit/api/test_hitl_response_contract_models.pyetc. cover the contract well; keep that coverage when addressing finding 5.
High-risk paths were flagged by the policy-router (HITL manager / pipeline-engine core changes); this review posts the formal decision per the pipeline routing.
…test - Move the shared answer-validation imports in routes/hitl.py to module level (semgrep inline-import-route-files blocks new inline imports). - Add response_contract to the persisted gate-context key-set assertion in test_interrupt_persists_fire_time_context_on_the_gate_row (FAR-860 adds the key to build_hitl_gate_context).
|
Branch Fixer — CI fixes pushed ( Fixes the two failing checks on head Lint (Backend) — Test (Backend) — Non-blocking review findings 1–4 (MCP error-sentinel collision, REST |
farnalabs
left a comment
There was a problem hiding this comment.
Re-review of head a5ae3bc (fresh review: new non-merge commit landed after the 20:51 CR). CI now green on head (Test (Backend), Lint (Backend) pass; SonarCloud coverage still running). The fail-open in validate_hitl_answer is now documented, the REST imports are hoisted, and the new test_executor assertion plus the fail-open comment land well.
Still outstanding from the previous review:
-
api/mcp_server.py _dispatch_hitl_action (approve + deliver_manual): the error-detection check
isinstance(validated_answer, dict) and "error" in validated_answerkey-sniffs the validated answer. validate_hitl_answer returns the answer dict UNMODIFIED and only inspects kind/option_id, so a caller submitting {"kind": "choice", "option_id": "x", "error": "..."} passes validation and is then misread as an MCP error dict — mgr.approve / mgr.deliver_manual is silently skipped. Return a tuple (error, answer) or a wrapper object from the validation path instead of scanning dict keys. -
Missing focused unit test on validate_hitl_answer itself: no test file references the helper directly; its choice/kind-mismatch/option_id-rejection rules (including the modelled fail-open on unresolved config) are exercised only via callers. Please add a focused backend/tests/unit/api test on the shared helper.
-
routes/hitl.py still defines class AnswerValidationError(HTTPException) shadowing the same name as the shared ValueError subclass in api/hitl_answer_validation.py — rename the REST one (e.g. AnswerValidationErrorHTTP) so grep stays unambiguous.
Minor (non-blocking, next touch): hitl_context.py has a nested import json as _json inside _build_context_inner — move to module-level; mcp_server._validate_mcp_choice_answer still lazily imports the shared helper inside the function while routes/hitl.py now hoists it (make them consistent).
Everything else checks out: graph-validator response_contract checks (node + edge with dedup), gate-weakening detection, audit enrichment, and the injection path are covered by tests including a fails-without-injection e2e case; frontend schema.ts matches the regenerated Pydantic contract. (Feedback only — formal decision posted by post-decision.)
modulo-reviewbot
left a comment
There was a problem hiding this comment.
Formal review decision: CHANGES_REQUESTED (post-decision node, PR #616, head a5ae3bc).
The review node verdict is CHANGES_REQUESTED. Blocking findings carried through from the review:
-
backend/src/modulo/api/mcp_server.py — _dispatch_hitl_action detects MCP errors by key-sniffing ("error" in validated_answer); validate_hitl_answer returns the answer unmodified, so an answer carrying an "error" key passes validation and silently skips mgr.approve/mgr.deliver_manual. Required: return a tuple/wrapper instead. (Carried over from prior review, unaddressed.)
-
backend/tests/unit/api — no focused unit test on validate_hitl_answer itself; its choice/kind-mismatch/option_id-rejection and fail-open-on-unresolved-config rules are only exercised via callers. Required: add a focused unit test. (Carried over from prior review, unaddressed.)
-
backend/src/modulo/api/routes/hitl.py — class AnswerValidationError(HTTPException) shadows the shared ValueError subclass name in api/hitl_answer_validation.py. Required: rename the REST variant for grep clarity.
Non-blocking: minor lint/import-consistency notes in hitl_context.py, mcp_server.py, graph_validator/init.py. The remainder of the diff (schema.ts Pydantic contract round-trip, e2e injection coverage, hitl_gate_guard) is strong.
Merge blocked until the above are resolved.
SonarCloud quality gate failed on PR #616: - new_duplicated_lines_density 4.2% (>3%): the 21-line node/edge HITL gate-config walk in _check_hitl_gate_response_contracts was a verbatim copy of the one in _check_hitl_gate_subject_paths. Extract the shared _iter_hitl_gate_configs generator so the two checks cannot drift. - new_coverage 73.5% (<80%): hitl_answer_validation.py had 33 of 42 new lines uncovered. Add direct unit tests for validate_hitl_answer (all contract branches), taking the module to 100% covered.
|
Branch Fixer — SonarCloud quality gate fix ( The Root cause
Fix
Verification (run from
Expected gate movement: |
Remaining two blocking findings from the CHANGES_REQUESTED review (a concurrent fix already landed the gate-config dedup + focused validator test): - mcp_server._dispatch_hitl_action: _validate_mcp_choice_answer now returns (error, validated_answer) out-of-band instead of key-sniffing the validated answer dict. A legit answer carrying an 'error' key (validate_hitl_answer returns the answer unmodified) can no longer be misread as an MCP error and silently skip mgr.approve / mgr.deliver_manual. - routes/hitl.py: rename the REST HTTPException subclass to AnswerValidationErrorHTTP so it no longer shadows the shared ValueError subclass of the same name in api/hitl_answer_validation.py. Verified: ruff check/format, mypy src/modulo/, and the targeted unit suites (validator, hitl resilience/routes, mcp hitl, graph-validator contract) pass.
Branch Fixer — review blockers + SonarCloud gate resolved (head
|
Minor findings from the review ("next touch") plus regression coverage for the
just-landed out-of-band error contract:
- mcp_server: hoist the shared-helper imports (AnswerValidationError /
validate_hitl_answer) to module level, removing the lazy import inside
_validate_mcp_choice_answer so it matches routes/hitl.py.
- hitl_context: drop the nested `import json as _json`, using the
module-level json import.
- Add MCP-adapter tests proving a legitimate answer dict carrying an "error"
key is returned as the answer (not mistaken for an error sentinel) and that
an invalid answer yields the ({"error": "invalid_answer"}, None) tuple.
Branch Fixer — follow-up on the remaining review findingsCommit
The two blocking findings from the last review are already on the head from the concurrent fixer commits ( Verification (from Commit SHA: |
farnalabs
left a comment
There was a problem hiding this comment.
Re-review of head b139a0f (fresh review: two non-merge commits landed after the 21:22 CR; dispatch head f3fd2d0 has been superseded). CI green except the still-running parallel SonarCloud coverage job; mergeable=true.
All three blocking findings from the prior reviews are resolved:
- mcp_server.py key-sniffing is fixed: _validate_mcp_choice_answer now returns (error, validated_answer) out-of-band and _dispatch_hitl_action no longer reads an 'error' key from the answer; a test (test_mcp_adapter_keeps_answer_with_error_key_as_answer) locks the behaviour.
- A focused unit test on the shared helper landed (backend/tests/unit/api/test_hitl_answer_validation.py, 15 cases covering none-answer, fail-open on unresolvable config, no-contract approval rules, kind mismatch, choice option_id rules, unsupported kinds).
- The REST HTTPException subclass is renamed AnswerValidationErrorHTTP, removing the name shadowing.
Also good on this head: graph_validator import tidy + _iter_hitl_gate_configs dedup shared by subject_path and response_contract checks, module-level json import in hitl_context.py, and the validation-before-manager-call sequencing in both REST and MCP paths. Contract round-trip matches the regenerated frontend/src/lib/api/schema.ts (answer dict, HitlResponseContract, HitlResponseOption). NOTE: the latest commit b139a0f landed at 21:38:10Z — the head SHA in the PR is newer than the dispatch SHA; the CI run for it was at the time of review green/running as described.
No blocking findings remain. (Feedback only — formal decision posted by post-decision.)
modulo-reviewbot
left a comment
There was a problem hiding this comment.
PR Reviewer post-decision node — APPROVE
Verdict source: policy-router verdict (passed through from review node), head SHA f3fd2d0.
High-risk flag (for human visibility): the policy-router flagged this diff as HIGH-RISK (path-glob match on production code — backend/src/modulo/core/hitl_manager/** and backend/src/modulo/core/pipeline_engine/**). Verdict remains APPROVE per the review node's authoritative assessment; flag set for transparency only.
Why approved
- All three prior blocking findings from the earlier CHANGES_REQUESTED are fixed on the reviewed head:
- MCP error-detection key-sniffing replaced with an out-of-band (error, validated_answer) tuple; test proves an answer carrying an
errorkey is kept as an answer. - Focused 15-case unit test on the shared
validate_hitl_answerhelper, including fail-open-on-unresolvable-config coverage. - REST HTTPException subclass renamed to
AnswerValidationErrorHTTP— no more name shadowing.
- MCP error-detection key-sniffing replaced with an out-of-band (error, validated_answer) tuple; test proves an answer carrying an
- Shared validator = single source of truth for REST + MCP (backend/src/modulo/api/hitl_answer_validation.py).
- Answer-to-state injection only for kind=choice with fails-without-fix e2e coverage; approval option_id cannot leak into audit.
- Response-contract changes flagged as gate-weakening by hitl_gate_guard — correct security posture.
- schema.ts regenerated and consistent with the Pydantic contract; graph-validator dedup via shared
_iter_hitl_gate_configs. - CI green (only the still-running parallel SonarCloud coverage job pending), mergeable=true. No test deletions or skip/xfail additions.
No blocking findings remain.
|



Summary
BACKEND SLICE of FAR-860 (the UI half is a follow-up PR). Today a HITL gate's only responses are approve/reject (plus the hidden deliver_manual and approve-with-modification paths). This generalises the gate to a typed response contract so it can present agent-defined options, and makes the human's answer become pipeline state.
Design authority: ADR 035 (farnalabs/devtools, adr/035-hitl-response-contract.md).
Changes
api/routes/pipelines.py):response_contract: { kind: approval | choice, options: [{id, label, description?}] }onHitlGateConfig.approvalis the default and fully backward compatible;choicerequires a non-empty option list with unique ids.hitl_context.py): the contract is threaded into the fire-time briefing so the UI can render the options.node_runner.py): the chosen option is injected into run state athitl_answer_{gate_id}using the SAME resume seam deliver_manual / approve-with-modification already use - no new routing primitive. Downstream EXISTING conditional edges branch on it.api/routes/hitl.py) and MCP (review_hitl) both carry the answer, both routing throughHITLManager._decide, both using ONE shared validator (api/hitl_answer_validation.py) so their strictness cannot drift.hitl_manager):hitl.output_deliveredgainsanswer_kind/answer_option_id.response_contractchanges as gate changes.human_onlystays default-true (FAR-609); no new bypass.frontend/src/lib/api/schema.tsregenerated (schema freshness verified).Corrections made during QA (all fixed before push)
option_idwas wrongly required for ALL answer kinds (422 on a plain approval answer); validation is now kind-aware.option_idinto run state;_inject_answer_statenow early-returns unlesskind == "choice".Tests
test_fails_without_injectionproof that the conditional edge only routes when the injection happens.Not in this PR (follow-up)
Part of FAR-860