llm-ingestion-okf/src/llm_ingestion_okf/errors.py
Kjell Tore Guttormsen f10fc60de2 feat(import): Door C flow against an injected import gate (Phase 2 step 5)
Reads an external OKF bundle as {bundle-relative path -> document text},
hands it WHOLE to an injected gate over the guard's okf.import_bundle (a
bundle-level call: it resolves the cross-link graph across concepts), and
merges only concepts clearing the non-blocking floor. Same injection pattern
as Door B, so the core stays dependency-free while the CI channel for the
real guard is settled.

Two constraints shaped the design and are pinned by tests:

- A merged concept is written VERBATIM. Stamping provenance into it would
  require round-tripping its frontmatter through this library's line-oriented
  parser, which cannot represent the block lists the guard's parser accepts --
  silent data loss -- and would persist bytes the guard never screened.
- Ownership is therefore proven by content identity: identical bytes at the
  target name are a no-op re-merge (re-import of an unchanged bundle is
  idempotent), and anything else at the name is refused. Curated content and
  an updated concept are refused alike; refusing is what never destroys.

The floor is fail-closed beyond the plan's "no error" wording: an error, an
unrecognised disposition, and a concept the gate returned no verdict for are
all refusals. quarantine_review stays its own bucket, as at Door B.
origin/channel are validated against the guard's pinned vocabulary -- it
derives trust from origin by enum identity, so an unrecognised string would be
silently downgraded rather than caught.

Three primitives promoted for reuse rather than duplicated:
reduce_to_id_grammar and check_filename_length to materialize.py, and
extract.decode_text. Door C slugs the WHOLE concept path, so tables/users.md
and views/users.md stay distinct. Concept discovery folds case explicitly
rather than globbing *.md, whose case-sensitivity follows the filesystem and
would import the same bundle differently on APFS and ext4.

README's "what is gated today" section corrected: it claimed nothing is gated,
which is no longer true, but the honest statement is narrower than "the doors
are gated" -- the library cannot verify that an injected adapter is a real
guard, and a permissive stub is believed.

405 tests green; ruff, ruff format and mypy --strict clean.
2026-07-25 06:57:25 +02:00

137 lines
6.3 KiB
Python

"""Typed error hierarchy rooted in IngestError.
STABILITY CONTRACT: `IngestError.code` is the machine-readable API for
distinguishing sub-causes — consumers assert on it, never on message
wording. Codes listed in the class docstrings below are stable across
releases; message text is NOT stable and may improve freely.
"""
from __future__ import annotations
class IngestError(Exception):
"""Base class for every error raised by this library.
Carries a stable, machine-readable `code` naming the sub-cause (see the
subclass docstrings for the registry). Errors constructed without an
explicit code carry `"unspecified"`.
"""
def __init__(self, message: str, *, code: str = "unspecified") -> None:
super().__init__(message)
self.code = code
class ManifestError(IngestError):
"""The manifest failed fail-fast schema validation (ingest-spec §4).
Codes:
- `manifest_unreadable` — the manifest file cannot be read
- `manifest_invalid_json` — the bytes are not valid UTF-8 JSON
- `manifest_version_unsupported` — manifest_version is not the integer 1
- `manifest_schema` — a generic shape violation (wrong type, missing or
unknown field, empty string, bad identifier, multi-line title,
non-positive max_rows, empty extractions list)
- `source_type_unknown` — source.type is not 'file', 'sql', or 'http'
- `credential_embedded` — source.base_url embeds userinfo credentials
- `extraction_id_duplicate` — two extractions share an id
- `okf_type_reserved` — an extraction claims the reserved 'verdict' layer
"""
class RenderError(IngestError):
"""A value cannot be rendered under the §5 body rules (never silent coercion).
Codes:
- `unsupported_cell_type` — a SQL cell is not integer/float/text/NULL
"""
class SourceError(IngestError):
"""An extraction failed against its source (ingest-spec §4, §8).
Covers fail-closed path-boundary violations, missing/malformed source
content, and max_rows cap violations — always typed, never a leaked
OSError and never silent truncation.
Codes:
- `path_escape` — a path resolves outside its root directory, or cannot
be resolved at all (e.g. an embedded NUL byte)
- `source_root_missing` — the file-source root is not a directory
- `source_file_missing` — the query does not resolve to a file
- `csv_no_header` — the CSV has no header row
- `csv_ragged_row` — a CSV row's cell count differs from the header's
- `max_rows_exceeded` — an extraction exceeds max_rows (any source type)
- `connection_ref_unset` — the sql connection_ref env var is not set
- `database_missing` — the sql connection_ref points at a missing file
- `sql_no_columns` — the sql statement produced no result columns
- `sql_failed` — the sql statement failed at the database
- `credential_ref_unset` — the http credential_ref env var is not set
- `http_transport` — the http GET failed at transport or decode
- `fence_marker_in_body` — an http body line is a code-fence marker
"""
class ExtractionError(IngestError):
"""A dropped file could not be converted to text (Door B, Phase 2).
File-type -> text extraction is text-only plumbing; a corrupt file, an
unknown type, or a binary type without its optional parser is a typed
per-file failure — never a silent skip, never a leaked stdlib error, and
never a bundled parser in core.
Codes:
- `extractor_unknown` — no extractor is registered for the file extension
- `extractor_extra_missing` — a `[extract]`-gated binary type (pdf/docx/
xlsx) was given but the optional extra is not installed
- `extractor_decode_error` — a text-type file's bytes are not valid UTF-8
- `extractor_empty_csv` — a CSV has no header row
"""
class MaterializationError(IngestError):
"""Materialization refused or failed (ingest-spec §5).
Codes:
- `ingested_at_invalid` — ingested_at is not ISO-8601 UTC with a Z suffix
- `collision_unstamped` — the §3 collision gate: a generated name is
occupied by a file without the ingest stamp
- `inbox_slug_empty` — a dropped file's name reduces to an empty slug
under the id grammar (Door B; never an invented fallback name)
- `inbox_slug_too_long` — the generated inbox filename would exceed the
255-byte filesystem limit (Door B; never a truncated name, which would
be lossy and could collide with another long name sharing its prefix)
- `inbox_slug_collision` — two files dropped in the same run reduce to one
generated filename (Door B); both are refused rather than letting
iteration order decide which one survives
- `inbox_title_invalid` — an inbox title is multi-line or contains `[`/`]`,
either of which would break frontmatter or an index link
- `inbox_source_file_invalid` — an inbox `source_file` is multi-line and
would inject frontmatter lines
- `okf_type_reserved` — an inbox concept claims the reserved 'verdict'
layer (the same reservation ManifestError enforces at Door A)
- `import_path_empty` — an external concept path reduces to an empty slug
under the id grammar (Door C; never an invented fallback name)
- `import_path_too_long` — the generated import filename would exceed the
255-byte filesystem limit (Door C; never a truncated name)
- `import_slug_collision` — two concepts in one external bundle reduce to
one generated filename (Door C); both are refused rather than letting
iteration order decide which one survives
- `import_label_invalid` — an external concept path contains `[`/`]`, which
would break its index link (the guard's path gate permits them)
- `import_provenance_invalid` — an `origin`/`channel` outside the guard's
pinned vocabulary (Door C); refused rather than carried, because the
guard derives trust from `origin` by enum identity and an unrecognised
value would be silently downgraded
"""
class NetworkGateError(IngestError):
"""A network source was used without the per-run opt-in flag (spec §8).
Local-only default, no silent egress: the flag is a run argument — the
manifest cannot grant itself network access.
Codes:
- `network_opt_in_missing` — an http source without allow_network=True
"""