From e0f93dfa7bf9fb53c8f7f8e75ed32a5979493e3b Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 15:03:45 +0200 Subject: [PATCH] fix(fase3): stamp real proposer model into provenance, kill fake-model leak (F1) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019any9zfGNNwWJPX5Zq2QRz --- src/portfolio_optimiser/run.py | 12 ++++++++++-- tests/test_vertical_slice_e2e.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/portfolio_optimiser/run.py b/src/portfolio_optimiser/run.py index 2bdfc48..b8ea642 100644 --- a/src/portfolio_optimiser/run.py +++ b/src/portfolio_optimiser/run.py @@ -190,11 +190,19 @@ async def run_project( gen_context = debate_output or context # 5. Structured candidate -> blocking validation; token bound = the meter in this loop. - outcome = await generate_via_llm(factory("proposer"), project, gen_context, meter) + proposer_client = factory("proposer") + outcome = await generate_via_llm(proposer_client, project, gen_context, meter) proposal = outcome.proposal # 6. First-class provenance stamp (authoritative; independent of MAF Annotation). - model = "fake-model" if client_factory is not None else resolve_model(profile, "proposer") + # F1: an injected client_factory stamps the injected client's REAL model ("unknown" is the + # neutral fallback for a client that doesn't surface one -- never a fabricated name); the + # default path keeps the deterministic resolve_model. + model = ( + (getattr(proposer_client, "model", None) or "unknown") + if client_factory is not None + else resolve_model(profile, "proposer") + ) stamp = ProvenanceStamp( citations=citations, model=model, diff --git a/tests/test_vertical_slice_e2e.py b/tests/test_vertical_slice_e2e.py index 63006f2..34bd6c3 100644 --- a/tests/test_vertical_slice_e2e.py +++ b/tests/test_vertical_slice_e2e.py @@ -41,6 +41,24 @@ async def test_a_valid_proposal_end_to_end(docs_dir, make_client_factory, fresh_ assert stamp.token_usage > 0 # sourced from the synthetic UsageDetails +async def test_a1_provenance_stamps_injected_client_model_not_sentinel( + docs_dir, make_client_factory, fresh_store +) -> None: + """F1 regression: an injected client_factory must stamp the injected client's REAL model + (SyntheticUsageChatClient.model == 'synthetic'), never the 'fake-model' literal — the leak + falsified provenance on the public deployer seam (run.py:197).""" + result = await run_project( + "FV42-GSV-E1", + "local", + docs_dir=docs_dir, + verdict_input=_VI, + client_factory=make_client_factory(_VALID), + store=fresh_store, + ) + assert result.provenance.model == "synthetic" + assert result.provenance.model != "fake-model" + + async def test_b_out_of_range_is_rejected(docs_dir, make_client_factory, fresh_store) -> None: result = await run_project( "FV42-GSV-E1",