"""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/pptx/odt/rtf) 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 - `extractor_empty_pdf` — a PDF yielded no text on any page (a scanned or image-only document); refused rather than persisted as an empty concept, which would be the silent skip this registry exists to prevent - `extractor_pdf_error` — the PDF parser failed on the file's bytes; the third-party exception is wrapped, never leaked - `extractor_binary_missing` — the converter binary is absent; distinct from the extra not being installed, because the wheel can be present while the binary it should carry is not - `extractor_binary_version` — the converter binary is present but is not the pinned version; refused rather than used, because extraction is deterministic only within one converter version and a byte-pinned fixture cannot tell "different version" from "defect" - `extractor_convert_error` — the converter failed on this file's bytes; the third-party failure is wrapped, never leaked - `extractor_empty_conversion` — the converter returned no text; refused rather than persisted as an empty concept, for the same reason as `extractor_empty_pdf` - `extractor_ocr_group_missing` — a PDF page was to be read with OCR but the optional `ocr` group is not installed. DISTINCT from `extractor_extra_missing`: the `[extract]` extra can be fully installed and the document parsed, with only the OCR engine absent, and one code for both would send an operator to reinstall what they already have ASSET codes (0.10.0). None of these fails a document: an image a reader could not carry is a ROW in the run log and a line in the concept saying what was there, because one unreadable picture must not cost the three thousand concepts of text around it. - `asset_type_unknown` — the bytes behind a pointer are not an image format this package recognises. Sniffed from the bytes, never from the claimed extension: a `.jpg` that is really a PNG would otherwise be written under a name whose extension lies - `asset_unresolved` — the file a document points at was not found beside it, or an inline data URI could not be decoded. Containment is the document's own directory, so a reference above it lands here rather than being followed - `asset_remote` — the source is off this machine. Extraction opens no socket: network access is an explicit per-run opt-in and extraction is not on that path, so a remote image is carried as a pointer and never as bytes - `asset_pdf_unsupported` — a PDF image whose samples this encoder does not express: a stencil mask, a `Decode` array, a colour space with no exact PNG form, a soft mask that cannot be carried, or anything but 8-bit samples. Refused rather than approximated, because a picture that is plausibly the wrong colour is wrong in a way no consumer can detect - `asset_samples_invalid` — the sample buffer does not fit the dimensions the image dictionary declares. Refused rather than padded: a short buffer means the dictionary was read wrong - `asset_too_large` — the picture is over this package's bound: because it DECLARES a size beyond `MAX_IMAGE_PIXELS`, because the file itself is that large, because the stream behind it DECOMPRESSES to more than `MAX_IMAGE_BYTES`, or because one link of its filter chain would COST more than `MAX_FILTER_DECODE_BYTES` to decode. The four are one code because they are one decision — this run will not hold that picture — and because a consumer counting refusals wants the picture, not the mechanism. Each bound is read off the corpora and sits an order of magnitude above anything measured - `asset_size_invalid` — the container declares a size that is not a size: a zero or negative `/Width` or `/Height`. DISTINCT from `asset_too_large`, because the two say different things about the document — one is a legitimate publisher shipping a picture bigger than this package carries, the other is a dictionary written wrong or written to be read wrong — and counting them together would make a corpus statistic about the first untrue. Refused before the stream is read: a negative dimension multiplies to a negative pixel count, under which every bound reads as satisfied - `asset_not_viewable` — the bytes are a real image in a format no model can be SHOWN (TIFF, JPEG 2000), and this package has no lossless conversion for it. DISTINCT from `asset_type_unknown`, which says the bytes are not an image at all: this one says they are, and carrying them would put a file in the bundle that the `images: N` count reports as arrived and nothing downstream can read. Measured 2026-09-19 on the frozen R761 delivery: 19 of its 50 assets were carried in exactly that condition, as RLE8 BMP - `asset_bmp_unsupported` — a BMP variant this reader does not express (RLE4, BITFIELDS, 16- or 32-bit samples, a 12-byte BITMAPCOREHEADER, a palette over 256 entries). DISTINCT from `asset_not_viewable`, which says there is no conversion route for the format at all: this one says there is one and this file is outside it, which is a different fact about the document and a different thing to go and fix - `asset_pdf_unbounded` — the image is reached through a PDF stream filter this package has no measured cost ratio for (`LZWDecode`, `RunLengthDecode`, `CCITTFaxDecode`, `/Crypt`, anything unknown), or through an encrypted stream it cannot decipher. DISTINCT from `asset_too_large`, which says a measurement was taken or predicted and came out over the bound: this one says neither was possible, so the picture is refused UNREAD rather than decoded to find out what it costs. Measured 2026-09-18: bounding only the first link of a filter chain let 1 636 bytes of PDF cost 886 554 624 bytes of peak RSS, and bounding every link's OUTPUT still let 33 475 bytes cost 3 261 599 744 through a filter whose decoder holds a hundred bytes per byte of input """ class ExtractionWarning(UserWarning): """Extraction succeeded, but the output is lossy in a way worth stating. Text extraction recovers text. Anything a PDF *draws* — figures, diagrams, images — has no text to recover, so a bundle built from drawn documents is incomplete by construction. That is categorically true rather than document-specific, so it is warned about rather than detected: deciding "is there a figure on this page" is a layout heuristic this library does not own. A named class so a consumer can filter it deliberately. """ 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 - `asset_collision` — two different images reduce to one asset name in one run, or a name in `assets/` is occupied by different bytes (Doors B and C, 0.10.0). An asset name carries the digest of its own contents, so this is a `sha256[:12]` collision; refused rather than resolved, because resolving it silently means one of two pictures is lost and every pointer to it shows the other - `source_reference_unquotable` — a manifest source's id or locator contains a character that would restructure the `sources` flow mapping (Door A, v0.2 profiles); refused rather than emitted, because the resulting document parses cleanly into a record no one wrote - `sources_empty` — a `sources` list with no entries; `sources: []` reads as a measured absence when it is the absence of a measurement - `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 - `unknown_renderer` — a profile names a per-suffix renderer that is not registered; refused rather than falling back to identity, which would produce a bundle that looks rendered and is not - `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 SegmentationError(IngestError): """A segmentation plan is unusable, or does not apply (Door B, 1-to-N). A concept is "a single unit of knowledge within a bundle" (OKF v0.2 §2), not a file someone dropped, so splitting one document into several is a judgement. A judgement cannot be made on a run path that promises zero model calls, so it is made once, written down as data, adjudicated by a human, and replayed deterministically. Every failure here is that replay refusing to guess: a plan that no longer matches its extraction is never silently re-derived, because the offsets it carries would then point into text no one adjudicated. Codes: - `segmentation_plan_invalid` — the plan's shape is wrong: a missing or wrongly-typed field, an empty entry list, or a `parent_id` naming no entry in the same plan - `segmentation_path_invalid` — an entry's path is not a bundle-relative `/`-separated path (absolute, empty, or containing `.`/`..`), a component reduces to nothing under the id grammar, or two entries claim one path after normalisation - `segmentation_span_invalid` — a span is not a half-open pair of non-negative offsets with `start < end`, or it reaches past the end of the canonical extracted text - `segmentation_duplicate_id` — two entries share a `segment_id` - `segmentation_extractor_mismatch` — the plan was adjudicated against a different extraction. Source bytes cannot see an extractor swap or a version bump, so the hash alone would still match while every stored offset had silently moved - `segmentation_unsupported_profile` — a plan was passed to a profile that does not declare the segmentation capability - `segmentation_plan_unmatched` — the plan is well-formed but its `source_sha256` matches no dropped file, so nothing would be segmented and the run would report an ordinary success over a flat bundle. A mistyped hash is the likely cause and it is unreadable from the result; refusing is the only way the operator learns that the judgement they adjudicated was never replayed """ 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 """