feat(fase6): gate-promote approved verdicts back into the OKF wiki (Steg 8)

Close the last agentic-loop seam (målbilde §3/§6/§7/§11 step 6): an
APPROVED verdict is promoted from the raw output layer into the context
layer (the OKF bundle) as a navigable `type: verdict` concept file, so
human/persona-approved knowledge reaches the next run's hypothesis.

- okf.py (pure stdlib, MAF-free): render_frontmatter / write_concept_file
  / link_in_index — the D7-portable OKF write counterpart of navigate.
- verdicts.py: promote_verdict + PromotionRefused gate (fail-closed; only
  approved decisions enter the wiki, never raw agent output), provenance
  stamp (who/experiment/when; timestamp a required kwarg), neutral index
  label (signal reaches a prompt only via the gated ExpeL fold, never
  bundle_context), _safe_filename_token (id sanitised for path/link).
- R4 = optional+gated: a public opt-in primitive, NOT wired into
  run_project (mirrors write_verdict — the system reads, the gate promotes).
- Load-bearing trio (test_step8_promotion_loadbearing.py): gate refuses a
  non-approved verdict, approved verdict is navigable, promoted signal
  stays out of the read-context — all proven RED-on-detach. Suite 144->148.

Design hardened by an adversarial plan-critic (12 findings; the BLOCKER —
index-link leak into bundle_context via index_summary — closed by the
neutral label + a no-leak test). Honesty limits documented: promoted file
is minimal (signal as prose only), and the learning-key id means
same-candidate approvals share a filename (last-write-wins).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
This commit is contained in:
Kjell Tore Guttormsen 2026-06-30 11:06:28 +02:00
commit 6b645ad32a
6 changed files with 385 additions and 2 deletions

View file

@ -69,6 +69,24 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
`tests/test_step7_async_loop_loadbearing.py` — en dom droppet etter Run A MÅ nå Run B's prompt
(Run B bruker FERSK store → overføringen er fil-løkka, ikke in-memory-carryover); tom-inbox-kontroll
beviser kausalitet. Markør = realiseringsverdi som finnes ingen steder i bundelen (ikke frøets 0.82).
- **Gated wiki-promotering (Steg 8, målbilde §3/§6/§7):** når en ekspert/persona GODKJENNER et
utfall, løfter `verdicts.promote_verdict` det fra RAW output-laget inn i kontekst-laget (OKF-bundelen)
som en `type: verdict`-konseptfil, navigerbar av neste kjørings `seed_store_from_bundle`. **Gaten er
fail-closed:** en ikke-godkjent dom (`decision ∉ {approved, approved_with_adjustment}`) raiser
`PromotionRefused` og skriver/linker INGENTING — kun menneske/persona-godkjent kunnskap når wikien,
aldri rå agent-output (selv-forurensning). Provenance-stemplet (hvem/eksperiment/når; `timestamp` er
påkrevd keyword, ingen wall-clock-default → deterministisk). **OKF-skriveren bor i `okf.py` og er ren
stdlib** (D7-portabel, MAF-fri — håndhevet av `test_okf_is_maf_free`); navigasjon følger KUN
index-cross-links, så `promote_verdict` linker filen i `index.md` via en NØYTRAL label (ellers lekker
signalet inn i `index_summary``bundle_context` utenom gaten). **R4 = valgfri+gated:** `promote_verdict`
er en offentlig opt-in-primitiv, wires IKKE inn i `run_project` (speiler `write_verdict` — systemet
leser; gaten/personaen promoterer). Ærlighets-grenser: promotert fil er MINIMAL (læringssignal kun som
`description`/body-prosa, reproduserer ikke seedens strukturerte `realization_rate` o.l.); id =
læringsnøkkel, så to godkjenninger om samme kandidat deler filnavn (last-write-wins, som `write_verdict`)
— wikien vokser én kuratert fil per distinkt kandidat, ikke per dom-hendelse. Load-bearing-trio
(`tests/test_step8_promotion_loadbearing.py`): gaten avviser ikke-godkjent dom (RØD uten gate); godkjent
dom er navigerbar (RØD når `link_in_index` detaches); promotert signal holdes ute av `bundle_context`
(RØD når en beskrivende index-label lekker det inn). Index-RMW er ikke-atomisk (enprosess-MVP).
- **Kostnadsdisiplin:** utvikle primært på lokal profil (gratis); Foundry/Azure (privat tenant finnes) kun til målrettet, minimal verifisering; billigste modeller + små syntetiske data + harde token-tak. Ingen tunge test-kjøringer.
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.

View file

@ -2,7 +2,7 @@
Generic, open framework on **Microsoft Agent Framework (MAF)** for finding cost-savings / efficiency proposals *within* each project of a portfolio of independent projects. Multiple agents collaborate to generate candidate proposals; a mandatory deterministic validator (solver + Monte Carlo) decides the numbers; domain experts review via human-in-the-loop, and the system learns from their verdicts.
> **Status:** Early development. The deterministic backbone is solid; the agentic learning loop is being wired one load-bearing seam at a time — **Steps 1, 3/4, 5 and 7 are wired** (OKF-navigated context, checker gate, informed refinement, and the async file feedback loop; see below). Step 8 (gated wiki promotion) remains. Not yet end-to-end usable.
> **Status:** Early development. The deterministic backbone is solid; the agentic learning loop is being wired one load-bearing seam at a time — **Steps 1, 3/4, 5, 7 and 8 are wired** (OKF-navigated context, checker gate, informed refinement, the async file feedback loop, and gated wiki promotion; see below). Not yet end-to-end usable.
> **Disclaimer — technical framework only.** This project is a *technical framework*. Organizations that deploy it are themselves responsible for ensuring a valid processing purpose and for any required assessments (DPIA, risk/ROS, security reviews, etc.). The framework ships technical affordances (local-only mode, provenance/audit logging, no silent data egress) to *enable* compliant use, but makes no compliance guarantees.
@ -21,7 +21,7 @@ The mandatory deterministic backbone (validator + budget meter + provenance) is
- **Steps 3/4 — checker gates the reasoning (wired).** Two falsifiers now act on the same candidate: the deterministic validator gates the **numbers** (blocking, as before), and the maker-checker's checker gates the **reasoning** (target picture §2/§6). The checker ends its turn with a `VERDICT: APPROVE` / `VERDICT: REJECT — <reason>` line; an explicit reject blocks an otherwise-validated proposal (`run_project` surfaces both debate participants via `output_from=agents` and overrides the outcome to a checker-sourced `Rejection`). The gate is opt-in-reject (fail-open on a missing marker), and `provenance.validator_decision` stays honest — it reflects the validator only, never the checker. Load-bearing: `tests/test_checker_gate_loadbearing.py` goes red on *either* detach (revert `output_from`, or drop the override).
- **Step 5 — informed refinement (wired).** The proposer's bounded retry is no longer blind: the validator's *previous* `Rejection.reason` is fed into the next attempt's prompt (`generate.py` `generate_via_llm``_build_messages(prior_rejection=...)`), so the model corrects against the falsification instead of re-answering identically (target picture §5/§7). It carries only the most-recent reason (never an accumulated history, never the rejected proposal JSON) and runs under the *existing* `max_attempts` + token-meter cap — no new loop, so "improve until good enough" without a ceiling stays impossible. The only per-attempt falsifier here is the validator; seeding generation with the checker's critique is a run-level, separately-scoped concern and is deliberately not done here. Load-bearing: `tests/test_step5_refine_loadbearing.py` goes red when the reason is detached from the prompt (the outcome never flips *and* the verbatim-reason assertion fails), with a bounded control proving the loop still stops at `max_attempts`.
- **Step 7 — async file feedback loop (wired).** `run_project(..., verdict_dir=...)` adds the long feedback timescale (target picture §3/§7): an expert/persona drops a verdict file (plain JSON — the raw output layer, §10 R2) into an inbox folder *after* a run, and a separate, later run ingests it — merged into the store *before* the Step-1 fold — so a verdict that landed out of band reaches the next hypothesis. The loop is fully resumable across runs separated in time; no live session is assumed. The system *reads* the folder, the expert/persona *writes* it (§3 role split), so `run_project` deliberately does **not** persist its own captured verdict back (that is the outbox / Step-8 concern). Ingestion is tolerant (a missing folder, foreign or half-written files are skipped, not raised) and **merges** (never replaces), preserving `run_portfolio`'s cross-project store. Reachable from the CLI via `--bundle-dir --verdict-dir`. Load-bearing: `tests/test_step7_async_loop_loadbearing.py` — a verdict dropped after run A must reach run B's prompt (run B uses a *fresh* store, so the transfer is the file loop, not in-memory carryover), with an empty-inbox control proving causality.
- **Step 8** (gated wiki promotion) — not yet wired.
- **Step 8 — gated wiki promotion (wired).** When an expert/persona **approves** an outcome, `verdicts.promote_verdict` lifts it from the raw output layer into the context layer (the OKF bundle) as a `type: verdict` concept file, navigable by the next run's `seed_store_from_bundle` (target picture §3/§6/§7). The **gate** is fail-closed: a verdict whose decision is not an approval raises `PromotionRefused` and writes/links nothing — only human/persona-approved knowledge enters the wiki, never raw agent output (self-contamination). The promotion is provenance-stamped (who approved / which experiment / when — `timestamp` is a required keyword, no wall-clock default). The OKF writer lives in `okf.py` and stays pure stdlib (D7-portable, MAF-free). **R4 = optional + gated:** `promote_verdict` is a public opt-in primitive, deliberately **not** wired into `run_project` (mirrors `write_verdict` — the system reads context; the gate/persona promotes). Two honesty limits: the promoted file is *minimal* (it carries the learning signal only as `description`/body prose — it does not reproduce the hand-authored seed's structured `realization_rate` etc.), and because the verdict id is the learning key, two approvals about the same candidate share a filename (last-write-wins, like `write_verdict`) — the wiki grows one curated file per distinct candidate, not per verdict event. Load-bearing trio (`tests/test_step8_promotion_loadbearing.py`): the gate refuses a non-approved verdict (red if the gate is removed), the approved verdict is navigable (red if `link_in_index` is detached), and the promoted signal stays out of `bundle_context` — reaching a prompt only via the gated ExpeL fold (red if a descriptive index label leaks it into the read-context).
## Docs

View file

@ -143,6 +143,44 @@ def bundle_context(bundle: Bundle) -> str:
return "\n\n".join(s for s in sections if s.strip())
def render_frontmatter(frontmatter: dict[str, str]) -> str:
"""Render a frontmatter dict as ``key: value`` lines (the inverse direction of
``parse_frontmatter``, used by the Step-8 promotion writer). Scalar values are **single-lined**
(every newline/CR collapses to a space) because ``parse_frontmatter`` is line-oriented and stops
at the first ``---`` line a multi-line value would otherwise corrupt the block or terminate it
early. NOT a bijection: this only guarantees that the single-line fields it writes re-parse to
the same strings; ``parse_frontmatter`` keeps quotes and treats ``tags: [...]`` as a literal
string, so callers pass already-formatted values. Keys are emitted in insertion order."""
return "\n".join(f"{key}: {' '.join(str(value).split())}" for key, value in frontmatter.items())
def write_concept_file(bundle_dir: str, name: str, frontmatter: dict[str, str], body: str) -> Path:
"""Write a typed OKF concept file (``---`` frontmatter + markdown body) into ``bundle_dir``,
path-safe via ``safe_resolve`` (fail-closed: a ``name`` escaping the bundle raises
``PathSecurityError``). Pure stdlib the D7-portable counterpart of ``navigate_bundle``'s read.
Returns the written path."""
resolved = Path(safe_resolve(bundle_dir, name))
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_text(f"---\n{render_frontmatter(frontmatter)}\n---\n\n{body}", encoding="utf-8")
return resolved
def link_in_index(bundle_dir: str, target_name: str, label: str) -> bool:
"""Append an intra-bundle cross-link ``- [label](target_name)`` to ``index.md`` so
``navigate_bundle`` (which follows ONLY index cross-links) reaches a newly written file.
Idempotent: if a link to ``target_name`` already exists the index is left untouched. Returns
whether a link was added. ``label`` is supplied by the caller and ends up in ``index_summary``
(hence ``bundle_context``) verbatim, so the promotion policy passes a NEUTRAL label carrying no
verdict signal (målbilde §3/§6). Known MVP limitation: the read-modify-write is not atomic."""
resolved = Path(safe_resolve(bundle_dir, _INDEX_NAME))
body = resolved.read_text(encoding="utf-8")
if f"]({target_name})" in body:
return False
prefix = body if body.endswith("\n") else body + "\n"
resolved.write_text(f"{prefix}- [{label}]({target_name})\n", encoding="utf-8")
return True
def load_ir_projection(bundle_dir: str, name: str = _IR_PROJECTION) -> dict[str, Any]:
"""Load the bundle's IR projection (``validator-input.json`` by default): the candidate
measure's cost-IR (``measure``, ``affected_items``, ``claimed_saving_nok``) — the

View file

@ -20,6 +20,7 @@ from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@ -354,3 +355,90 @@ def seed_store_from_bundle(bundle_dir: str) -> VerdictStore:
for vf in bundle.verdicts
]
return VerdictStore(verdicts=verdicts)
# --- Gated wiki-promotion (Fase 6, Steg 8): output layer -> context layer, HITL-gated ------------
# målbilde §3 (promoterings-gate) / §6 (kun godkjent kunnskap, aldri rå agent-output; provenance) /
# §7 (load-bearing: a non-approved verdict must NOT reach the wiki). R4 = optional+gated: this is a
# PUBLIC opt-in primitive, deliberately NOT wired into run_project — the system reads context; the
# gate/persona promotes (mirrors write_verdict's role split).
_APPROVED_DECISIONS = frozenset({"approved", "approved_with_adjustment"})
# A FIXED neutral index label carrying NO verdict signal. link_in_index folds it into index.md ->
# index_summary -> bundle_context verbatim, so passing the rationale here would leak the realization
# signal into the read-context on a path that bypasses the gate (målbilde §3/§6). Load-bearing:
# test_step8 Test C goes red if this is replaced by the rationale.
_PROMOTED_LINK_LABEL = "Promotert ekspert-vurdering (gated)"
class PromotionRefused(Exception):
"""The gate (målbilde §6): a non-approved verdict was offered for promotion. Fail-closed — the
wiki receives ONLY human/persona-approved knowledge, never raw agent output (self-contamination).
"""
def _safe_filename_token(verdict_id: str) -> str:
"""Turn a verbatim ``Verdict.id`` (arbitrary author string — sentinels, hashes, anything) into a
filename/link-safe token: keep ``[A-Za-z0-9._-]``, replace the rest with ``-``. A token that is
only separators/dots (degenerate, e.g. ``".."``) falls back to a content hash. This prevents an
id with ``/`` (an unnavigable link) or ``..`` from steering the written path defense beside
``write_concept_file``'s fail-closed ``safe_resolve``. The original id is kept in frontmatter."""
token = re.sub(r"[^A-Za-z0-9._-]", "-", verdict_id)
if not token.strip(".-_"):
return hashlib.sha256(verdict_id.encode("utf-8")).hexdigest()[:16]
return token
def promote_verdict(
bundle_dir: str,
verdict: Verdict,
*,
approver: str,
experiment: str,
timestamp: str,
) -> Path:
"""Promote an APPROVED verdict from the raw output layer into the OKF context layer (the wiki) as
a ``type: verdict`` concept file, navigable by the next run's ``seed_store_from_bundle`` (Steg 8,
målbilde §3/§6/§7). GATE (fail-closed): a verdict whose ``decision`` is not an approval raises
``PromotionRefused`` and writes/links NOTHING only human/persona-approved knowledge enters the
wiki. Provenance-stamped (who/which-experiment/when). ``timestamp`` is a required keyword (no
wall-clock default) so promotion is deterministic and the stamp reproducible.
The promoted file is MINIMAL: it does NOT reproduce the hand-authored seed's structured fields
(``realization_rate`` etc.) the raw ``Verdict`` model carries the learning signal only as
``rationale`` prose, which becomes the ``description`` frontmatter ``seed_store_from_bundle``
folds into ExpeL. The index link uses a NEUTRAL label (``_PROMOTED_LINK_LABEL``), so the signal
reaches a prompt only via the gated fold, never via ``bundle_context`` (§3/§6).
Known limitation (mirrors ``write_verdict``): ``_mint_id`` keys on the candidate features, so two
approved verdicts about the SAME candidate share an id -> share a filename -> last-write-wins;
the wiki grows one curated verdict file per distinct candidate measure, not per verdict event.
Returns the written path."""
if verdict.decision not in _APPROVED_DECISIONS:
raise PromotionRefused(
f"refusing to promote a non-approved verdict (decision={verdict.decision!r}); "
"only human/persona-approved knowledge enters the wiki (målbilde §6)"
)
f = verdict.proposal_features
frontmatter = {
"type": "verdict",
"decision": verdict.decision,
"description": verdict.rationale,
"verdict_id": verdict.id,
"provenance": f"godkjent av {approver}; eksperiment {experiment}; {timestamp}",
"timestamp": timestamp,
"tags": "[verdict, promoted, HITL]",
}
codes = ", ".join(sorted(f.affected_codes))
body = (
"# Promotert ekspert-vurdering\n\n"
f"{verdict.rationale}\n\n"
f"- Tiltak: {f.measure_type}\n"
f"- Berørte koder: {codes}\n"
f"- Beslutning: {verdict.decision}\n"
f"- Provenance: {frontmatter['provenance']}\n"
)
filename = f"promoted-verdict-{_safe_filename_token(verdict.id)}.md"
path = okf.write_concept_file(bundle_dir, filename, frontmatter, body)
okf.link_in_index(bundle_dir, filename, _PROMOTED_LINK_LABEL)
return path

View file

@ -97,6 +97,71 @@ def test_parse_frontmatter_reads_scalar_fields() -> None:
assert fm["decision"] == "approved_with_adjustment"
def test_render_frontmatter_roundtrips_consumed_fields(tmp_path) -> None:
"""Step-8 writer: ``render_frontmatter`` + ``write_concept_file`` emit a block that
``parse_frontmatter`` re-reads with the fields ``seed_store_from_bundle`` consumes
(``type``, ``decision``, ``description``) intact. NOT a bijection only these scalar fields
are guaranteed to survive write -> read."""
fm = {
"type": "verdict",
"decision": "approved",
"description": "LED-retrofit godkjent (realiseringsgrad=0.57)",
"verdict_id": "abc123",
}
okf.write_concept_file(str(tmp_path), "promoted-verdict-abc123.md", fm, "body prose\n")
parsed = okf.parse_frontmatter(tmp_path / "promoted-verdict-abc123.md")
assert parsed["type"] == "verdict"
assert parsed["decision"] == "approved"
assert parsed["description"] == "LED-retrofit godkjent (realiseringsgrad=0.57)"
assert parsed["verdict_id"] == "abc123"
def test_render_frontmatter_single_lines_scalars(tmp_path) -> None:
"""A multi-line rationale must NOT corrupt the line-oriented frontmatter block (parse stops at
``---``). Newlines in a scalar value are flattened to spaces, so every following key survives."""
fm = {
"type": "verdict",
"description": "line one\nline two\n---\nnot a delimiter",
"decision": "approved",
}
okf.write_concept_file(str(tmp_path), "f.md", fm, "body\n")
parsed = okf.parse_frontmatter(tmp_path / "f.md")
assert "\n" not in parsed["description"]
assert parsed["decision"] == "approved" # the trailing key was NOT lost to a spurious ---
def _minimal_bundle(tmp_path) -> str:
(tmp_path / "index.md").write_text(
"---\ntype: index\n---\n\n# Bundle\n\n- [proj](bygg.md)\n", encoding="utf-8"
)
(tmp_path / "bygg.md").write_text("---\ntype: project\n---\n\nbody\n", encoding="utf-8")
return str(tmp_path)
def test_link_in_index_makes_concept_file_navigable(tmp_path) -> None:
"""Step-8 writer: a file written into a bundle is reachable by ``navigate_bundle`` only after
``link_in_index`` adds an intra-bundle cross-link the navigator follows (``_LINK_RE``)."""
bundle_dir = _minimal_bundle(tmp_path)
fm = {"type": "verdict", "decision": "approved", "description": "d"}
okf.write_concept_file(bundle_dir, "promoted-verdict-x.md", fm, "body\n")
assert "promoted-verdict-x.md" not in {f.name for f in okf.navigate_bundle(bundle_dir).files}
added = okf.link_in_index(bundle_dir, "promoted-verdict-x.md", "Promotert")
assert added is True
bundle = okf.navigate_bundle(bundle_dir)
assert "promoted-verdict-x.md" in {f.name for f in bundle.files}
assert [f.name for f in bundle.verdicts] == ["promoted-verdict-x.md"]
def test_link_in_index_is_idempotent(tmp_path) -> None:
"""Linking the same target twice adds exactly one bullet (returns ``False`` the second time) —
so re-promoting an existing verdict does not double-link the index."""
bundle_dir = _minimal_bundle(tmp_path)
assert okf.link_in_index(bundle_dir, "promoted-verdict-x.md", "A") is True
assert okf.link_in_index(bundle_dir, "promoted-verdict-x.md", "B") is False
body = (tmp_path / "index.md").read_text(encoding="utf-8")
assert body.count("(promoted-verdict-x.md)") == 1
def test_okf_is_maf_free() -> None:
"""D7 portability: ``okf.py`` IMPORTS no ``agent_framework`` / ``mcp`` (the docstring may name
them to document the constraint, exactly as ``retrieval.py`` does) checked via the AST, not a

View file

@ -0,0 +1,174 @@
"""Step-8 load-bearing seam (målbilde §3 / §6 / §7 / §11 step 6): GATED wiki-promotion — when an
expert/persona APPROVES an outcome, it is promoted from the raw output layer into the context layer
(the OKF bundle) as a ``type: verdict`` concept file; a NON-approved verdict MUST NOT reach the wiki.
The gap (fot-i-bakken): an approved verdict never re-entered the context layer, so the loop learned
only via injected/dropped seeds. Step 8 adds ``promote_verdict`` the public, opt-in (R4) promotion
primitive, deliberately NOT wired into ``run_project`` (the system reads context; the gate promotes).
Three load-bearing tests (per the Fase-2 green-but-dead trap):
- Test A (GATE, §7 mandatory): a rejected verdict otherwise fully promotable raises
``PromotionRefused`` and writes/links NOTHING. Goes RED the moment the gate (the ``raise``) is
detached: the rejected verdict's file + index link then appear (self-contamination, §6).
- Test B (NAVIGABLE): an approved verdict is written AND linked so ``navigate_bundle`` reaches it
proving promotion is non-decorative (a file the loop cannot navigate never reaches the next run).
Goes RED if ``link_in_index`` is detached (the promoted file ``bundle.verdicts``).
- Test C (NO-LEAK): the promoted verdict's realization marker (0.57 — absent from every bundle file)
stays OUT of ``bundle_context`` it reaches a prompt only via the gated ExpeL fold, never the
read-context. Goes RED if ``promote_verdict`` passes the rationale (not the neutral label) into
the index, leaking the signal into ``index_summary`` -> context (the BLOCKER the design closes).
Transitive coverage: ``test_step1_expel_loadbearing.py`` already proves
``seed_store_from_bundle`` -> ExpeL fold -> prompt; Test B proves the promoted verdict
``bundle.verdicts`` (what that seeder reads). Together: promoted approved knowledge reaches the next
run's hypothesis — proven without re-running ``run_project``.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from portfolio_optimiser import okf
from portfolio_optimiser.verdicts import (
ProposalFeatures,
PromotionRefused,
Verdict,
bundle_candidate_features,
promote_verdict,
)
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
# A realization marker absent from EVERY bundle file (the seed is 0.82). It can reach a prompt only
# via the ingest -> ExpeL fold path; in the read-context it must NEVER appear (Test C).
_MARKER = "realiseringsgrad=0.57"
_APPROVED_ID = "STEG8-APPROVED"
def _copy_bundle(tmp_path: Path) -> str:
"""Promotion WRITES into the bundle, so every test works on a throwaway copy — the shared
framework-neutral fixture is never mutated."""
dst = tmp_path / "bundle"
shutil.copytree(BUNDLE_DIR, dst)
return str(dst)
def _approved_verdict(bundle_dir: str, *, vid: str = _APPROVED_ID) -> Verdict:
"""An approved verdict keyed on the bundle candidate, carrying the realization marker in its
rationale the same key/payload shape the Step-1 fold consumes."""
return Verdict(
id=vid,
proposal_features=bundle_candidate_features(bundle_dir),
decision="approved",
rationale=f"LED-retrofit godkjent med realiseringskorreksjon ({_MARKER})",
)
def _promoted_files(bundle_dir: str) -> list[str]:
return [p.name for p in Path(bundle_dir).glob("promoted-verdict-*.md")]
# --- Test A: the gate ---------------------------------------------------------------------------
def test_rejected_verdict_is_not_promoted(tmp_path) -> None:
"""LOAD-BEARING GATE (§7): a NON-approved verdict — otherwise fully promotable (valid id,
writable bundle) so detaching the gate WOULD produce a file+link raises ``PromotionRefused``
and leaves the wiki untouched. RED if the gate is removed."""
bundle_dir = _copy_bundle(tmp_path)
index_before = (Path(bundle_dir) / "index.md").read_text(encoding="utf-8")
rejected = Verdict(
id="STEG8-REJECTED",
proposal_features=ProposalFeatures(
affected_codes=frozenset({"ENERGI-TOTAL-EL"}),
measure_type="LED-retrofit",
claimed_saving_nok=30000,
),
decision="rejected",
rationale="expert rejected: realiseringsgapet for stort i denne konteksten",
)
with pytest.raises(PromotionRefused):
promote_verdict(
bundle_dir, rejected, approver="persona", experiment="exp-A", timestamp="2026-06-30"
)
assert _promoted_files(bundle_dir) == [], "a rejected verdict must NOT be written to the wiki"
assert (Path(bundle_dir) / "index.md").read_text(encoding="utf-8") == index_before, (
"a rejected verdict must NOT be linked into the index"
)
# --- Test B: positive promotion is navigable ----------------------------------------------------
def test_approved_verdict_is_promoted_and_navigable(tmp_path) -> None:
"""LOAD-BEARING NAVIGABILITY: an approved verdict is written as a ``type: verdict`` file AND
linked so ``navigate_bundle`` reaches it (the seeder ``seed_store_from_bundle`` reads exactly
``bundle.verdicts``). RED if ``link_in_index`` is detached."""
bundle_dir = _copy_bundle(tmp_path)
path = promote_verdict(
bundle_dir,
_approved_verdict(bundle_dir),
approver="persona",
experiment="exp-B",
timestamp="2026-06-30",
)
assert path.name == "promoted-verdict-STEG8-APPROVED.md"
fm = okf.parse_frontmatter(path)
assert fm["type"] == "verdict"
assert fm["decision"] == "approved"
assert fm["verdict_id"] == _APPROVED_ID
assert "persona" in fm["provenance"] and "exp-B" in fm["provenance"]
verdict_names = {f.name for f in okf.navigate_bundle(bundle_dir).verdicts}
assert path.name in verdict_names, (
"the promoted verdict is not navigable — link_in_index did not make it reachable, so it "
"could never reach a later run (decorative promotion)"
)
def test_promote_sanitizes_unsafe_verdict_id(tmp_path) -> None:
"""A verdict id is read verbatim and can be an arbitrary author string; it must be sanitized
before it becomes a filename/link, or a ``/`` makes the link unnavigable and ``..`` escapes the
bundle. The original id is preserved in the ``verdict_id`` frontmatter."""
bundle_dir = _copy_bundle(tmp_path)
nasty = _approved_verdict(bundle_dir, vid="../../etc/evil id")
path = promote_verdict(bundle_dir, nasty, approver="p", experiment="e", timestamp="2026-06-30")
assert path.parent == Path(bundle_dir) # written INSIDE the bundle, not escaped
assert "/" not in path.name.replace("promoted-verdict-", "").replace(".md", "")
assert path.name in {f.name for f in okf.navigate_bundle(bundle_dir).verdicts}
assert okf.parse_frontmatter(path)["verdict_id"] == "../../etc/evil id" # original preserved
# --- Test C: the promoted signal does NOT leak into the read-context -----------------------------
def test_promoted_signal_stays_out_of_bundle_context(tmp_path) -> None:
"""LOAD-BEARING NO-LEAK (§3/§6): after promotion the realization marker is in the bundle (in the
promoted ``type: verdict`` file) but ABSENT from ``bundle_context`` it reaches a prompt only
via the gated ExpeL fold, never the read-context. RED if ``promote_verdict`` passes the rationale
(not the neutral label) into the index link, leaking the marker into ``index_summary``."""
bundle_dir = _copy_bundle(tmp_path)
path = promote_verdict(
bundle_dir,
_approved_verdict(bundle_dir),
approver="persona",
experiment="exp-C",
timestamp="2026-06-30",
)
bundle = okf.navigate_bundle(bundle_dir)
context = okf.bundle_context(bundle)
assert _MARKER not in context, (
"the promoted verdict's realization signal leaked into the read-context — promotion must "
"pass a NEUTRAL index label, so the signal reaches a prompt only via the gated ExpeL fold"
)
# though it IS present in the bundle (just excluded from context, like the seed verdict):
assert _MARKER in okf.parse_frontmatter(path)["description"]
assert path.name in {f.name for f in bundle.verdicts}