Skip to content

feat(FAR-860) [partial]: HITL response contract backend (schema, injection, REST + MCP, audit) - #616

Merged
github-actions[bot] merged 7 commits into
mainfrom
deliver/FAR-860
Sep 15, 2026
Merged

github-actions[bot] merged 7 commits into
mainfrom
deliver/FAR-860

Conversation

@farnalabs

Copy link
Copy Markdown
Owner

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

  • Schema (api/routes/pipelines.py): response_contract: { kind: approval | choice, options: [{id, label, description?}] } on HitlGateConfig. approval is the default and fully backward compatible; choice requires a non-empty option list with unique ids.
  • Briefing capture (hitl_context.py): the contract is threaded into the fire-time briefing so the UI can render the options.
  • Answer injection (node_runner.py): the chosen option is injected into run state at hitl_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.
  • Surfaces: REST (api/routes/hitl.py) and MCP (review_hitl) both carry the answer, both routing through HITLManager._decide, both using ONE shared validator (api/hitl_answer_validation.py) so their strictness cannot drift.
  • Audit (hitl_manager): hitl.output_delivered gains answer_kind / answer_option_id.
  • Guardrails: graph validation for edge-level AND node-level configs; the gate-weakening guard treats response_contract changes as gate changes. human_only stays default-true (FAR-609); no new bypass.
  • Generated frontend/src/lib/api/schema.ts regenerated (schema freshness verified).

Corrections made during QA (all fixed before push)

  • option_id was wrongly required for ALL answer kinds (422 on a plain approval answer); validation is now kind-aware.
  • An approval-gate answer could inject an arbitrary option_id into run state; _inject_answer_state now early-returns unless kind == "choice".
  • mypy strict on the new code (exact CI invocation).

Tests

  • New: response-contract model/validation, graph-validator (edge + node), gate-guard, audit, and an end-to-end branching test using a real StateGraph + the real state reducer, including a test_fails_without_injection proof that the conditional edge only routes when the injection happens.
  • Regression: existing approve/reject gates behave identically.

Not in this PR (follow-up)

  • The HITL review UI for rendering selectable options and submitting the choice.
  • Multi-turn revise loops (recorded in ADR 035 as the follow-on).

Part of FAR-860

@farnalabs farnalabs added the agent-generated PR created by an autonomous agent label Sep 15, 2026

@farnalabs farnalabs left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 farnalabs left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code findings from review of the three-dot diff (non-blocking feedback; formal decision follows post-decision):

  1. mcp_server.py _dispatch_hitl_action: the error-sentinel check isinstance(validated_answer, dict) and "error" in validated_answer collides 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.

  2. 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).

  3. 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.

  4. 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.

  5. CI: Test (Backend) and Lint (Backend) fail on head 1f04518 — the re-review blocker; see the earlier comment.

@modulo-reviewbot modulo-reviewbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. CI is failing on head. Test (Backend) and Lint (Backend) check-runs are failing on 1f04518; merge is currently blocked. The PR cannot be merged until these go green.

  2. backend/src/modulo/api/mcp_server.py — error-sentinel key-sniffing. _dispatch_hitl_action checks 'error' in validated_answer to detect an MCP error dict. A legit answer dict that happens to contain an 'error' key is misread as an MCP error and silently skips mgr.approve. Return a tuple/sentinel from the validation path instead of sniffing dict keys.

  3. backend/src/modulo/api/routes/hitl.py — name shadowing. The REST AnswerValidationError (an HTTPException subclass) shadows the shared ValueError subclass name in api/hitl_answer_validation.py. Rename to avoid confusion.

  4. backend/src/modulo/api/hitl_answer_validation.py — undocumented fail-open. The shared validator fails open (returns the unvalidated answer) when resolve_hitl_gate_config returns None, meaning an arbitrary option_id is accepted in the drift case. Deliberate, but it must be documented (and ideally logged).

  5. Missing focused unit test. validate_hitl_answer itself 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: nested import json as _json inside 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 approval option_id leak — good.
  • backend/tests/unit/api/test_hitl_response_contract_models.py etc. 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).
@farnalabs

Copy link
Copy Markdown
Owner Author

Branch Fixer — CI fixes pushed (a5ae3bccd)

Fixes the two failing checks on head 1f04518:

Lint (Backend)semgrep.inline-import-route-files flagged the two inline imports of modulo.api.hitl_answer_validation inside _validate_choice_answer (backend/src/modulo/api/routes/hitl.py:509,512). There is no circular dependency here (hitl_answer_validation.py imports only modulo.db.crud.hitl_gate_config), so both imports are hoisted to module level. Re-verified: ruff check / ruff format --check, mypy src/modulo/, lint-imports, and the semgrep scan over the file all pass.

Test (Backend)tests/unit/pipeline_engine/test_executor.py::test_interrupt_persists_fire_time_context_on_the_gate_row asserted the exact key set of the persisted gate context. FAR-860 adds response_contract in build_hitl_gate_context (core/pipeline_engine/hitl_context.py), so the expected set now includes it, plus an assertion that response_contract is None for this gate (it declares no contract). tests/unit/pipeline_engine + test_hitl_response_contract_models.py: 2010 passed.

Non-blocking review findings 1–4 (MCP error-sentinel collision, REST AnswerValidationError naming overlap, fail-open comment, direct unit test for validate_hitl_answer) are intentionally left untouched: they are design calls rather than CI failures, and this fix stays scoped to the re-review blocker (finding 5).

@farnalabs farnalabs left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. api/mcp_server.py _dispatch_hitl_action (approve + deliver_manual): the error-detection check isinstance(validated_answer, dict) and "error" in validated_answer key-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.

  2. 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.

  3. 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 modulo-reviewbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.)

  2. 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.)

  3. 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.
@farnalabs

Copy link
Copy Markdown
Owner Author

Branch Fixer — SonarCloud quality gate fix (f3fd2d00c)

The SonarCloud scan (coverage import) check failed on head a5ae3bccd: the quality gate was ERROR on two conditions.

Root cause

  • new_duplicated_lines_density = 4.2% (> 3% threshold) — the 21-line node/edge HITL gate-config walk added by _check_hitl_gate_response_contracts was a verbatim copy of the walk in the pre-existing _check_hitl_gate_subject_paths.
  • new_coverage = 73.5% (< 80% threshold) — backend/src/modulo/api/hitl_answer_validation.py (42 new lines to cover) had 33 uncovered lines (21.4% covered); no test exercised validate_hitl_answer directly.

Fix

  1. Extracted the shared node-level hitl_config + edge-level hitl_gate_config walk into a single GraphValidator._iter_hitl_gate_configs generator; both _check_hitl_gate_subject_paths and _check_hitl_gate_response_contracts now consume it. This removes both duplicated copies and, more importantly, stops the two checks drifting on the "skip edge configs that belong to a node-level gate" rule.
  2. Added backend/tests/unit/api/test_hitl_answer_validation.py, a direct unit test suite for validate_hitl_answer covering every contract branch: None answer, missing/non-string kind, unresolvable config (fail-open), no-contract approval, no-contract option_id rejection, kind mismatch, choice option membership, non-list options, approval option_id rejection, and unsupported kind. resolve_hitl_gate_config is patched, so no DB is required.

Verification (run from backend/)

  • ruff check + ruff format --check on changed paths: clean
  • mypy src/modulo/core/graph_validator/__init__.py src/modulo/api/hitl_answer_validation.py: clean
  • lint-imports: 8 contracts kept, 0 broken
  • pytest tests/unit/api/test_hitl_answer_validation.py: 17 passed; module coverage 100% (42/42)
  • pytest tests/unit/graph_validator/ tests/unit/api/test_hitl_routes_coverage.py: all passed
  • Pre-commit hooks (semgrep, gitleaks, import-linter, ruff): passed

Expected gate movement: new_duplicated_lines_density → ~0%, new_coverage → ~90% (both well inside the thresholds).

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.
@farnalabs

Copy link
Copy Markdown
Owner Author

Branch Fixer — review blockers + SonarCloud gate resolved (head a84a8e802)

The branch now addresses all three blocking findings from the CHANGES_REQUESTED review and both failing SonarCloud quality-gate conditions.

MCP error sentinel (blocking #1)a84a8e802
_validate_mcp_choice_answer now returns (error, validated_answer) out-of-band. _dispatch_hitl_action returns the error dict when error is not None instead of key-sniffing "error" in validated_answer. Since validate_hitl_answer returns the answer unmodified, a legitimate answer such as {"kind": "choice", "option_id": "x", "error": "..."} previously passed validation and was then misread as an MCP error, silently skipping mgr.approve / mgr.deliver_manual. The tuple keeps the error channel separate from the answer payload.

REST name shadowing (blocking #3)a84a8e802
Renamed the REST HTTPException subclass to AnswerValidationErrorHTTP so it no longer shadows the shared ValueError subclass api/hitl_answer_validation.AnswerValidationError; both names now grep unambiguously.

Focused validator test (blocking #2) + Sonar dedup/coveragef3fd2d00c
A concurrent Branch Fixer pass landed the other two items on the same branch while this run was in flight, so a84a8e802 rebases cleanly on top of it: the node+edge HITL-gate walk is extracted to a shared _iter_hitl_gate_configs (removes the 21-line new-code duplication that drove new_duplicated_lines_density 4.2% > 3%), and backend/tests/unit/api/test_hitl_answer_validation.py covers every validate_hitl_answer branch (raises new_coverage above the 80% threshold from 73.5%).

Verified locally on the reconciled head: ruff check / ruff format --check, mypy src/modulo/, bandit, vulture, and the targeted unit suites (validator, HITL resilience/routes, MCP HITL, graph-validator contract) all pass. Pre-commit hooks ran on both commits (a84a8e802); no --no-verify.

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.
@farnalabs

Copy link
Copy Markdown
Owner Author

Branch Fixer — follow-up on the remaining review findings

Commit b139a0f5b (on top of a84a8e802) closes the two minor items the re-review called out ("non-blocking, next touch") and adds regression coverage for the MCP error-contract change:

  1. backend/src/modulo/api/mcp_server.py — import consistency. Hoisted the shared-helper imports (AnswerValidationError / validate_hitl_answer from api/hitl_answer_validation.py) to module level, removing the lazy import inside _validate_mcp_choice_answer, so the MCP surface matches the now-hoisted routes/hitl.py.
  2. backend/src/modulo/core/pipeline_engine/hitl_context.py — nested import. Removed the import json as _json inside _build_context_inner; it now uses the existing module-level json import.
  3. MCP adapter regression tests (backend/tests/unit/api/test_hitl_answer_validation.py):
    • test_mcp_adapter_keeps_answer_with_error_key_as_answer — pins the exact bug from the review: a legitimate answer dict carrying an "error" key is returned as the answer, not mistaken for the error sentinel (the old "error" in validated_answer key-sniffing silently skipped mgr.approve).
    • test_mcp_adapter_returns_error_tuple_on_invalid_answer — pins the new out-of-band (error, answer) contract.

The two blocking findings from the last review are already on the head from the concurrent fixer commits (a84a8e802: MCP (error, answer) tuple + AnswerValidationErrorHTTP rename; f3fd2d00c: graph-validator gate-config dedup + focused validate_hitl_answer tests) — this commit does not duplicate them, only adds the minor cleanups and coverage on top.

Verification (from backend/): ruff check ., ruff format --check ., and mypy src/modulo/ all clean; targeted unit suites pass (test_hitl_answer_validation.py, test_mcp_hitl_inspection.py, test_hitl_resilience.py, test_mcp_server_coverage_gaps.py, test_hitl_answer_injection*.py, tests/unit/graph_validator/, tests/architecture/).

Commit SHA: b139a0f5b2755b981583f8bf5674cfbc2caa0dae

@farnalabs farnalabs left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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).
  3. 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 modulo-reviewbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
    1. MCP error-detection key-sniffing replaced with an out-of-band (error, validated_answer) tuple; test proves an answer carrying an error key is kept as an answer.
    2. Focused 15-case unit test on the shared validate_hitl_answer helper, including fail-open-on-unresolvable-config coverage.
    3. REST HTTPException subclass renamed to AnswerValidationErrorHTTP — no more name shadowing.
  • 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.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions
github-actions Bot merged commit f192790 into main Sep 15, 2026
27 checks passed
@github-actions
github-actions Bot deleted the deliver/FAR-860 branch September 15, 2026 22:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-generated PR created by an autonomous agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants