"""The promotion gate (method-spec §3 Step 8, §6) — an opt-in PUBLIC primitive. ``promote`` lifts one APPROVED verdict from the raw output layer into the OKF context layer as a navigable ``type: verdict`` concept file. It is NEVER wired into the run itself: the system reads context; the gate/persona promotes. Fail-closed — only human/persona-approved knowledge enters the wiki, never raw agent output (self-contamination). The index link label is FIXED and neutral: the index body flows verbatim into the rendered read-context, so a descriptive label would leak the learning signal around the gated fold. """ from __future__ import annotations import hashlib import re from pathlib import Path from portfolio_optimiser_claude.inbox import VerdictDocument # §6: the gate's accepted set — approved_with_adjustment exists ONLY here and # in bundle-seed frontmatter, never on the run path (§4). _ACCEPTED_DECISIONS = frozenset({"approved", "approved_with_adjustment"}) _PROMOTED_PREFIX = "promoted-verdict-" # §6: FIXED neutral label — carries NO verdict signal. _NEUTRAL_LABEL = "Promotert ekspert-dom" _TOKEN_UNSAFE = re.compile(r"[^A-Za-z0-9._-]") _INDEX_FILENAME = "index.md" class PromotionError(ValueError): """A verdict was refused at the gate (§6) — nothing written, nothing linked.""" def _filename_token(verdict_id: str) -> str: # Path-safe, fail-closed against escaping names: sanitise to the safe # alphabet; a degenerate token falls back to a content hash. token = _TOKEN_UNSAFE.sub("", verdict_id) if not token.strip("."): token = hashlib.sha256(verdict_id.encode("utf-8")).hexdigest()[:16] return token def _frontmatter_text(value: str) -> str: # The frontmatter parser is line-oriented — prose must stay on one line. return " ".join(value.split()) def promote( verdict: VerdictDocument, bundle_dir: Path, *, approved_by: str, experiment: str, timestamp: str, ) -> Path: """Promote one approved verdict into the bundle — fail-closed, idempotent link. ``timestamp`` is an explicit REQUIRED argument (no wall-clock default), so promotion is deterministic and reproducible. The promoted file is minimal: the rationale becomes ``description`` (the learning signal as prose) — the structured learning fields of hand-authored seeds are never reproduced. Ids key on candidate features (§4.2), so re-approving the same candidate overwrites the same file (last-write-wins): one curated verdict file per distinct candidate, not one per verdict event. """ if verdict.decision not in _ACCEPTED_DECISIONS: raise PromotionError( f"promotion refused: decision {verdict.decision!r} is not in the " f"gate's accepted set {sorted(_ACCEPTED_DECISIONS)} (§6 fail-closed)" ) filename = f"{_PROMOTED_PREFIX}{_filename_token(verdict.id)}.md" path = (bundle_dir / filename).resolve() if path.parent != bundle_dir.resolve(): raise PromotionError(f"promotion refused: {filename!r} escapes the bundle") path.write_text( "---\n" "type: verdict\n" f"title: {_NEUTRAL_LABEL}\n" f"decision: {verdict.decision}\n" f"description: {_frontmatter_text(verdict.rationale)}\n" f"verdict_id: {verdict.id}\n" f"provenance: approved_by={approved_by}; experiment={experiment}; " f"timestamp={timestamp}\n" "tags: [verdict, promoted]\n" "---\n", encoding="utf-8", ) _link_from_index(bundle_dir, filename) return path def _link_from_index(bundle_dir: Path, filename: str) -> None: # §6: navigation follows only index cross-links — an unlinked file is # unreachable. Idempotent: re-promotion never double-links. (reference # limitation: the read-modify-write is not atomic — single-process MVP.) index_path = bundle_dir / _INDEX_FILENAME index_text = index_path.read_text(encoding="utf-8") if f"({filename})" in index_text: return link_line = f"- [{_NEUTRAL_LABEL}]({filename})\n" if not index_text.endswith("\n"): index_text += "\n" index_path.write_text(index_text + link_line, encoding="utf-8")