safe_resolve let an untyped ValueError escape when a target carried an
embedded NUL: os.path.realpath rejects the string before the boundary
check runs. That broke SourceError's documented contract ("always typed,
never a leaked OSError") and the v0.3.0 promise of stable machine-readable
codes on every failure.
A target that cannot be resolved at all now fails closed under the
existing `path_escape` code, with its own message. No new code is added —
`path_escape` already means "the boundary refused this target"; its
docstring is widened to say so. Consumers asserting on the code are
unaffected.
Found by consumer review: portfolio-optimiser's navigate_bundle leaks the
same ValueError from the same class of input, which makes an entire bundle
unreadable instead of skipping one link. That defect is theirs to fix; this
commit closes the library-side half.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdVgowYC4LARgvNdNMiuvz
Consumer tests were binding message wording (pytest.raises(match=...)) to
distinguish sub-causes within one error type. Every raise site now carries
a documented, stable code attribute; the registry lives in the errors.py
class docstrings. Codes are stable API; message text is explicitly not.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TDD step 7: read_http executes the optional http extension point behind
an injectable transport seam (urllib_get is the only socket path; the
suite runs socket-free against a mock). credential_ref resolves from the
environment at run time, fail-fast before any transport call; the secret
rides in the Authorization header only. Explicit single-slash URL join,
CRLF-to-LF normalization, LF line-count cap (missing trailing newline
still counts), and code-fence-marker rejection. materialize_bundle
renders the body as a verbatim fenced block; the §8 gate test asserts
zero transport calls without the opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
TDD step 5: materialize_bundle takes three explicit inputs (manifest,
bundle dir, regex-validated ingested_at — no wall-clock default) and
stages every extraction in memory before the first disk mutation. The
§3 collision gate refuses to overwrite any file without the ingest
stamp, before any mutation; replacement removes exactly the stamped
set. Frontmatter is the seven §5 keys in spec order with whitespace
collapse; output is LF-only raw bytes with one trailing newline; the
provenance stamp hashes the same bytes that are parsed. Fresh index.md
gets bundle_summary plus idempotent links in extraction order (§6
creation path; preserve/removal rules come next). The §8 network gate
refuses http fail-fast without the per-run opt-in; the http transport
itself lands in step 7.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
TDD step 4: read_sql executes one read-only SELECT against the sqlite
database whose path is resolved at run time from the env var named by
connection_ref (credentials never in the manifest). Read-only enforced
by the connection mode (file:...?mode=ro), single statement via
Connection.execute, §5 cell text conversion, streaming max_rows cap,
and every sqlite failure wrapped in SourceError.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
TDD step 3: read_csv executes a file-source extraction — boundary-checked
path resolution via commonpath on canonical paths (rejects .. traversal,
absolute paths, symlink escapes, and prefix-collision siblings), utf-8-sig
decoding, streaming max_rows cap as a typed error (never silent
truncation), ragged-row rejection, and verbatim cells (escaping stays in
the §5 renderers). New SourceError in the typed hierarchy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg