feat(major2): the hosted surface refuses the proposal review by name and points at the CLI [skip-docs]

Ordre 20260904T173146Z-8102814273-from-portfolio-optimiser, steg 7 av 10.

En PRE-whitelist-sjekk paa raa payload, ETTER isinstance-vakten (en ikke-objekt-body skal
beholde sin 400, ikke bli en 500) og FOER den generiske unknown-field-sjekken, som ellers
ville svart foerst.

Plasseringen er MAALT, ikke valgt: _CONSUMED_FIELDS maa vaere disjunkt fra run_projects
parametre (Fase 4es negative halvdel) mens proposal_reviewer ER en av dem, saa navnet kan
ikke bo i noen av de tre listene. F4-presedensen er IKKE analog - enable_plan_review er en
NOESTET noekkel inne i det whitelistede explore_contract, som er derfor den kan navngis der.

DISKRIMINATOREN ER TEKSTEN, IKKE STATUSEN: whitelisten svarer alt enhver ukjent nokkel med
400 "unknown field(s)", saa en detachet navngitt nekt ville fortsatt gitt 400 med feltnavnet.
Den navngitte meldingen peker paa CLI-doera og paa /readiness, og kontrollen (et ordinaert
ukjent felt) asserterer at den generiske meldingen deler ingenting av det.

Null run_project-kall, ikke bare en 400 (oekt 57).

_response_payload er IKKE utvidet: flaten nekter revieweren, saa feltet kunne kun vaert tomt.

RODT foer impl: tre armer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 07:09:57 +02:00
commit bfc634d806
2 changed files with 80 additions and 2 deletions

View file

@ -24,6 +24,7 @@ to today, golden transcript included.
from __future__ import annotations
import inspect
import io
import json
import subprocess
@ -35,7 +36,7 @@ from typing import Any
import pytest
from spikes._harness import FakeChatClient, message_texts
from portfolio_optimiser import hitl
from portfolio_optimiser import hitl, hosting
from portfolio_optimiser import proposal_review as pr
from portfolio_optimiser.budget import Budget, BudgetExceeded, TokenMeter
from portfolio_optimiser.generate import ParseFailure, _build_messages, generate_via_llm
@ -49,7 +50,8 @@ from portfolio_optimiser.validator import Rejection, ValidatedProposal, proposal
from portfolio_optimiser.verdicts import VerdictStore
_REPO = Path(__file__).resolve().parents[1]
_BUNDLE_DIR = _REPO / "shared" / "examples" / "bygg-energi-mikro"
_EXAMPLES = _REPO / "shared" / "examples"
_BUNDLE_DIR = _EXAMPLES / "bygg-energi-mikro"
_PID = "BYGG-KONTOR-NORD"
_RUN_ID = "proposal-review-door"
@ -1395,3 +1397,61 @@ def test_a_run_with_no_reviewer_prints_no_review_line(
argv = [a for a in _cli_argv(tmp_path, outbox="outbox") if a != "--proposal-review"]
assert run.main(argv) == 0
assert "proposal review" not in capsys.readouterr().out
# ---------------------------------------------------------------------------------------------
# Group D (Steps 7 and 8) — the hosted refusal, and the library dispatcher.
# ---------------------------------------------------------------------------------------------
class _RunProjectRecorder:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
self.calls.append(kwargs)
raise AssertionError("run_project must not be reached on a refused invocation")
async def test_t21_the_hosted_surface_refuses_the_door_by_name(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""T21 — the hosted refusal. Detach points: deleting the named refusal (M22), forwarding the
field instead (M23).
**The discriminator is the TEXT, not the status.** The whitelist already answers any unknown
field with ``400 unknown field(s): ``, so a detached named refusal would STILL return 400
naming the field the arm would pass while the message said nothing about where the door
actually is. So the named message points at the CLI and at ``/readiness``, and the control
(an ordinary unknown field) asserts the generic message shares none of it.
Zero ``run_project`` calls, not merely a 400: at the status code a refusal after the spend
looks identical to one before it (økt 57's rule)."""
recorder = _RunProjectRecorder()
monkeypatch.setattr(hosting, "run_project", recorder)
payload = {"project_id": _RUN_PID, "docs_dir": str(_BUNDLE_DIR)}
with pytest.raises(hosting.InvocationRefused) as excinfo:
await hosting.invoke({**payload, "proposal_review": True})
message = str(excinfo.value)
assert "--proposal-review" in message
assert "/readiness" in message
assert recorder.calls == []
with pytest.raises(hosting.InvocationRefused) as generic:
await hosting.invoke({**payload, "outbox_dir": "/x"})
assert "--proposal-review" not in str(generic.value)
assert "outbox_dir" in str(generic.value)
def test_the_refused_name_enters_neither_half_of_the_whitelist() -> None:
"""The partition arm. Fase 4e's two asserts (every forwarded field IS a ``run_project``
parameter, every consumed field is NOT) must stay untouched, which is why this is a
PRE-whitelist check on the raw payload rather than a fourth list entry.
The F4 precedent is measured as NOT analogous: ``enable_plan_review`` is a NESTED key inside
the whitelisted ``explore_contract``, which is why it can be named at all."""
assert set(hosting._REFUSED_BY_NAME).isdisjoint(hosting._ALLOWED_FIELDS)
assert set(hosting._REFUSED_BY_NAME).isdisjoint(inspect.signature(run_project).parameters)
# ...and the parameter it corresponds to is a real one, so the refusal names a door that exists.
assert "proposal_reviewer" in inspect.signature(run_project).parameters