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 `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 (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). 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. - **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. - **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. - 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. 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. > **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). - **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 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 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 ## 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()) 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]: 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 """Load the bundle's IR projection (``validator-input.json`` by default): the candidate
measure's cost-IR (``measure``, ``affected_items``, ``claimed_saving_nok``) — the measure's cost-IR (``measure``, ``affected_items``, ``claimed_saving_nok``) — the

View file

@ -20,6 +20,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -354,3 +355,90 @@ def seed_store_from_bundle(bundle_dir: str) -> VerdictStore:
for vf in bundle.verdicts for vf in bundle.verdicts
] ]
return VerdictStore(verdicts=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" 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: def test_okf_is_maf_free() -> None:
"""D7 portability: ``okf.py`` IMPORTS no ``agent_framework`` / ``mcp`` (the docstring may name """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 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}