portfolio-optimiser/docs/plan/2026-07-16-llm-ingestion-guard-inclusion.md
Kjell Tore Guttormsen c255662802 feat(ingest): P2/S1.b — innholdsgaten står, rundt materialize og ikke i den
Planens §3 sa at `ingest.materialize` er repoets ENE skrivepunkt på Door A, og
det premisset ble felt av måling FØR bygging: `materialize` er en ren delegasjon
til pinnet llm_ingestion_okf v0.3.2s `materialize_bundle`, som stager i minnet og
utfører sin egen disk-fase. Det finnes ingen callback mellom de to, så en gate
plassert der kunne bare kjørt ETTER at bytene landet — en opprydding, ikke en gate.

Sømmen ble i stedet kopier bundelen → materialiser inn i kopien → skann det som
ble generert → publiser eller forkast. Kopien er bærende, ikke bekvemmelighet:
bibliotekets §3 eierskaps-skann, kollisjonsgaten mot kuratert innhold og §6
index-merge leser alle den EKSISTERENDE bundelen. Staging i tom katalog mister
alle tre og publiserer en bundle uten kuraterte naboer — datatap forkledd som
sikkerhetsfiks.

De fire §4-beslutningene, tatt og målt: (1) ingen av guardens to preset —
Origin.EXTERNAL/AUTOMATIC, fordi trust_for utleder policy fra origin alene og
PRESET_USER_UPLOAD bærer en quarantine-semantikk Door A ikke har; (2) utfall per
BUNDLE, diagnostikk per DOKUMENT — delvis publisering ville etterlatt bundle +
index som svarer til intet manifest, men import_bundle itererer forbi første
avvisning; (3) Report til log.md, aldri konsept-frontmatter, der fire golden-suiter
pinner bytene; (4) mypy-override OG adapter, siden override alene gjør sømmen
type-blind i stedet for type-sikker.

`materialize` forblir ugatet med vilje — goldenene pinner den, og en kaller som
vil ha gaten ber om den ved navn.

Fem mutasjoner alle røde + grønn kontroll (hele suiten, ~120 s hver): detach
gaten · la den fyre ETTER publisering · Origin.INTERNAL · tom staging-katalog ·
rapporter kun første avvisning.

Målingen felte en VAKUØS test først: en hard injeksjon scorer fail_secure under
BEGGE trust-tierene, så Origin.INTERNAL-mutasjonen lot alle tre avvisningstestene
stå grønne — beslutning 1 så dekket ut uten å være testet. Båndet der tieren
faktisk avgjør er høy-entropi-innhold (quarantine_review vs warn), og testen ble
skrevet mot nøyaktig det før mutasjonen ble re-målt. Mutasjon 4 ble på sin side
felt av KUN én test; 809 andre merket ikke at bundle-kopien forsvant.

Laveste disposition er `warn`, ikke `allow` — `allow` finnes ikke i guarden. En
gate skrevet mot == allow ville avvist hvert dokument som noensinne ingestes.

Kriterium 5 står: demo-stdout er byte-identisk med tests/golden/demo-transcript.stdout,
målt både i suiten og ved eksplisitt kjøring. shared/ er urørt.

801 -> 810 passed / 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDu94KoyxAmhJsG2n63X8Q
2026-08-09 22:58:00 +02:00

172 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Inkluderingsplan — `llm-ingestion-guard` som write-time persist-gate
> **⚠️ UTDATERT PÅ TO PUNKTER — REALISERT 2026-08-09 av
> [innholdsgate-planen](2026-08-09-innholdsgate-og-aerlighet.md) (P2/S1.b).** Dokumentet beholdes
> som kartleggingen den var; disse to påstandene skal IKKE leses som gjeldende:
>
> 1. **«Guard-modenhet: `v0.2` (alpha)» er FEIL** (målt 2026-08-09: syv publiserte tagger, siste
> `v0.3.4`, `dependencies = []`). Det premisset gjorde adopsjonen urimelig risikabel og er
> grunnen til at innholdsgate-planen ble skrevet. Adoptert pin: `v0.3.4`.
> 2. **Gate-plasseringen i denne planen er FEIL.** Den plasserer gaten i `ingest.materialize`,
> «repoets ene skrivepunkt». Målt er `materialize` en ren delegasjon til det pinnede
> `llm_ingestion_okf`-bibliotekets `materialize_bundle`, som stager i minnet og skriver selv —
> ingen callback mellom staging og disk. Gaten bor derfor i `materialize_gated`
> (kopi → materialiser → skann → publiser eller avvis), ikke der denne planen sier.
>
> **Status:** PLAN (ikke implementert). Denne økta KARTLA repoets untrusted-ingest og la denne
> planen; guarden er IKKE wiret inn ennå (per adopsjons-briefens §7: «kun planlegg og oppdater
> planene»). Utløst av konsument-inkluderings-prompten fra `llm-ingestion-pipeline-security`.
> **Guard-modenhet:** `v0.2` (alpha) — stdlib-only kjerne, framework-agnostisk, offentlig API kan
> fortsatt endres. Pin ved adopsjon.
>
> **Kildepekere:** guardens adopsjons-brief (open-remote:
> `open/llm-ingestion-pipeline-security/docs/ADOPTION-BRIEF.md`). Guarden er allerede anerkjent i
> repoets veikart: [sesjonsplan Fase 26](2026-07-10-sesjonsplan-fase2-6.md) D-G («Guard forblir
> eget sikkerhets-repo (avhengighet)», linje 120) og [toolkit-repo-brief](2026-07-14-toolkit-repo-brief.md)
> §5/§8 («migreres IKKE; forblir eget repo … fabrikkens sikkerhetsvask krever `llm-ingestion-guard`
> som publisert»).
---
## 1. Hva guarden er, og hvorfor den er relevant HER
`llm-ingestion-guard` er **write-time**-søsknet til query-time chatbot-guardrails. Den sitter ikke
mellom bruker og modell ved spørring, men herder den andre formen: **untrusted innhold som flyter
gjennom et LLM-berikelses-/ekstraksjons-steg inn i et persistert, downstream-lest artefakt** — en
RAG-base, kunnskapsbase, wiki eller **OKF-bundle**.
Dette treffer arkitekturen vår direkte: `ingest.py` materialiserer eksterne kilder inn i OKF-bundles,
og optimiser-loopen konsumerer bundelen **uendret** som betrodd agent-kontekst (`bundle_context`).
En forgiftet konseptfil skrevet ved *write*-tid leses senere av forslagsstiller-agenten som **betrodd
kontekst** — agentens (framtidige) query-time-guardrail ser aldri hvor konseptet kom fra. Write-gaten
er det ENESTE stedet provenansen fortsatt er kjent. OKF har ingen skjema-registry, ingen sentral
autoritet og ingen signering — en mottatt bundles påståtte opphav er ikke verifiserbart på format-nivå.
Guarden gjør aldri modellkallet selv (relevant for kostdisiplinen: null modellkall, stdlib-only, ingen
nett). Den gir de to bibliotek-halvdelene rundt vår egen tool-less transform (`prepare_input` /
`screen_output`), pluss en OKF-adapter for hele bundles (`okf.import_bundle`).
## 2. §7-checklist scoret for dette repoet
| §7-kriterium | Repoet | Kilde |
|---|---|---|
| Persisterer LLM-beriket/eksternt-mottatt innhold i en store en *downstream* agent leser som betrodd | **JA** | `ingest.materialize` → OKF-bundle → `bundle_context` (run.py:302); verdict-inbox → `store.add``ExpeLContextProvider` (run.py:287-290) |
| **Minst én ingest-sti tar UNTRUSTED innhold** (ekstern URL / opplastet fil / mottatt bundle / auto-hentet web) — *den avgjørende* | **JA** | `http`-kilde (I6) auto-henter web/vendor-innhold (`read_http`/`_urllib_get`, ingest.py); verdict-inbox er «inbox som tar imot eksterne drops» (S2.5/F7/F11) |
| Et LLM-steg (summarize/extract/classify/rewrite) mellom untrusted kilde og store | Delvis — LLM-steget leser bundelen *downstream* (ingest er deterministisk, pre-loop); verdict-inbox injiseres direkte i agent-instruksjoner | ingest.py §1-docstring; verdicts.py:299-311 |
| Ønsker **fail-secure** (stopp før persist) framfor best-effort med stille commit | **JA** — hele repoets kontrakt er fail-fast/fail-closed (validator obligatorisk-blokkerende; `allow_network`; verdict-lag reservert) | CLAUDE.md-invarianter |
**Konklusjon: den avgjørende betingelsen holder → guarden er `applicable` (marker = `planned`).**
Ærlig motargument (vurdert, forkastet): man kunne hevde `not-applicable` fordi de eneste kildene som
kjøres i praksis i dag er operatør-forfattet lokal data (`reference_projects.json`, lokale CSV-er).
Men (a) `http`-konnektoren (I6) FINNES i koden og ER en untrusted auto-fetch-sti, og (b) verdict-inboxen
er eksplisitt anerkjent som en injeksjonsflate i veikartet (S2.5: «verdict-inboxen slutter å være en
uvalidert injeksjonsflate»). Derfor er `planned` den ærlige klassifiseringen — ikke `not-applicable`,
og ikke `integrated` (ingenting er wiret ennå).
## 3. Untrusted-grenser vs first-party
**Untrusted-grenser (guarden hører hjemme her) — persist-gater rangert etter eksponering:**
| # | Persist-gate | Fil | Hva krysser inn | Nåværende delforsvar | Gap guarden lukker |
|---|---|---|---|---|---|
| 1 | `ingest.materialize` / `read_http` | ingest.py:472 (http-gate 506-511) | Ekstern kilde (http/sql/csv) → `ingest-{id}.md` i bundelen → betrodd kontekst | `allow_network`-egress-gate, `safe_resolve`-baneinnesperring, kodefence-marker-avvisning, tabellcelle-escaping, verdict-lag-reservasjon | Ingen **carrier-strip** (zero-width/BIDI/Unicode-tag/HTML-comment/`data:`), ingen **lexicon/entropy/active-content-scan** av rendret kropp, ingen EchoLeak markdown-bilde/lenke-deteksjon. En zero-width-kodet instruksjon i en CSV-celle overlever i dag verbatim. |
| 2 | `load_verdicts_from_dir``store.add` | verdicts.py:181, kalt run.py:287-290 | Eksternt-droppet JSON (`id`+`rationale` verbatim) → store → injisert i agent-instruksjoner via `ExpeLContextProvider` | Tolerant skip, `decision`-vokabular (`{approved,rejected}`), `max_rationale_len`, `max_files` fail-fast (S2.5) | Ingen innholds-scan av `rationale`/`description` før den blir few-shot. Høyest frekvens i normal-loopen. |
| 3 | `promote_verdict``okf.write_concept_file` | verdicts.py:513 (skriv 563-564) | Godkjent dom → `type: verdict`-konseptfil i wikien → betrodd av ALLE framtidige kjøringer | Fail-closed HITL-gate (`PromotionRefused`, kun `approved`/`approved_with_adjustment`), nøytral index-label, `_safe_filename_token` | Innholdet stammer fra (untrusted) inboxen; guarden = defense-in-depth (scan `rationale`/`description` FØR permanent betrodd kontekst). Størst blast-radius. |
| 4 | Mottatt OKF-bundle (`okf.import_bundle`-mode) | *finnes ikke i dag* | En hel tredjeparts-bundle merges | — | Roadmapen ÅPNER den: D-H demo-sti («unzip energi-eksempel i bundle-innboks»), D7-portabilitet, toolkit-fabrikk. «Når, ikke hvis»: bruk `okf.import_bundle(origin=EXTERNAL)` per-konsept-gate når en received-bundle-sti legges til. |
**First-party (guarden gjelder IKKE):**
- **`datasource.py` / `reference_domain.py`** — operatør/syntetisk data, JSON-Schema-validert fail-fast. Deployerens egen kontekst.
- **`file`/`sql`-kilder mot operatørens egen lokale katalog** — first-party *etter opphav*. Tillit følger dataens opphav, ikke innsettings-kanalen: en CSV lastet ned fra en vendor er fortsatt ekstern og hører under gate 1. Deployeren eier klassifiseringen (jf. «rent teknisk rammeverk»-invarianten).
- **`write_verdict`** (verdicts.py:161) — forfatter-primitiv; tillitsgrensen er *lastingen* (gate 2), ikke skrivingen.
- **`outbox.write_outbox`** — systemets EGET output-lag (må aldri re-ingesteres som inbox, run.py:261-263).
- **`persona.load_persona_example`** (persona.py:47) — first-party fil-les; men NB: i simuleringen forplanter den seg gjennom `promote_verdict` inn i wiki/ExpeL-kjeden — dekkes indirekte av gate 3.
- **Optimiser-loopens interne resonnement** (proposer/debatt/validator) — intet eksternt innhold hentes; resonnement over allerede-materialisert bundle-kontekst.
## 4. NÅR — innplassering i veikartet
Guarden er en **persist-gate**, så den skal wires **før den første untrusted-ingest-stien går live**,
festet der beriket/mottatt innhold committes. Veikartet gir tidspunktet eksplisitt:
- **Hard grense: FØR M3** (Fase 6-pilot). M3 er der «én ekte kilde … herdet og materialisert med ekte
provenance; én ekte fagekspert onboardet på inbox/outbox-kontrakten» — dvs. første ekte untrusted-ingest
(gate 1) OG første ekte inbox-drop (gate 2) samtidig. Guarden MÅ stå før M3.
- **Naturlige wiring-økter (utvider eksisterende steg, ingen ny fase):**
- **Gate 1 → utvid S2.4** («Konnektor-herding mot mock»): S2.4 herder allerede http-konnektoren mot
injisert transport. Legg guardens `sanitize` + `scan_output` på rendret ekstraksjons-kropp der.
- **Gate 2 → utvid S2.5** («Inbox-herding»): S2.5 gjør inboxen til «ikke lenger uvalidert injeksjonsflate»
med vokabular + lengde-tak. Guardens innholds-scan av `rationale`/`description` er den manglende brikka.
- **Gate 3 → ny opt-in ved `promote_verdict`**: defense-in-depth-scan, opt-in (speiler at `promote_verdict`
selv er opt-in, ikke wiret i `run_project`).
- **Gate 4 → når en received-bundle-sti faktisk legges til** (D-H demo-sti / toolkit-fabrikk): `okf.import_bundle`.
- **Avhengighet allerede erklært:** guarden er «eget sikkerhets-repo (avhengighet)» (D-G) og toolkit-fabrikkens
«sikkerhetsvask krever `llm-ingestion-guard` som publisert» (toolkit-brief §8). Denne planen KONKRETISERER
hvor i DETTE repoet den avhengigheten lander.
Rekkefølge-anbefaling: gate 2 (S2.5) og gate 1 (S2.4) først (de er allerede planlagte herdings-økter i
Fase 2-bolk B, gated på D-B), gate 3 som liten opt-in når som helst, gate 4 kun når received-bundle åpnes.
Ingen av dem er på kritisk sti før M3-forberedelsen begynner.
## 5. HVOR + minimal wiring (per gate)
Skjematisk (adopsjons-briefens §2/§4 API; IKKE implementer nå):
**Gate 1 — `ingest.materialize` (staging-loopen, før `_write_bytes`):**
```
prepared = prepare_input(raw_source_text) # carrier-strip + fence FØR render
# ... eksisterende render_table / _render_fenced_block på prepared ...
decision = screen_output(rendered_body, PRESET_*) # scan rendret konseptkropp
if decision.disposition is Disposition.FAIL_SECURE:
raise IngestError(gate_code=decision.reasons) # fail-fast, matcher modulens kontrakt
```
Fail-secure = `IngestError` (byttet inn i modulens eksisterende fail-fast-mønster). Alert-payload = gate-kode
+ run-id, ALDRI innhold (§8).
**Gate 2 — `load_verdicts_from_dir` (i skip-loopen):** kjør `rationale`/`description` gjennom `screen_output`
med EXTERNAL/MANUAL-provenance; fail-secure = **skip fila** (konsistent med den eksisterende tolerante
skip-kontrakten Steg-7-loopen hviler på) + logg gate-kode. Aldri raise (bevarer per-fil-toleransen).
**Gate 3 — `promote_verdict` (opt-in, før `write_concept_file`):** `screen_output``rationale` → fail-secure
hever `PromotionRefused` (gjenbruk eksisterende fail-closed-sti).
**Gate 4 — received-bundle:** `okf.import_bundle(bundle, origin=EXTERNAL, channel=…)` per-konsept-gate FØR
merge; `c.error`-konsepter skippes (FAIL_SECURE), aldri merget.
## 6. Invariant-forenlighet (må holde ved wiring)
- **MAF-fri / D7-portabel:** guarden er stdlib-only, null deps → forenlig med `ingest.py`/`okf.py`'s
«pure stdlib + pydantic, MAF-fri»-krav (håndhevet av `test_okf_is_maf_free` + `test_ingest_loadbearing`).
Legg guarden til i `_MAF_FREE_MODULES`-probens tillatte tredjeparts-deps eller verifiser at den ikke
drar inn `agent_framework`/`mcp` (den gjør ikke). **D7-speiling påkrevd:** SDK-søsknet må wire samme guard
(framework-agnostisk → samme pakke) i egen økt.
- **`shared/` er PULL-ONLY + commons-koordinering:** ingest-SPEC-en bor i `shared/ingest-spec.md` (frossen,
commons). Hvis wiring krever et spec-felt (f.eks. `screened_at`/trust-tier i provenance-frontmatteren),
MÅ amendmenten skrives i `portfolio-optimiser-commons` og pulles hit — aldri lokalt. **NB:** samme
inkluderings-prompt kjører i commons-repoet; koordiner så guard-vask i den delte `shared/`-halvdelen
(persona/skills, ekspert-artefakt) eies der, og denne planen kun dekker repo-lokale moduler.
- **Alpha-pin:** `v0.2` — pin eksakt versjon i `pyproject.toml`; behandle API som ustabilt til v1.0.
- **Kostdisiplin:** guarden gjør null modellkall og null nett → forenlig med offline-først. Semantisk/faktisk
forgiftning er USYNLIG for lexicon+entropy (guardens ærlige grense §8) — den fanges av HITL-dommen +
validatoren, ikke guarden. Ikke overselg: guarden er defense-in-depth på tekst-laget, ikke en vegg.
## 7. IKKE i scope nå (scope-grense)
- Ingen kildekode-endring i denne økta. Ingen ny dependency lagt til `pyproject.toml`.
- Ingen endring i `shared/` eller ingest-spec (commons, PULL-ONLY).
- Ingen wiring i `run_project`.
- **Åpne operatør-beslutninger (ikke-blokkerende):** (a) hvilke gater som faktisk skal wires ved M3-forberedelse
(anbefaling: gate 1+2 obligatorisk, gate 3 opt-in, gate 4 kun ved received-bundle); (b) om et
`screened_at`-provenance-felt skal inn i ingest-spec-en (krever commons-amendment — koordiner med D-B/S2.4).
## 8. Verifisering (testbare kriterier — for den framtidige wiring-økta, ikke nå)
Når guarden faktisk wires (S2.4/S2.5-utvidelse), skal disse holde:
- **Load-bearing detach:** en test der en kjent injeksjons-payload (zero-width-kodet instruksjon i en
CSV-celle / `rationale`) MÅ gi FAIL_SECURE (gate 1: `IngestError`; gate 2: skip); detach guard-kallet →
payloaden når bundelen/storen → RØD.
- **Fail-secure retning:** gate 1 raiser (fail-fast), gate 2 skipper (tolerant) — bevist per gate.
- **Ingen regresjon:** `test_okf_is_maf_free` + `test_ingest_loadbearing` grønne (guarden drar ikke inn MAF).
- **Determinisme/offline:** `grep`-guard bekrefter ingen ny socket-sti; guard-kallet gjør null nett/modell.
- **Denne planen (nå):** `docs/plan/2026-07-16-llm-ingestion-guard-inclusion.md` finnes; STATE.md bærer
markørlinja `llm-ingestion-guard: planned — …` (grep-bar av guard-repoets rollup).