docs(i6): plan for http source extension point (I6)

/trekplan for the http source family (MAF-only). Validator green (4 steps,
plan_version 1.7). 5 exploration agents; scope-guardian ALIGNED; plan-critic
REVISE -> all resolved (1 blocker: gate false-green, now asserts both branches
so it goes RED on either detach; 5 major inc. materialize-only flag scoping
verified vs run.py, fence-collision -> fail-fast, create_sdk_mcp_server verified
vs official Agent SDK docs, live-grep scoped to I6 artifacts, golden oracle
hand-derived in-plan). High-effort gemini-bridge pass: re-confirmed BROKEN
(retrieval bug + deprecated model) -> substituted plan-critic + primary-source
checks (CommonMark 4.5 fence, CPython str.splitlines -> \n-only max_rows count).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015U5UK6gVFJNvTEdDcyUb8W
This commit is contained in:
Kjell Tore Guttormsen 2026-07-04 09:40:36 +02:00
commit 2208cdf3e9

View file

@ -0,0 +1,565 @@
# I6 — HTTP source connector as a demonstrated extension point (MAF-only)
> **Plan quality: A** (92/100) — APPROVE_WITH_NOTES
>
> Generated by trekplan v5.9.1 on 2026-07-04 — `plan_version: 1.7`
## Context
The ingest layer implements the `file`/CSV (I2/I3) and `sql` (I4/I5) source families on
both stacks, built from the frozen `shared/ingest-spec.md`. That spec (§1, §4) declares
`type: "http"` an OPTIONAL extension point: the schema already validates `HttpSource`
(`src/portfolio_optimiser/ingest.py:84-100`), but `materialize` refuses it fail-fast
(`ingest.py:394-398`, "gated extension point (I6) with no connector"). I6 activates that stub
to demonstrate — honestly and end-to-end — HOW a third, network-transport source family plugs
into the same connector / materialization / gate contracts, WITHOUT consuming the platform's
"first live source" milestone. It realizes two locked framework principles at once: the
90%-principle (generic core + clear extension points, do not chase the last 10%) and the
security frame's "local-only default, no silent egress" (§8) — a network source added strictly
behind an explicit run-flag the manifest itself cannot grant. MAF-only; `okf.py`, `shared/`,
and the frozen spec are untouched.
## Architecture Diagram
```mermaid
graph TD
subgraph "I6 changes in ingest.py"
M["materialize(...,<br/>allow_network=False,<br/>http_get=None)"]:::mod
GATE{"isinstance(HttpSource)<br/>AND not allow_network?"}:::new
DISP["dispatch loop<br/>(file / sql / http branch)"]:::mod
RH["read_http(base_url, query,<br/>*, max_rows, credential_ref, get)"]:::new
UG["_urllib_get<br/>(stdlib GET — ONLY socket path)"]:::new
FENCE["_render_fenced_block<br/>(verbatim, ``` fence)"]:::new
end
subgraph "reused UNCHANGED"
RC["_render_concept_file<br/>(7-key frontmatter)"]:::keep
WB["_write_bytes / ownership /<br/>collision / index"]:::keep
OKF["okf.render_frontmatter /<br/>link_in_index"]:::keep
end
TEST["golden + load-bearing tests<br/>inject a canned http_get<br/>(no socket, no creds)"]:::test
M --> GATE
GATE -->|"no flag → IngestError"| REFUSE["fail-fast (spec §8)"]:::new
GATE -->|"allow_network=True"| DISP
DISP --> RH --> UG
RH -->|"body text"| FENCE --> RC --> WB --> OKF
TEST -.injects.-> RH
classDef new fill:#2d6,stroke:#164,color:#000
classDef mod fill:#fd6,stroke:#a70,color:#000
classDef keep fill:#ddd,stroke:#888,color:#000
classDef test fill:#8cf,stroke:#048,color:#000
```
## Codebase Analysis
- **Tech stack:** Python ≥3.10, Pydantic (validation/IR), stdlib `urllib` (transport),
`pytest` (+ byte-for-byte golden harness), `ruff`/`mypy`, `uv`. Zero new runtime deps.
- **Key patterns:** one connector fn per source type returning renderable content; a
per-source-type dispatch inside `materialize`; credential/connection resolution by
**env-var name** (never from the manifest); a committed golden case per type; load-bearing
tests that go RED when a seam is detached; spec-silent decisions pinned in the module
docstring + frozen in golden bytes.
- **Relevant files (verified):**
- `src/portfolio_optimiser/ingest.py``HttpSource` schema (`:84-100`), the gate to
replace (`:394-398`), the dispatch loop (`:408-426`), `read_sql` template (`:233-278`),
`_render_concept_file` 7-key frontmatter (`:304-318`, `source_system = manifest.source.id`),
`_write_bytes` (`:321-327`), ownership/collision/index machinery (`:428-470`), module
docstring pin-block (`:1-31`).
- `tests/test_ingest_golden_sql.py` — byte-for-byte golden harness (`_bundle_bytes`, file-set
equality, §10 second-run idempotence).
- `tests/test_ingest_sql_loadbearing.py` / `tests/test_ingest_sql.py` — detach-RED and
connector-unit templates.
- `tests/test_ingest_materialize.py:245-253``test_http_source_has_no_connector` (stale
after I6: still green under default-no-flag, but its "no connector" comment becomes false).
- `examples/ingest-golden-sql/` — golden layout to mirror (`manifest.json`, `fixture/`,
`ingested-at.txt`, `expected-bundle/{index.md, ingest-*.md}`).
- `.claude/projects/2026-07-04-i4-ingest-sql-maf/build_fixture.py` — throwaway fixture-builder
pattern.
- `docs/extending.md`**already exists** (bilingual dev doc: Norwegian headers, English
prose); I6 **appends** an http source-family section + the D7 hook pointer.
- **Reusable code (leverage unchanged):** `_render_concept_file`, `_write_bytes`, the ownership
scan / collision gate / index generation (`ingest.py:428-470`), `load_manifest`,
`okf.render_frontmatter` / `okf.link_in_index`. **Explicitly NOT reused for http:**
`render_table` / `_escape_cell` / `_sql_value_to_text` (http body is verbatim inside a
fenced code block, not a table).
- **External tech (researched):** none — 0 research topics (frozen spec fully defines http
semantics; I4 SQL is the direct in-repo precedent; stdlib mock). The single external-adjacent
item (`create_sdk_mcp_server` currency for the docs pointer) is a doc-time verification, not a
planning input.
- **Recent git activity:** `bc52c12` (I6 brief), `1b7612b`/`d7e5f2f` (I4 SQL, the direct
precedent), `e4ee8bd``994f5be` (I2 CSV). Branch `main`, clean.
## Implementation Plan
Each step is test-first (Iron Law). Steps are ordered by dependency: connector → gate+dispatch →
golden → docs.
### Step 1: Implement `read_http` connector + injectable transport seam (docstring-pinned)
- **Files:** `src/portfolio_optimiser/ingest.py`, `tests/test_ingest_http.py` *(new)*
- **Changes:** Add, near `read_sql` (`ingest.py:233`):
- A transport-seam type alias `HttpGet = Callable[[str, str | None], str]` (`(url, credential) -> response body text`); add `from collections.abc import Callable` and
`from urllib.request import Request, urlopen` + `from urllib.error import URLError` to imports.
- `def _urllib_get(url: str, credential: str | None) -> str` — the stdlib GET and the **only**
socket path: build a `Request(url, headers=..., method="GET")`, attach
`Authorization: Bearer {credential}` iff `credential is not None`, `urlopen`, read, decode
UTF-8. Wrap `URLError`/`OSError`/`UnicodeDecodeError` as `IngestError`. Do NOT pre-add a
`# noqa: S310``pyproject.toml` selects no `flake8-bandit` (`S`) rules, so S310 never
fires and a dead noqa would trip `RUF100` if it is ever enabled; add the noqa ONLY if
`uv run ruff check .` actually flags the `urlopen` at Verify.
- `def read_http(base_url, query, *, max_rows, credential_ref=None, get: HttpGet = _urllib_get) -> str`
mirroring `read_sql`: resolve `credential_ref` via `os.environ.get` (present-but-unset/empty →
`IngestError` fail-fast, mirroring `ingest.py:249`; `None` → no auth); build
`url = base_url.rstrip("/") + "/" + query.lstrip("/")`; call `get(url, credential)`;
normalize CRLF→LF; **fence-collision fail-fast** — if any normalized line, after stripping
up to its leading spaces (`line.lstrip(" ")`), begins with ` ``` ` (a fence marker — note
CommonMark permits a closing fence indented ≤3 spaces, so a leading-space check is required),
raise `IngestError` (a body that cannot be safely embedded in a fenced code block is an
ERROR, never silently-corrupted markdown — the module's no-silent-coercion discipline + §8
fail-fast); enforce the cap on the **`\n`-only line count** of the CRLF-normalized text —
`n = normalized.count("\n"); line_count = n if (normalized == "" or normalized.endswith("\n")) else n + 1`;
`line_count > max_rows → IngestError` (never truncation, §8). **Do NOT use `str.splitlines()`**
for the cap: CPython `splitlines()` splits on the extended Unicode set
(`\v`, `\f`, `\x1c`-`\x1e`, `\x85`, `\u2028`, `\u2029`), so a body with a vertical-tab or line-separator would be over-counted vs the
LF-only rendered file (verified 2026-07-04). The `\n`-only count matches the rendered body and
still treats a single trailing newline as no phantom line. Return normalized text. Pure: no
writes, no logging (materialize owns §8 log).
- `def _render_fenced_block(body: str) -> str``f"\`\`\`\n{body.rstrip(chr(10))}\n\`\`\`\n"`
(verbatim inside a fenced code block; LF-only; exactly one trailing newline).
- **Docstring pin (module docstring, `ingest.py:23-30` block):** append the http (I6) pinned
decisions, mirroring the I4 sql sentence: transport is an injectable `get` callable
(default `_urllib_get`, the only socket); `credential_ref` names an env secret resolved at
run time (Bearer), never from the manifest, never logged, never stamped in frontmatter;
URL = `base_url` (one trailing slash stripped) + `/` + `query` (leading slash stripped);
body = response decoded UTF-8, CRLF→LF, trailing newlines stripped, rendered verbatim in a
``` fenced block (NOT table-escaped); `max_rows` caps response **line count**
(`len(normalized.splitlines())`), fail-fast; a response line beginning with ` ``` `
`IngestError` fail-fast (cannot be safely fenced — never silent corruption, consistent with
the no-silent-coercion frame; the fixed 3-backtick fence is deliberate, NOT a silent
fallback).
- **Reuses:** `read_sql` structure (`ingest.py:233-278`) — env-resolve → fetch → cap →
`IngestError` wrap; the CSV/SQL streaming-cap idiom (`ingest.py:193-197`, `:270-274`).
- **Test first:**
- File: `tests/test_ingest_http.py` (new)
- Verifies (each injects a canned `get`, no socket, no creds unless monkeypatched):
`read_http` returns the injected body verbatim and calls `get` with the joined URL;
a body of exactly `max_rows` lines → returns normally (boundary pass);
`max_rows + 1` lines → `IngestError` (never truncated) **[SC max_rows]**;
a body with a line beginning ` ``` ``IngestError` (fence-collision fail-fast);
`credential_ref` present but env unset → `IngestError`; `credential_ref` set → the injected
`get` receives the resolved secret (secret comes from env, never the manifest);
`read_http`'s `get` default is `_urllib_get` (`inspect.signature` — proves the stdlib
default without a socket).
- Pattern: `tests/test_ingest_sql.py` (connector-unit style; local `get`-builder mirroring
`_make_db` locality — do NOT add to `conftest.py`).
- **Verify:** `uv run pytest tests/test_ingest_http.py -q` → expected: all pass (offline).
- **On failure:** revert — `git checkout -- src/portfolio_optimiser/ingest.py tests/test_ingest_http.py`
- **Checkpoint:** `git commit -m "feat(ingest): http read_http connector + injectable transport seam (I6)"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- src/portfolio_optimiser/ingest.py
- tests/test_ingest_http.py
min_file_count: 2
commit_message_pattern: "^feat\\(ingest\\): http read_http connector \\+ injectable transport seam \\(I6\\)$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/okf.py
- shared/ingest-spec.md
must_contain:
- path: src/portfolio_optimiser/ingest.py
pattern: "def read_http"
- path: src/portfolio_optimiser/ingest.py
pattern: "_render_fenced_block"
- path: tests/test_ingest_http.py
pattern: "read_http"
```
### Step 2: Gate http behind the `allow_network` run-flag + wire the dispatch branch
- **Files:** `src/portfolio_optimiser/ingest.py`, `tests/test_ingest_http_loadbearing.py`
*(new)*, `tests/test_ingest_materialize.py`
- **Changes:**
- `materialize` signature (`ingest.py:373-375`): add keyword-only `allow_network: bool = False`
and `http_get: HttpGet | None = None` (both defaulted → all existing file/sql callers stay
backward-compatible; `run_project`/`run_portfolio` are NOT touched — verified they never call
`materialize`). Extend the docstring to note the §8 network opt-in flag is a run argument.
- Replace the unconditional refusal (`ingest.py:393-398`) with the gate:
`if isinstance(source, HttpSource) and not allow_network: raise IngestError(...)` — refuse
fail-fast **before any source call**, message citing §8 ("the manifest cannot grant itself
network access"). This exact `and not allow_network` clause is the load-bearing seam.
- Restructure the dispatch loop (`ingest.py:408-426`) so each branch sets `body` and a
`row_count`: `FileSource``read_csv` + `render_table`, `row_count = len(rows)`;
`elif isinstance(source, HttpSource)``get = http_get if http_get is not None else _urllib_get`;
`text = read_http(source.base_url, extraction.query, max_rows=extraction.max_rows, credential_ref=source.credential_ref, get=get)`;
`body = _render_fenced_block(text)`; `row_count = len(text.splitlines())`; `else` (Sql) →
`read_sql` + `render_table`, `row_count = len(rows)`. Keep the single `_LOGGER.info` §8 log
(`ingest.py:416-421`) logging `rows=row_count` (line count for http). The stale
"http already refused above" comment (`ingest.py:412`) is removed.
- `tests/test_ingest_materialize.py:245-253`: update `test_http_source_has_no_connector`'s
comment/intent — the default-no-flag path still raises `IngestError`, but the wording
"no connector" is now false; reword to "http is refused without the per-run network flag"
(assertion stays green; comment corrected). Do not delete — it is the default-refuse smoke.
- **Reuses:** `_render_concept_file`, `_write_bytes`, ownership/collision/index machinery
(`ingest.py:428-470`) — all unchanged; the http bundle flows through the identical §3/§6 disk
phase.
- **Test first:**
- File: `tests/test_ingest_http_loadbearing.py` (new)
- Verifies (detach-RED seams, each maps to a Success Criterion):
1. **Network gate [SC1] — asserts BOTH branches on the SAME manifest + SAME recording `get`
(this is what makes it genuinely detach-RED):**
- *Refuse branch:* `materialize(http_manifest, out, ingested_at=…, allow_network=False, http_get=recording_get)`
→ assert `IngestError` **and** `recording_get` was **never called** (refuse before any
source access, §8).
- *Allow branch:* `materialize(http_manifest, out, ingested_at=…, allow_network=True, http_get=recording_get)`
→ assert it **succeeds** (writes `ingest-*.md`) **and** `recording_get` **was called**.
Why both: the seam is `if isinstance(source, HttpSource) and not allow_network: raise`.
Removing the whole clause → the *refuse* assertion fails (get called, no error) → RED;
removing just `and not allow_network` (reverting to the *unconditional* refusal that
exists today) → the *allow* assertion fails (raises when it should fetch) → RED. A
refuse-only test would false-green on the second detach. *(Inject a recording `get` that
returns a valid payload — never the default `_urllib_get`, whose dead socket would
false-green the refuse branch.)*
2. **Fenced-not-table [§5 http seam]:** payload with a raw `|` and `\`; assert the body is
``` -fenced and the pipe survives **un-escaped** (`"\\|" not in body`, raw `|` present).
RED if the http path degrades to `render_table`.
3. **Navigability [§2/§11]:** drive the http bundle through unchanged
`okf.navigate_bundle`/`bundle_context`; reachable via index link, classified by
`okf_type`, provenance rides through. RED when index linking detaches.
4. **Over-cap writes nothing [SC max_rows]:** over-cap payload → `IngestError` **and** no
`ingest-*.md` written (memory-staging). RED if silent truncation replaces the raise.
- Pattern: `tests/test_ingest_sql_loadbearing.py` (detach discipline) + the ordering proof in
`tests/test_ingest_manifest.py:178`.
- **Verify:** `uv run pytest tests/test_ingest_http_loadbearing.py tests/test_ingest_materialize.py -q`
→ expected: all pass. Detach-proof (manual, at review): the gate test goes RED on BOTH
detaches — (a) delete the whole `if isinstance(source, HttpSource) and not allow_network:`
block → refuse branch RED; (b) reduce it to the unconditional `if isinstance(source, HttpSource):`
→ allow branch RED.
- **On failure:** revert — `git checkout -- src/portfolio_optimiser/ingest.py tests/test_ingest_http_loadbearing.py tests/test_ingest_materialize.py`
- **Checkpoint:** `git commit -m "feat(ingest): gate http behind allow_network run-flag + dispatch (I6)"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- src/portfolio_optimiser/ingest.py
- tests/test_ingest_http_loadbearing.py
- tests/test_ingest_materialize.py
min_file_count: 3
commit_message_pattern: "^feat\\(ingest\\): gate http behind allow_network run-flag \\+ dispatch \\(I6\\)$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/okf.py
- src/portfolio_optimiser/run.py
- shared/ingest-spec.md
must_contain:
- path: src/portfolio_optimiser/ingest.py
pattern: "allow_network"
- path: src/portfolio_optimiser/ingest.py
pattern: "and not allow_network"
- path: tests/test_ingest_http_loadbearing.py
pattern: "allow_network=False"
```
### Step 3: Author the byte-deterministic golden case `examples/ingest-golden-http/`
- **Files:** `examples/ingest-golden-http/manifest.json` *(new)*,
`examples/ingest-golden-http/fixture/status` *(new)*,
`examples/ingest-golden-http/fixture/report` *(new)*,
`examples/ingest-golden-http/ingested-at.txt` *(new)*,
`examples/ingest-golden-http/expected-bundle/` *(new, generated + hand-reviewed)*,
`tests/test_ingest_golden_http.py` *(new)*,
`.claude/projects/2026-07-04-i6-ingest-http-maf/build_fixture.py` *(new, throwaway)*
- **Changes:**
- `manifest.json`: `source` = `{"type": "http", "id": "status-api", "base_url": "https://api.example.test/v1", "credential_ref": null}`
(`.test` reserved TLD + `credential_ref: null` → runs with **no network, no credentials**,
proving SC offline); `bundle_summary`; two extractions —
`{"id": "status", "title": "Service status", "query": "status", "okf_type": "dataset", "max_rows": 10}`,
`{"id": "report", "title": "Ops report", "query": "report", "okf_type": "reference", "max_rows": 10}`.
- `fixture/status` (1 line, discriminates fenced-verbatim vs table-escape — contains raw `|`
and `\`): `{"service": "billing", "state": "degraded | partial", "path": "c:\temp\cache"}`
- `fixture/report` (3 lines, ≤ max_rows): `baseline ok` / `pipe | and backslash \ kept verbatim` /
`end of report`.
- `ingested-at.txt`: `2026-07-04T12:00:00Z` + trailing newline (byte-write, §5 format).
- `build_fixture.py`: writes the two payload files + `ingested-at.txt` (mirrors the I4 builder;
payloads are LF-only so the golden is platform-stable). Documents regenerability. **Author
the payload bytes as raw bytes / raw-string literals** — in a normal Python string literal
`\t` is a TAB and `\c` a literal backslash, so the intended verbatim backslashes in
`c:\temp\cache` (and `backslash \`) would be silently corrupted; write with `rb"..."` /
`.write_bytes(b"...")` or `r"..."` so the byte the fenced-not-escaped test depends on
survives exactly.
- `expected-bundle/`: **generate** by running `materialize` against the fixture with a canned
`get` (map `url.rsplit("/",1)[-1]``fixture/<name>`), `allow_network=True`, then **verify
the generated bytes against the spec-derived expectation below BEFORE committing** — this is
an agent check against an independent oracle, not a human gate, so a headless `claude -p`
run performs it too. The non-circular correctness oracle is carried here in the plan (and,
independently, by the Step-2 load-bearing tests, which hand-assert the fenced-not-escaped
body, the 7-key frontmatter, and navigability — so correctness is proven by hand-written
assertions, not by comparing code output to code output). If the generated `ingest-status.md`
diverges from the expectation below in any way OTHER than the `ingest_manifest` hash,
**escalate** (do not commit a wrong oracle).
Hand-derived `expected-bundle/ingest-status.md` (from §5/§7 + `fixture/status`; every field
except the manifest-hash is independently derived):
```
---
type: dataset
title: Service status
source_system: status-api
source_query: status
ingested_at: 2026-07-04T12:00:00Z
ingest_manifest: manifest@<sha256(manifest.json)[:16]>
generated: true
---
```fence
{"service": "billing", "state": "degraded | partial", "path": "c:\temp\cache"}
```fence
```
Note the pipe and backslashes are **un-escaped** (verbatim) inside the fence — the exact
discriminator vs `render_table`. `index.md` = `bundle_summary` + `- [Service status](ingest-status.md)`
+ `- [Ops report](ingest-report.md)`. The `ingest_manifest` hash is the only computed field
(SHA-256 of the committed `manifest.json` bytes) — frozen once committed.
- **Reuses:** golden layout + harness from `examples/ingest-golden-sql/` and
`tests/test_ingest_golden_sql.py`; builder shape from the I4 `build_fixture.py`.
- **Test first:**
- File: `tests/test_ingest_golden_http.py` (new)
- Verifies **[SC golden determinism]**: build the canned `get` from `fixture/`; `materialize(GOLDEN/manifest.json, out, ingested_at=<ingested-at.txt>, allow_network=True, http_get=fake)`;
file-set equality, per-file byte compare against `expected-bundle/`, second-run idempotence
(§10). No credential env set (`credential_ref: null`).
- Pattern: `tests/test_ingest_golden_sql.py:30-47` (swap the `monkeypatch.setenv` DSN for the
injected `get`).
- **Verify:** `uv run pytest tests/test_ingest_golden_http.py -q` → expected: pass, byte-identical.
- **On failure:** revert — `git checkout -- examples/ingest-golden-http tests/test_ingest_golden_http.py .claude/projects/2026-07-04-i6-ingest-http-maf/build_fixture.py && rm -rf examples/ingest-golden-http`
- **Checkpoint:** `git commit -m "test(ingest): http golden extraction, byte-deterministic (I6)"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- examples/ingest-golden-http/manifest.json
- examples/ingest-golden-http/ingested-at.txt
- examples/ingest-golden-http/fixture/status
- examples/ingest-golden-http/fixture/report
- examples/ingest-golden-http/expected-bundle/index.md
- examples/ingest-golden-http/expected-bundle/ingest-status.md
- examples/ingest-golden-http/expected-bundle/ingest-report.md
- tests/test_ingest_golden_http.py
- .claude/projects/2026-07-04-i6-ingest-http-maf/build_fixture.py
min_file_count: 9
commit_message_pattern: "^test\\(ingest\\): http golden extraction, byte-deterministic \\(I6\\)$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/okf.py
- shared/ingest-spec.md
must_contain:
- path: examples/ingest-golden-http/expected-bundle/ingest-status.md
pattern: "```"
- path: examples/ingest-golden-http/expected-bundle/ingest-status.md
pattern: "generated: true"
- path: examples/ingest-golden-http/manifest.json
pattern: "\"type\": \"http\""
```
### Step 4: Document the http extension-point pattern + D7 SDK hook pointer in `docs/extending.md`
- **Files:** `docs/extending.md`
- **Changes:** Append a new section (bilingual house style: Norwegian header, English prose)
documenting the http source family as the worked extension-point example: the manifest
`type: "http"` contract (`base_url` no embedded credentials, `credential_ref` by env-name),
the **run-argument** network opt-in (`materialize(..., allow_network=True)`; the manifest
cannot grant network — §8), the injectable transport seam (`http_get`, default stdlib
`urllib`), and the "verbatim in a fenced code block" body. Add the D7 sibling-hook pointer:
an MCP-based connector is "an extension of this family" (§4), and on the Claude Agent SDK side
the in-process server hook is **`create_sdk_mcp_server(name, version="1.0.0", tools=...) ->
McpSdkServerConfig`** (package `claude-agent-sdk`) — **VERIFIED 2026-07-04 against the official
Claude Agent SDK Python docs** (code.claude.com/docs/en/agent-sdk/python + docs.claude.com
agent-sdk/mcp; the name is confirmed, not assumed — pin it). Note MCP is documented as an
extension of this family, NOT a new client wired into the run path (Non-Goal). Frame
everything as "local mock / no live source"; do not claim a live integration.
- **Reuses:** the existing "Bevisst ikke bygget (90 %-kuttlista)" seam-naming style in
`docs/extending.md:55-74`; the honesty framing in `docs/extending.md:7-13`.
- **Test first:** *(docs — no unit test; command-checkable at Verify)*
- Verifies: `grep -q create_sdk_mcp_server docs/extending.md` exits 0, and a new http
source-family section is present; the new I6 artifacts carry no affirmative live-source claim.
- **Verify:** `grep -q create_sdk_mcp_server docs/extending.md && echo OK` → expected: `OK`;
`grep -rin "live" docs/extending.md examples/ingest-golden-http/ src/portfolio_optimiser/ingest.py`
→ expected: any hit is only "local mock" / "no live source" negated framing (scoped to the
I6 artifacts — the repo-wide grep is not a clean gate: `docs/`, `src/persona.py`,
`simulation.py` etc. carry dozens of unrelated pre-existing "live"/"delivery" substrings).
- **On failure:** revert — `git checkout -- docs/extending.md`
- **Checkpoint:** `git commit -m "docs(i6): http source extension-point + D7 create_sdk_mcp_server pointer"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- docs/extending.md
min_file_count: 1
commit_message_pattern: "^docs\\(i6\\): http source extension-point \\+ D7 create_sdk_mcp_server pointer$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/okf.py
- shared/ingest-spec.md
must_contain:
- path: docs/extending.md
pattern: "create_sdk_mcp_server"
- path: docs/extending.md
pattern: "allow_network"
```
## Alternatives Considered
| Approach | Pros | Cons | Why rejected |
|----------|------|------|--------------|
| Thread `allow_network` through `run_project`/`run_portfolio` (as the brief assumed) | Matches the brief's literal assumption | `run.py` never imports `ingest`/calls `materialize` (verified by 2 agents + grep) — the param would be dead code | Scope creep + speculative feature; violates surgical-change. Flag scoped to `materialize` only. |
| Reuse `render_table` for the http body | Maximal "mirror I4" symmetry | §5 requires the http body **verbatim in a fenced code block**, not a table; `render_table` escapes `\|`/`\\` | Spec-nonconforming; the fenced-not-table load-bearing test would fail. New `_render_fenced_block`. |
| `localhost http.server` thread for tests | "Real" HTTP round-trip | Opens a socket → violates §11 "without network access"; flaky/slow; hermeticity risk | Injectable `get` callable (canned payloads, no socket) is the hermetic §11 reading. Documented fallback only. |
| Variable-length fence (count backticks in body) | Silently "handles" a body containing ``` | Extra determinism surface; papers over an ambiguous body the model would later read | Rejected in favour of **fail-fast**: a body line beginning ` ``` ``IngestError` (no silent corruption; consistent with the module's no-silent-coercion frame). Fixed 3-backtick fence is deliberate, not a fallback. |
| `urllib.parse.urljoin` for the URL | Stdlib canonical | Surprising path semantics (absolute paths, `..`) → non-deterministic joins | Explicit `rstrip("/") + "/" + lstrip("/")` — predictable, pinned. |
## Test Strategy
- **Framework:** `pytest` (`uv run pytest`); byte-for-byte golden harness; detach-RED
load-bearing regime (spec §11).
- **Existing patterns:** three-file-per-source-type convention
(`test_ingest_http.py` connector-unit, `_loadbearing.py` detach-RED, `_golden_http.py`
byte-for-byte) — mirrors the sql trio. Canned-`get` builders local to the test files (never
`conftest.py`, which is MAF/LLM-only).
- **New tests in this plan:** ~11 across 3 new files + 1 comment correction.
### Tests to write
| Type | File | Verifies | Model test |
|------|------|----------|------------|
| Unit | `tests/test_ingest_http.py` | verbatim fetch; URL join; `max_rows`; credential env-resolve (set/unset); urllib default | `tests/test_ingest_sql.py` |
| Load-bearing | `tests/test_ingest_http_loadbearing.py` | network gate (never-called + IngestError); fenced-not-table; navigability; over-cap-writes-nothing | `tests/test_ingest_sql_loadbearing.py` |
| Golden | `tests/test_ingest_golden_http.py` | byte-identical materialization; §10 idempotence; no creds | `tests/test_ingest_golden_sql.py` |
| Comment fix | `tests/test_ingest_materialize.py:245` | default-no-flag still refuses (intent reworded) | — |
**Offline property (SC):** satisfied by construction — every http test injects `http_get` (no
socket) and needs no credential. Acceptance: `uv run pytest` exits 0 with the network cable
pulled. Convention to enforce in review: **no ingest test calls `materialize`/`read_http` for an
http source without passing `http_get`** (the default `_urllib_get` is the only socket path and
is never exercised).
## Risks and Mitigations
| Priority | Risk | Location | Impact | Mitigation |
|----------|------|----------|--------|------------|
| Critical | Silent egress if the gate sits after connector construction, or `_urllib_get` is reachable without the flag | `ingest.py:393` gate / dispatch | Network hit with no operator opt-in — defeats the task | Gate in `materialize` BEFORE the staging loop; `_urllib_get` only reached inside the http branch, which the gate guards. Load-bearing test asserts `get` never called when flag absent. |
| High | Credential echo in the verbatim body | http body render | A reflecting endpoint could echo `Authorization` into a committed bundle file | Inherent to "verbatim body" — do NOT auto-redact (breaks determinism). Golden mock never echoes a secret; document as a residual deployer-owned risk (§1 boundary). |
| High | Credential leak via error/log text | `read_http` IngestError, `_LOGGER.info` | Secret in an exception or log line | Mirror `read_sql`: wrap transport errors WITHOUT the secret; keep §8 log to `id`/`ingested_at`/`row_count` only. (Optional sentinel-secret grep test — nice-to-have, not gating.) |
| High | Determinism: verbatim-vs-LF-only tension, trailing newline, fence collision | `_render_fenced_block` / `read_http` | Golden passes locally, diverges on other input/platform; or silently-broken markdown in the bundle | Pin (docstring + golden): UTF-8 decode fail-fast; CRLF→LF; strip trailing newlines outside the fence; `max_rows = len(splitlines())`; **a body line beginning ` ``` ` → `IngestError` fail-fast** (never a silently-corrupted fenced block — consistent with the no-silent-coercion frame). |
| Medium | `max_rows` line-count semantics (extended-Unicode / trailing / blank lines) | `read_http` | Spurious pass/fail vs golden | Count `\n`-only on the CRLF-normalized text (NOT `str.splitlines()`, which also splits on `\v \f \x1c-\x1e \x85 \u2028 \u2029` — verified): `n = normalized.count("\n"); line_count = n if normalized.endswith("\n") else n+1`. Matches the LF-only body; a trailing newline adds no phantom line; internal blank lines count. Test exactly `max_rows` (pass) and `max_rows+1` (raise). |
| Medium | `base_url`+`query` join edge cases | `read_http` | Non-deterministic joins; scheme/host escape | Pinned `rstrip("/")+"/"+lstrip("/")`; `base_url` userinfo already rejected (`ingest.py:92-100`); the joined URL is never stamped in frontmatter. |
| Low | Stale `test_http_source_has_no_connector` intent | `tests/test_ingest_materialize.py:245` | Comment says "no connector" (now false) | Reword in Step 2; keep the default-refuse assertion. |
| Low | Temptation to touch `okf.py`/`shared/` | `okf.py`, `shared/` | Breaks D7-portability invariant / commons pull-only | `forbidden_paths` on every code step; SC `git diff --stat` shows no change. |
| Low | ruff `S310` (urlopen) / mypy `Callable` typing | `ingest.py` | Lint/type red | `# noqa: S310` on the gated `urlopen`; `Callable` from `collections.abc`; confirm at Verify. |
## Assumptions
| # | Assumption | Why unverifiable at plan time | Impact if wrong |
|---|-----------|-----------------|-----------------|
| 1 | The default auth scheme for a resolved `credential_ref` is `Authorization: Bearer {secret}` | The spec names credential-by-ref but not the wire scheme; endpoint-specific | A real deployer's endpoint may need a different scheme — acceptable for a demo extension point; documented, and the `get` seam is swappable. Golden uses `credential_ref: null` so this is not frozen in golden bytes. |
*(RESOLVED, not open — the brief's `[ASSUMPTION] flag threads through materialize and
run_project` was verified FALSE: `run.py` never calls `materialize`. The flag is scoped to
`materialize` only. `source_system` = `manifest.source.id` confirmed from
`_render_concept_file:312`; `max_rows` = response line count pinned per §8. The D7 pointer
`create_sdk_mcp_server` was verified 2026-07-04 against the official Claude Agent SDK Python docs
— it is the confirmed in-process MCP-server primitive, not an assumption.)*
## Verification
*Per-step manifests are checked by trekexecute. These are the brief's Success Criteria as
end-to-end checks (run from repo root):*
- [ ] `uv run pytest -q` → expected: exits 0 (full suite green, no network, no credentials set).
- [ ] Network gate detach-proof: comment out `and not allow_network` in `ingest.py`, run
`uv run pytest tests/test_ingest_http_loadbearing.py -k gate` → expected: **RED**; restore → green.
- [ ] `uv run pytest tests/test_ingest_golden_http.py -q` → expected: pass; output byte-matches
`examples/ingest-golden-http/expected-bundle/`.
- [ ] `uv run pytest tests/test_ingest_http.py -k max_rows` → expected: pass (over-cap → IngestError).
- [ ] `grep -rin "live" docs/extending.md examples/ingest-golden-http/ src/portfolio_optimiser/ingest.py`
→ expected: any hit is only "local mock" / "no live source" negated framing (scoped to the
NEW I6 artifacts — a repo-wide `grep "live"` is not a pass/fail gate: dozens of unrelated
pre-existing hits in `docs/`, `persona.py`, `simulation.py`, `test_*_live.py`).
- [ ] `git diff --stat` (vs pre-I6) → expected: NO change to `src/portfolio_optimiser/okf.py` or `shared/`.
- [ ] `uv run ruff check .` → 0; `uv run ruff format --check .` → 0; `uv run mypy src` → 0.
- [ ] `grep -q create_sdk_mcp_server docs/extending.md` → exits 0; new http section present.
- [ ] Null API-spend: no model calls, no real network (golden/gate/unit all use injected `get`).
## Estimated Scope
- **Files to modify:** 3 (`ingest.py`, `tests/test_ingest_materialize.py`, `docs/extending.md`).
- **Files to create:** 11 (3 test files + 8 golden/fixture/builder: `manifest.json`,
`ingested-at.txt`, `fixture/status`, `fixture/report`, 3 × `expected-bundle/*.md`,
`build_fixture.py`).
- **Complexity:** medium (one genuinely new render path + a security gate; the rest mirrors I4).
## Plan Quality Score
| Dimension | Weight | Score | Notes |
|-----------|--------|-------|-------|
| Structural integrity | 0.15 | 95 | 4 steps, strict dependency order (connector→gate→golden→docs) |
| Step quality | 0.20 | 92 | test-first, exact file:line, explicit reuse; Step 2 touches 3 files (cohesive) |
| Coverage completeness | 0.20 | 95 | every Success Criterion → a step + a Verification check |
| Specification quality | 0.15 | 90 | pinned decisions concrete; 2 honest doc-time assumptions remain |
| Risk & pre-mortem | 0.15 | 92 | silent-egress, credential-leak, determinism all mitigated |
| Headless readiness | 0.10 | 90 | On-failure + Checkpoint + Manifest per step |
| Manifest quality | 0.05 | 88 | must_contain + forbidden_paths guard okf.py/shared/ |
| **Weighted total** | **1.00** | **92** | **Grade: A** |
**Adversarial review:**
- **Plan critic:** REVISE → addressed. 1 blocker (gate false-green), 5 major (live-grep
unsatisfiable, first-live-source circular, create_sdk_mcp_server contradiction, fence-collision
silent, golden circular/hand-review), 6 minor — all resolved in Revisions below.
- **Scope guardian:** ALIGNED. 0 creep, 0 Non-Goal violations, 0 dependency issues; independently
confirmed the `materialize`-only flag scoping is a correct scope fix (not a gap). Its one minor
(over-broad live-grep) coincides with plan-critic MAJOR-1 and is fixed.
## Revisions
*Added by adversarial review (Phase 9). All blocker + major findings addressed; the plan was
revised once for the merged set.*
| # | Finding | Severity | Resolution |
|---|---------|----------|------------|
| 1 | Gate load-bearing test would false-green: reverting `and not allow_network` to the unconditional refusal still passes a refuse-only test | blocker | Step 2 gate test now asserts BOTH branches on the same manifest + same recording `get` (refuse: flag off → IngestError + get never called; allow: flag on → succeeds + get called). Removing the whole gate → refuse branch RED; removing just the clause → allow branch RED. Verify wording fixed. |
| 2 | `grep -rin "live" docs/ src/ examples/` unsatisfiable (dozens of pre-existing hits) | major | Scoped the "no live claim" check to the NEW I6 artifacts (`docs/extending.md`, `examples/ingest-golden-http/`, `ingest.py`) in both Step 4 and Verification. |
| 3 | `git grep "first live source"` circular (matches only plan/brief) | major | Removed the circular sub-check. |
| 4 | `create_sdk_mcp_server` pinned in 3 gates while flagged "may be stale" | major | Verified 2026-07-04 against official Claude Agent SDK Python docs — confirmed correct; pinned confidently, Assumption downgraded to a verified fact. |
| 5 | Fence-collision (body containing a ` ``` ` line) silently corrupts the bundle | major | Changed to fail-fast: a body line beginning ` ``` ``IngestError` (Step 1 connector + docstring + risk + alternatives + a new unit test). |
| 6 | Golden expected-bundle circular + "hand-review" is a non-headless human step | major | Step 3 now carries the hand-derived `ingest-status.md` expectation IN the plan (non-circular, headless-usable), names the Step-2 load-bearing tests as the independent correctness oracle, and adds an escalate-on-divergence clause. |
| 7 | `max_rows` counts lines pre-strip; blank-line nuance | minor | Pinned: count on CRLF-normalized text before the trailing-strip; blank lines count as content lines (deterministic). Risk row + Step 1 updated. |
| 8 | `c:\temp\cache` backslash/tab trap in `build_fixture.py` | minor | Pinned raw-bytes / raw-string authoring so the verbatim backslash survives. |
| 9 | `# noqa: S310` likely dead (no bandit config) | minor | Do not pre-add; add only if `ruff check` flags the `urlopen` at Verify. |
| 10 | File-count off by one (10 vs 11 created) | minor | Corrected to 11 created / 3 modified. |
| 11 | Line-ref drift (`:393` vs `:394`) | minor | Standardized the gate reference to `:394-398`. |
| 12 | Pass-at-`max_rows` boundary test only in risk table | minor | Added the boundary-pass + `max_rows+1`-raise pair to the Step 1 test enumeration. |
## Adversarial Pass 2 (gemini-bridge, v5.1.1 high-effort)
The high-effort plan phase runs an additional independent gemini-bridge review. **Outcome:
gemini-bridge is non-functional (verified 2026-07-04, not assumed).** The deep-research query ran
to `status: completed`, but every retrieval path failed: `gemini_get_research_result`
`'Interaction' object has no attribute 'outputs'` (server-side bug, 3× retry); the followup
re-emit → `404 model gemini-3-pro-preview no longer available` (deprecated). This confirms the
prior recorded outage — the operator's expectation that it was fixed did not hold. No findings
were retrievable; the agent correctly refused to fabricate a second opinion.
**Substitute triangulation (per the standing gemini-outage handling):**
- An independent high-effort **plan-critic confirmation pass** traced the blocker fix against both
detach cases and verified revisions #2#6 — plan **sound**, no new blocker/major; folded in one
minor (CommonMark ≤3-space indented closing fence → `line.lstrip(" ").startswith("```")`).
- The two decisions gemini flagged as spec-checkable were resolved by **primary-source verification**
(no gemini needed): **CommonMark §4.5** fenced-code semantics (closing fence ≥ opening length,
≤3-space indent) → the fence-collision check is adequate; **CPython `str.splitlines()`** splits on
the extended Unicode set → the `max_rows` cap was switched to a **`\n`-only** count so it matches
the LF-only rendered body (Step 1 + risk row updated).
- Decision 4 ("config cannot grant its own capability; capability is an explicit runtime grant")
is the **capability-security / POLA** (Principle of Least Authority) pattern — the manifest is
data that cannot escalate its own authority; only the `allow_network` runtime grant can.
Net: no external second opinion available, but every decision gemini would have triangulated is
covered by an independent internal pass or an authoritative primary-source check.