# I2 — MAF reference implementation: ingest file catalogue/CSV (offline) > **Plan quality: B+** (88/100) — APPROVE_WITH_NOTES > > Generated by trekplan v5.9 on 2026-07-03 — `plan_version: 1.7` ## Context The 8-step optimiser loop consumes hand-curated OKF bundles today; the frozen ingest målbilde closes the gap to real data sources while preserving the architecture-defining rule that data reaches the model ONLY via OKF bundles (no RAG, no query-time retrieval — method spec §3). I2 is the first implementation session of that program: it proves the frozen contract (`shared/ingest-spec.md`, authored and guarded in I1) is implementable from the spec alone, on the reference (MAF) side, using the offline `file` source type so the session stays at zero model calls and zero network (cost discipline D6). It closes the session plan's key assumption 2 (fail-fast manifest validation without network, currently RISK-marked) and turns the planning session's ephemeral ad-hoc navigability proof into a durable load-bearing test. Source: brief Intent, verbatim intent trace. Scope note (brief, Non-Goals): I2 is deliberately a **partial-conformance milestone** — spec §1 full conformance (`file` + `sql`) completes at I4. The manifest contract validates all three source variants; only the `file` connector executes in I2. ## Architecture Diagram ```mermaid graph TD subgraph "New in this plan" M[manifest.json] -->|"load_manifest() — pydantic fail-fast (§4), verdict reservation (§3)"| C[ingest.py contract models] C -->|"file connector: safe_resolve boundary + csv.reader + max_rows cap"| X[extraction rows in memory] X -->|"render: escaped markdown table + §5/§7 provenance frontmatter"| S[staged files in memory] S -->|"replacement semantics: delete stamped only, collision fail (§3)"| B[OKF bundle dir] S -->|"index create/update: bundle_summary body, managed-line removal, link_in_index append (§6)"| I[index.md] end subgraph "Existing, UNCHANGED" B --> N["okf.navigate_bundle / bundle_context"] I --> N V["verdicts.promote_verdict"] -->|"promoted-verdict-*.md + index link — must survive re-ingest"| B R["retrieval.safe_resolve / PathSecurityError"] -.reused by.-> C L["okf.link_in_index / render_frontmatter / parse_frontmatter"] -.reused by.-> S end ``` ## Codebase Analysis - **Tech stack:** Python ≥3.10, pydantic ≥2.11, `uv`, pytest ≥8 (`asyncio_mode=auto`; ingest tests are plain sync), ruff (line-length 100), mypy (py3.10 target). No new runtime dependencies needed: stdlib `csv`, `hashlib`, `logging`, `pathlib` + pydantic suffice. - **Key patterns:** fail-fast pydantic startup contracts (`contracts.py:30-96`); frozen dataclasses for value objects, `BaseModel` for validated external input; PEP 604 unions, keyword-only options via bare `*`; condition-noun exceptions (`PromotionRefused` `verdicts.py:447`, `PathSecurityError` `retrieval.py:24`, dominant base `RuntimeError`); narrative module docstrings citing målbilde/spec sections; load-bearing tests with detach-RED docstrings (`tests/test_step8_promotion_loadbearing.py`). - **Relevant files:** `src/portfolio_optimiser/okf.py` (UNCHANGED — reuse `parse_frontmatter:31`, `render_frontmatter:146`, `link_in_index:168`, `navigate_bundle:113`, `bundle_context:133`), `src/portfolio_optimiser/retrieval.py` (`safe_resolve:63`, `is_within_dir:48`, `PathSecurityError:24`), `src/portfolio_optimiser/verdicts.py` (`promote_verdict:465` — structural analogue and the SC5 test's real promotion; SHA-256 `[:16]` idiom at `verdicts.py:95,461`), `tests/test_method_spec_loadbearing.py:156-174` (recorded I2 obligation: field-level cross-check), `tests/conftest.py` (no ingest-relevant fixtures — none needed), `shared/examples/bygg-energi-mikro/` (copytree fixture for the re-ingest safety test). - **Reusable code:** `safe_resolve` for the §4 root boundary (fail-closed); `render_frontmatter` for §5 key order + single-lining (collapses whitespace in ALL values — acceptable: §5 requires single-line values everywhere; golden pins it); `link_in_index` for §6 appends (idempotent by `](target)` — exactly the primitive §6 references); `parse_frontmatter` for stamp detection (`generated == "true"` string compare — it returns strings, never booleans); `promote_verdict` as the shape template (explicit required `timestamp`, no wall-clock default). - **Deliberately NOT reused:** `okf.write_concept_file` for the final write — it delegates to `Path.write_text` (platform newline translation risk) and does not guarantee exactly one trailing newline (`okf.py:164`). The ingest module builds each file's full content as a string and writes bytes (`encode("utf-8")`) for the §5 LF-only guarantee on CONCEPT files. Honest limit: index APPENDS reuse the frozen `link_in_index` (`Path.write_text`, `okf.py:180`), so `index.md` LF-ness is platform-scoped — nil on the LF-only development/target platform; recorded as Assumption 6. `contracts.py` is NOT imported: it imports `backends` → `agent_framework` (`contracts.py:25`), which would break the ingest module's MAF-free guard. Manifest models live in `ingest.py` itself. - **External tech (researched):** none — offline task, frozen spec, established repo patterns (brief Research Plan: 0 topics). - **Recent git activity:** I1 landed the spec + guards 1h before planning (`4df2140`, `7ba0fae`); `tests/test_method_spec_loadbearing.py` is freshly touched — extend, don't conflict. All work is sequential on `main`, clean tree. Commons subtree is PULL-ONLY (violation observed + cleaned 2026-07-03); **nothing in this plan writes under `shared/`**. - **Codebase-analysis correction (honesty):** the brief's Research Plan rationale mentions JSON-Schema validation; exploration verified `datasource.py` contains no schema validation — the fail-fast validation pattern lives in `contracts.py` as pydantic. The plan follows the pydantic pattern (which the brief's Preferences already mandate). ## Implementation Plan Every step is TDD (Iron Law): the step's tests are written and observed RED before the production code that turns them green. `okf.py` and everything under `shared/` are untouchable in every step (see per-step `forbidden_paths`). All commands run offline. ### Step 1: Fail-fast manifest contract (pydantic, polymorphic, verdict reservation) - **Files:** `src/portfolio_optimiser/ingest.py` (new), `tests/test_ingest_manifest.py` (new) - **Changes:** Create the ingest module with its narrative docstring (purpose, spec § references, MAF-free invariant, D7-portability) and the manifest contract per spec §4: `FileSource` (`type: Literal["file"]`, `root: str`), `SqlSource` (`type: Literal["sql"]`, `connection_ref: str`), `HttpSource` (`type: Literal["http"]`, `base_url: str` — reject embedded credentials (userinfo `@`) via validator, optional `credential_ref: str`), all with common `id: str` constrained to `^[a-z0-9][a-z0-9-]*$`; `source` as a discriminated union on `type` (`Field(discriminator="type")`). `Extraction`: `id` (same grammar), `title` (non-empty, single-line — reject `\n`/`\r`), `query: str`, `okf_type` (non-empty, single-line, `@field_validator` rejecting `verdict` case-insensitively — spec §3, before any source call), `max_rows: int = Field(gt=0)`. `ManifestV1`: `manifest_version: Literal[1]`, `source`, `bundle_summary: str` (non-empty), `extractions` (`min_length=1`, `@model_validator` rejecting duplicate extraction ids). Public loader `load_manifest(path: str | Path) -> tuple[ManifestV1, str]` that reads raw bytes ONCE, computes the `{stem}@{sha256(raw)[:16]}` stamp (spec §5), `json.loads`, validates — raising `pydantic.ValidationError` / `json.JSONDecodeError` before ANY source access. Define `IngestError(RuntimeError)` (condition-noun family; used by later steps for materialization-time refusals). `title` is whitespace-normalized at validation (`" ".join(title.split())` after the single-line check) so the frontmatter rendering (which collapses runs, `okf.py:154`) and the index label are guaranteed identical. The `HttpSource` credential validator is defined precisely: reject when `urllib.parse.urlsplit(base_url)` yields a non-`None` `username` or `password` (userinfo is THE credential-embedding URL mechanism §4 targets; query-param token heuristics are out of scope — documented in the validator docstring). **Baseline capture (SC7):** BEFORE writing anything in this step, run `uv run pytest -q` on the clean tree and record the summary line in the session log — this is the pre-existing-suite baseline SC7's "unchanged-green" is checked against. (new file) - **Reuses:** pydantic contract idiom from `contracts.py:30-96` (`Field` constraints, `Literal` enums, `@model_validator(mode="after")` returning `self`); SHA-256 `[:16]` idiom from `verdicts.py:95`; docstring style from `okf.py:1-12`. - **Test first:** - File: `tests/test_ingest_manifest.py` (new) - Verifies: valid `file` manifest loads with correct stamp (stem + 16-hex of raw bytes); each missing top-level field raises `ValidationError`; `manifest_version != 1` rejected; empty `extractions` rejected; bad `id` grammar (uppercase, leading `-`, empty) rejected for both source and extraction ids; duplicate extraction ids rejected; `max_rows <= 0` rejected; unknown `source.type` rejected; `file` source missing `root` rejected; `okf_type` of `verdict`/`Verdict`/`VERDICT` rejected; multi-line `title` rejected; `base_url` with embedded credentials rejected; **fail-fast ordering proof**: a manifest that is malformed AND whose `source.root` does not exist raises `ValidationError` without touching the missing root (the ingest analogue of `test_no_chat_client_call_on_malformed_contract` in `tests/test_contracts.py`); `sql`/`http` variants VALIDATE (schema breadth per brief assumption) though no connector executes them in I2. - Pattern: `tests/test_contracts.py` (inline dict constants, one `pytest.raises` per malformation) - **Verify:** `uv run pytest tests/test_ingest_manifest.py -q` → expected: all pass (after observed RED on first run without implementation) - **On failure:** revert — `rm -f src/portfolio_optimiser/ingest.py tests/test_ingest_manifest.py` (both files are NEW and untracked until this step's checkpoint — `git checkout --` cannot remove untracked files) - **Checkpoint:** `git commit -m "feat(ingest): fail-fast manifest contract with verdict reservation (I2)"` - **Manifest:** ```yaml manifest: expected_paths: - src/portfolio_optimiser/ingest.py - tests/test_ingest_manifest.py min_file_count: 2 commit_message_pattern: "^feat\\(ingest\\): fail-fast manifest contract with verdict reservation \\(I2\\)$" bash_syntax_check: [] forbidden_paths: - src/portfolio_optimiser/okf.py - shared/ingest-spec.md must_contain: - path: src/portfolio_optimiser/ingest.py pattern: "load_manifest" - path: src/portfolio_optimiser/ingest.py pattern: "discriminator" ``` ### Step 2: CSV connector — boundary check, streaming row cap, cell escaping, table body - **Files:** `src/portfolio_optimiser/ingest.py`, `tests/test_ingest_materialize.py` (new) - **Changes:** Add the `file` connector + renderer. Connector: resolve the manifest's `root` against the **manifest file's parent directory** when relative (pinned decision — extraction must not depend on cwd), then `safe_resolve(root, extraction.query)` for the fail-closed §4 boundary check; open with `encoding="utf-8-sig"` (BOM never leaks into the first header cell) and `newline=""`; parse with `csv.reader`. First row = header; fail (`IngestError`) on empty file (no header). Stream data rows counting against `max_rows` — raise `IngestError` the moment the cap is exceeded (§8: error, never silent truncation; no fetch-all-then-count). Fail on ragged rows (data row width ≠ header width) — silent padding/truncation is coercion. Renderer: cell text **verbatim** with escaping in this exact order: `\` → `\\` FIRST, then `|` → `\|`, then any newline (CR, LF, CRLF) → single space (pinned decision: on the `file`/CSV path every cell is a string, so §5's integer/float/NULL clauses bite typed `sql` values in I4 — a spec-text reading recorded as Assumption 1). Newline replacement treats CRLF as ONE unit: replace `\r\n` first, then lone `\r`, then lone `\n` — each with a single space (a per-character replace would turn CRLF into two spaces). A well-formed manifest whose `root` directory or resolved `query` CSV does not exist raises `IngestError` with a clear message (never a bare `FileNotFoundError` leaking from `open()`). Body = markdown table: `| h1 | h2 |` header row, `| --- | --- |` separator, data rows in source order, LF line endings, ending in exactly one `\n`. **Header cells are escaped identically to data cells** (spec §5 separates "column names" from "cell values" without giving headers their own rule — pinned decision: same escaping/newline-collapse, or a header containing `|` or an embedded newline breaks the table). The connector itself is PURE (returns header + rows); the §8 source-call logging lives in `materialize` (Step 3), which owns `ingested_at`. - **Reuses:** `retrieval.safe_resolve`/`PathSecurityError` (`retrieval.py:63,24`) — identical fail-closed semantics `okf.py` itself relies on. - **Test first:** - File: `tests/test_ingest_materialize.py` (new) - Verifies: table rendering (header/separator/rows, source order); escaping of `\`, `|`, embedded quoted newline (one assertion each, plus one cell containing BOTH `\` and `|` proving escape order, plus an explicit embedded-CRLF cell → exactly ONE space); a header cell containing `|` and one containing an embedded newline → escaped/collapsed identically to data cells; header-only CSV → header + separator, zero data rows; empty CSV file → `IngestError`; ragged row → `IngestError`; `max_rows` fixture with cap+1 rows → `IngestError`; `../escape` query → `PathSecurityError`; missing `root` dir and missing `query` file → `IngestError` (not bare `FileNotFoundError`); BOM fixture → first header cell clean. - Pattern: local `tmp_path` CSV-builder helper (conftest's LLM fixtures are irrelevant to ingest — no conftest changes) - **Verify:** `uv run pytest tests/test_ingest_materialize.py -q` → expected: all pass - **On failure:** revert — `git checkout -- src/portfolio_optimiser/ingest.py && rm -f tests/test_ingest_materialize.py` (`ingest.py` is tracked after Step 1's checkpoint; the test file is new in this step) - **Checkpoint:** `git commit -m "feat(ingest): CSV connector with boundary check, row cap and escaped table body (I2)"` - **Manifest:** ```yaml manifest: expected_paths: - src/portfolio_optimiser/ingest.py - tests/test_ingest_materialize.py min_file_count: 2 commit_message_pattern: "^feat\\(ingest\\): CSV connector with boundary check, row cap and escaped table body \\(I2\\)$" bash_syntax_check: [] forbidden_paths: - src/portfolio_optimiser/okf.py - tests/conftest.py must_contain: - path: src/portfolio_optimiser/ingest.py pattern: "safe_resolve" - path: src/portfolio_optimiser/ingest.py pattern: "utf-8-sig" ``` ### Step 3: Materialization — provenance frontmatter, LF byte-writer, in-memory staging - **Files:** `src/portfolio_optimiser/ingest.py`, `tests/test_ingest_materialize.py` - **Changes:** Add the per-extraction concept-file builder and the public entry point `materialize(manifest_path: str | Path, bundle_dir: str | Path, *, ingested_at: str) -> list[Path]` (explicit required `ingested_at` keyword, NO wall-clock default — mirrors `promote_verdict`; validate against the regex `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$` — NOT `datetime.fromisoformat`, which rejects the `Z` suffix on Python 3.10, the repo's version floor — then stamp the string verbatim). This programmatic function IS the I2 invocation surface (recorded decision, resolving the brief's open question: a `python -m` CLI is not demanded by the I2 verification set and is deferred). `materialize` performs the §8 source-call log per extraction on logger `portfolio_optimiser.ingest`: source id, the `ingested_at` argument (deterministic "when" — never wall-clock), row count; never log cell contents or (in later source types) resolved secrets. It creates the bundle directory first: `Path(bundle_dir).mkdir(parents=True, exist_ok=True)` — `safe_resolve` never creates directories, and unlike `okf.write_concept_file` the byte-writer has no implicit mkdir. Frontmatter: ordered dict with EXACTLY the §5 keys in §5 order — `type` (= `okf_type`), `title`, `source_system` (= `source.id`), `source_query` (the query, whitespace-collapsed), `ingested_at`, `ingest_manifest` (the Step-1 stamp), `generated` (`true`) — rendered via `okf.render_frontmatter` (insertion-ordered, single-lining). Filenames: `ingest-{extraction.id}.md` (§5; grammar keeps the namespace disjoint from `index.md`/`promoted-verdict-*` by construction). File content assembled as ONE string `---\n{fm}\n---\n\n{body}` (body already ends in exactly one `\n`) and written as bytes (`content.encode("utf-8")`) to a `safe_resolve`d path — LF-only and exactly one trailing newline guaranteed on every platform (§5). **All extractions execute and render in memory BEFORE the first disk mutation** (crash-window mitigation for §5's non-atomic replace sequence; recovery = idempotent re-run, §10). - **Reuses:** `okf.render_frontmatter` (`okf.py:146`), `retrieval.safe_resolve`; `promote_verdict`'s explicit-timestamp shape (`verdicts.py:465-472`). - **Test first:** - File: `tests/test_ingest_materialize.py` (extend) - Verifies: generated file's RAW TEXT has the 7 frontmatter keys in exact §5 order (read the file text, not just the parsed dict); `okf.parse_frontmatter` round-trips the values (`generated == "true"` as string; `ingest_manifest == {stem}@{hash16}` computed independently in the test from the manifest's raw bytes; `ingested_at` verbatim; `source_query` whitespace-collapsed); file bytes are LF-only with exactly one trailing `\n` (`read_bytes()` assertions: no `\r`, endswith exactly one `b"\n"`); missing/invalid `ingested_at` (no argument has no default — non-`Z`/non-ISO string raises `ValueError`; the golden timestamp `2026-07-03T12:00:00Z` passes the regex); materializing into a NON-EXISTENT nested `bundle_dir` succeeds (mkdir path); §8 log record (via `caplog`) carries source id, the explicit `ingested_at` string, and row count; two `materialize` runs with identical inputs produce byte-identical files (§10 idempotence at file level). - Pattern: `tests/test_okf.py` frontmatter round-trip assertions - **Verify:** `uv run pytest tests/test_ingest_materialize.py -q` → expected: all pass - **On failure:** revert — `git checkout -- src/portfolio_optimiser/ingest.py tests/test_ingest_materialize.py` - **Checkpoint:** `git commit -m "feat(ingest): deterministic materialization with §5/§7 provenance stamp (I2)"` - **Manifest:** ```yaml manifest: expected_paths: - src/portfolio_optimiser/ingest.py - tests/test_ingest_materialize.py min_file_count: 2 commit_message_pattern: "^feat\\(ingest\\): deterministic materialization with .5/.7 provenance stamp \\(I2\\)$" bash_syntax_check: [] forbidden_paths: - src/portfolio_optimiser/okf.py must_contain: - path: src/portfolio_optimiser/ingest.py pattern: "ingested_at" - path: src/portfolio_optimiser/ingest.py pattern: "ingest_manifest" ``` ### Step 4: Index generation + stamped-replacement semantics - **Files:** `src/portfolio_optimiser/ingest.py`, `tests/test_ingest_materialize.py` - **Changes:** Complete `materialize` with §3/§5/§6 bundle semantics. (a) **Ownership scan:** classify a bundle file as ingest-owned iff `okf.parse_frontmatter` yields `generated == "true"` AND an `ingest_manifest` key (string comparisons; sorted directory scan for determinism). (b) **Collision gate:** if a target filename `ingest-{id}.md` exists WITHOUT the stamp → `IngestError`, nothing written (§3: never overwrite curated content). (c) **Replace:** delete every stamped file, then write the new staged set. (d) **Index:** if `index.md` missing → create it with `bundle_summary` as the body (no frontmatter — spec-minimal, pinned decision; content = `{bundle_summary}\n` written as bytes). If present → preserve every unmanaged line byte-for-byte; remove ONLY whole lines matching the managed-link form `- [