feat(okf): navigate hierarchy — escape, not depth, is forbidden

The pulled method-spec (commons 9801d35) retires the "a target containing a
path separator is out-of-bundle" heuristic, which conflated depth with escape
and forbade valid hierarchy. Triage of the pull found FIVE contradictions in
okf.py, not the two STATE had measured on line 127 alone:

1. the separator ban skipped every legal nested target;
2. de-duplication keyed on the RAW target (`resolved` was computed a line
   later), not on the resolved path;
3. navigation never recursed — only the root index's links were read;
4. a leading `/` became filesystem-absolute via pathlib rather than denoting
   the bundle root (safe, because the boundary check caught it, but the right
   outcome for the wrong reason — and wrong the moment `/a/index.md` must be
   FOLLOWED);
5. rendering excluded only `verdict`, so a nested index body would render as
   content.

navigate_bundle is now depth-first in first-seen link order, de-duplicating on
the resolved path (so `./a.md` and `a.md` are one entry and cycles terminate);
resolution and the fail-closed boundary check move to _resolve_target, the sole
in-/out-of-bundle test. The missing-index rule binds the bundle root alone.
bundle_context renders flat regardless of depth and drops nested index bodies:
only the root index is the summary.

The gate is the commons-owned nav-golden pair that arrived with the same pull —
bundle in, expected-read-context out. Its negative case exists so the gate can
go red at all, and carries a real decoy one level up plus a `/etc/passwd` trap.

Detach-proved (mutate, run, restore from copy) — each new seam goes RED:
  D1 reinstate the separator heuristic -> RED
  D2 re-key dedup on the raw target   -> RED
  D3 read a leading `/` as absolute   -> RED
  D4 render nested index bodies       -> RED
Control after restore: 24 passed. Suite 631 -> 637, ruff + mypy --strict clean.

Comments asserting the retired doctrine were corrected rather than left to
document a rule the code no longer follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M1zp3BxCuzRnUtJPzvEFTQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 18:13:20 +02:00
commit 7d8de32543
2 changed files with 157 additions and 42 deletions

View file

@ -2,11 +2,15 @@
The read-context is built by NAVIGATING the bundle with progressive disclosure
never by stuffing the whole bundle (or keyword-retrieved chunks) into the prompt.
Navigation starts at ``index.md`` and follows its intra-bundle cross-links; broken
or bundle-escaping links are tolerated (skipped, never raised the OKF robustness
rule), while a missing ``index.md`` is an error (no entry point). ``type: verdict``
files are EXCLUDED from rendering: prior verdicts reach the hypothesis prompt ONLY
via the gated experience fold (see ``experience``), never via context rendering.
Navigation starts at the root ``index.md`` and follows intra-bundle cross-links
depth-first; a bundle MAY be hierarchical, since it is ESCAPE and not DEPTH that is
forbidden (the resolve-and-boundary-check REPLACES the retired "a path separator
means out-of-bundle" heuristic, which conflated the two and forbade valid
hierarchy). Broken or bundle-escaping links are tolerated (skipped, never raised
the OKF robustness rule), while a missing ``index.md`` is an error at the bundle
ROOT alone. ``type: verdict`` files are EXCLUDED from rendering: prior verdicts
reach the hypothesis prompt ONLY via the gated experience fold (see ``experience``),
never via context rendering.
Pure stdlib by design the context seam imports no agent toolkit (§11).
"""
@ -105,13 +109,54 @@ def _parse_index_entry(index_path: Path) -> ConceptFile:
return ConceptFile(path=index_path, frontmatter=frontmatter, body=body)
def navigate_bundle(bundle_dir: Path) -> list[ConceptFile]:
"""Navigate from ``index.md`` — deterministic order: index first, links first-seen.
def _resolve_target(bundle_root: Path, linking_dir: Path, target: str) -> Path | None:
"""Resolve one cross-link per §3 Step 1, or return ``None`` if it must be skipped.
Targets containing a path separator are out-of-bundle and skipped; resolution is
boundary-checked against the bundle directory (fail-closed); broken links are
skipped, never raised. Repeated links are de-duplicated. The index entry point
does not require ``type`` with or without a frontmatter block
A leading ``/`` denotes the BUNDLE ROOT never a filesystem-absolute path; any
other form is relative to the linking file's own directory. The resolved path is
then boundary-checked against the bundle directory, fail-closed: that check is
the SOLE in-/out-of-bundle test. It is escape, not depth, that is forbidden.
A target that fails to resolve for ANY reason missing file, escape, or an
invalid path component such as an embedded NUL byte (which makes ``resolve`` /
``is_file`` raise ``ValueError``) is skipped, never raised (OKF robustness).
"""
try:
candidate = (
bundle_root / target.lstrip("/") if target.startswith("/") else linking_dir / target
)
resolved = candidate.resolve()
if not resolved.is_relative_to(bundle_root) or not resolved.is_file():
return None
except ValueError:
return None
return resolved
def _descend(source: Path, bundle_root: Path, seen: set[Path], concepts: list[ConceptFile]) -> None:
"""Follow ``source``'s cross-links depth-first, in first-seen link order."""
for target in _CROSSLINK_PATTERN.findall(source.read_text(encoding="utf-8")):
resolved = _resolve_target(bundle_root, source.parent, target)
# De-duplication keys on the RESOLVED path, so `./a.md` and `a.md` are one
# entry and link cycles terminate.
if resolved is None or resolved in seen:
continue
seen.add(resolved)
is_index = resolved.name == _INDEX_FILENAME
concepts.append(_parse_index_entry(resolved) if is_index else parse_concept_file(resolved))
_descend(resolved, bundle_root, seen, concepts)
def navigate_bundle(bundle_dir: Path) -> list[ConceptFile]:
"""Navigate from ``index.md`` — depth-first, in first-seen link order.
Hierarchy is legal: a target MAY address a nested directory, and each index links
its immediate children (one path segment per level). Resolution and the
boundary check live in ``_resolve_target``; de-duplication keys on the resolved
path. A missing ``index.md`` is an error AT THE BUNDLE ROOT ALONE an
intermediate directory is navigated only through the links its own files carry,
so a nested directory without an index is unreachable, not fatal. The index entry
point does not require ``type`` with or without a frontmatter block
(``_parse_index_entry``); non-index concept files still require it.
"""
index_path = bundle_dir / _INDEX_FILENAME
@ -119,38 +164,31 @@ def navigate_bundle(bundle_dir: Path) -> list[ConceptFile]:
raise FileNotFoundError(
f"bundle has no entry point: missing {_INDEX_FILENAME} in {bundle_dir}"
)
index = _parse_index_entry(index_path)
bundle_root = bundle_dir.resolve()
concepts = [index]
seen = {_INDEX_FILENAME}
for target in _CROSSLINK_PATTERN.findall(index_path.read_text(encoding="utf-8")):
if "/" in target or "\\" in target or target in seen:
continue
seen.add(target)
try:
resolved = (bundle_dir / target).resolve()
if not resolved.is_relative_to(bundle_root) or not resolved.is_file():
continue
except ValueError:
# An unrepresentable target (e.g. an embedded NUL byte, which carries no
# path separator and so slips past the out-of-bundle filter) makes
# ``resolve``/``is_file`` raise ``ValueError``. That is a broken link, not
# a fatal error — skip it, never raise (method-spec §72, OKF robustness).
continue
concepts.append(parse_concept_file(resolved))
concepts = [_parse_index_entry(index_path)]
seen = {index_path.resolve()}
_descend(index_path.resolve(), bundle_root, seen, concepts)
return concepts
def bundle_context(bundle_dir: Path) -> str:
"""Render the read-context: index body, then ``## {type}: {title}`` sections.
Empty sections are dropped. ``type: verdict`` files are excluded the verdict
layer must never leak into the read-context (§3 Step 1, load-bearing §11).
Rendering is FLAT regardless of nesting depth directory structure is
navigation, not presentation, so a nested concept renders as the same section a
root one would and there is no level heading. Only the ROOT index body is the
summary: a nested ``index.md`` is navigation, not content, and is not rendered.
Empty sections are dropped. ``type: verdict`` files are excluded by a TYPE CHECK
on each file as it is reached applied at every level, never a property of the
link graph, so a mislabelled or injected edge cannot smuggle a verdict into the
context (§3 Step 1, load-bearing §11).
"""
index, *concepts = navigate_bundle(bundle_dir)
sections = [index.body] if index.body else []
for concept in concepts:
if concept.type == _VERDICT_TYPE or not concept.body:
if concept.path.name == _INDEX_FILENAME or concept.type == _VERDICT_TYPE:
continue
if not concept.body:
continue
sections.append(f"## {concept.type}: {concept.title}\n\n{concept.body}")
return "\n\n".join(sections)