`OKF_V0_2` landed in D2 but was unreachable from outside: no door took a profile. This threads one through, keyword-only behind the `*` the signature already carried, so every three-positional call site stays source-compatible — which is what po-claude asked for, and what makes additivity a property of the signature rather than something a consumer measures. Nine sites, not the ~6 STATE claimed. The load-bearing one is the call at materialize.py:378: the CONTENT phase has accepted `profile` since D2, but the call site never passed one, so A-E3/A-E4/A-E5 were all unreachable. The other eight are the disk phase (ownership glob, index name, index maintenance, concept filenames) plus `generated_filename` in manifest.py. `link_in_index` is public and called from all three doors, so it gets `*, profile=DEFAULT` rather than having the lookup moved to the call site: doors B and C keep exactly the behaviour they had, and which profile THEY own stays an open question instead of being decided silently by a signature change. Byte-neutrality is proven, not asserted: `OKF_V0_2.paths is DEFAULT.paths` and `.index is DEFAULT.index`, and the golden suite is green. That identity is also why six of the nine sites cannot be proven reachable by any shipped-profile test — no assertion distinguishes two names for one object. A synthetic test-only profile renaming the index and the concept files closes that gap, so a site left on `DEFAULT` fails by name rather than passing quietly. Scope stated rather than glossed: the profile does NOT reach manifest type validation (`manifest.py:198` still reads `DEFAULT.types`; measured equal to `OKF_V0_2.types`, so nothing is hidden today), and `STRICT_V1` is not supported here — its index policy sets three judging fields the materializer does not honour. Both are named in the docstring. No `okf_version` anywhere: that lands once, at D5, when the §12 placement question closes. 550 tests pass (was 542). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tf2BbC8uSRVU4ApQ9NL7QR
266 lines
9.1 KiB
Python
266 lines
9.1 KiB
Python
"""Manifest parsing and fail-fast validation (ingest-spec §3, §4).
|
|
|
|
Validation is schema-only and runs BEFORE any source call: source existence,
|
|
credentials, and network access are connector concerns. Queries are
|
|
configuration, not code — nothing here evaluates manifest content.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import urllib.parse
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Union
|
|
|
|
from .errors import ManifestError
|
|
from .profiles import DEFAULT, BundleProfile
|
|
|
|
_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*\Z")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FileSource:
|
|
id: str
|
|
root: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SqlSource:
|
|
id: str
|
|
connection_ref: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HttpSource:
|
|
id: str
|
|
base_url: str
|
|
credential_ref: Union[str, None] = None
|
|
|
|
|
|
Source = Union[FileSource, SqlSource, HttpSource]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Extraction:
|
|
id: str
|
|
title: str
|
|
query: str
|
|
okf_type: str
|
|
max_rows: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Manifest:
|
|
manifest_version: int
|
|
source: Source
|
|
bundle_summary: str
|
|
extractions: tuple[Extraction, ...]
|
|
|
|
|
|
def generated_filename(extraction_id: str, *, profile: BundleProfile = DEFAULT) -> str:
|
|
"""The concept filename for an extraction (spec §5).
|
|
|
|
The `ingest-` prefix keeps the namespace disjoint from `index.md` and
|
|
`promoted-verdict-*` (spec §3) for every id the §4 grammar admits.
|
|
|
|
The prefix and suffix are the profile's, because the ownership scan globs
|
|
on the same two values: a name built from one profile and scanned for under
|
|
another is a file the library cannot recognise as its own.
|
|
"""
|
|
return f"{profile.paths.ingest_prefix}{extraction_id}{profile.paths.concept_suffix}"
|
|
|
|
|
|
def load_manifest(path: Path) -> Manifest:
|
|
"""Read and fail-fast validate a manifest file (spec §4).
|
|
|
|
Raises ManifestError on any schema violation, before any source call.
|
|
"""
|
|
try:
|
|
raw = path.read_bytes()
|
|
except OSError as exc:
|
|
raise ManifestError(
|
|
f"cannot read manifest {path}: {exc}", code="manifest_unreadable"
|
|
) from exc
|
|
return load_manifest_bytes(raw)
|
|
|
|
|
|
def load_manifest_bytes(raw: bytes) -> Manifest:
|
|
"""Fail-fast validate a manifest from its raw bytes (spec §4).
|
|
|
|
The bytes-level entry point exists so materialization can hash and parse
|
|
the SAME bytes — the §5 provenance stamp must reference exactly the
|
|
manifest version that produced the run.
|
|
"""
|
|
try:
|
|
data: object = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, ValueError) as exc:
|
|
raise ManifestError(
|
|
f"manifest is not valid UTF-8 JSON: {exc}", code="manifest_invalid_json"
|
|
) from exc
|
|
return _validate_manifest(data)
|
|
|
|
|
|
def _validate_manifest(data: object) -> Manifest:
|
|
obj = _require_object(data, "manifest")
|
|
_require_keys(obj, "manifest", {"manifest_version", "source", "bundle_summary", "extractions"})
|
|
|
|
version = obj["manifest_version"]
|
|
if not _is_int(version) or version != 1:
|
|
raise ManifestError(
|
|
f"manifest_version must be the integer 1, got {version!r}",
|
|
code="manifest_version_unsupported",
|
|
)
|
|
|
|
source = _validate_source(obj["source"])
|
|
bundle_summary = _require_str(obj["bundle_summary"], "bundle_summary", allow_empty=True)
|
|
extractions = _validate_extractions(obj["extractions"])
|
|
|
|
return Manifest(
|
|
manifest_version=1,
|
|
source=source,
|
|
bundle_summary=bundle_summary,
|
|
extractions=extractions,
|
|
)
|
|
|
|
|
|
def _validate_source(data: object) -> Source:
|
|
obj = _require_object(data, "source")
|
|
source_type = obj.get("type")
|
|
if source_type == "file":
|
|
_require_keys(obj, "source", {"type", "id", "root"})
|
|
return FileSource(
|
|
id=_validate_id(obj["id"], "source.id"), root=_require_str(obj["root"], "source.root")
|
|
)
|
|
if source_type == "sql":
|
|
_require_keys(obj, "source", {"type", "id", "connection_ref"})
|
|
return SqlSource(
|
|
id=_validate_id(obj["id"], "source.id"),
|
|
connection_ref=_require_str(obj["connection_ref"], "source.connection_ref"),
|
|
)
|
|
if source_type == "http":
|
|
_require_keys(obj, "source", {"type", "id", "base_url"}, optional={"credential_ref"})
|
|
credential_ref = obj.get("credential_ref")
|
|
if credential_ref is not None:
|
|
credential_ref = _require_str(credential_ref, "source.credential_ref")
|
|
return HttpSource(
|
|
id=_validate_id(obj["id"], "source.id"),
|
|
base_url=_validate_base_url(obj["base_url"]),
|
|
credential_ref=credential_ref,
|
|
)
|
|
raise ManifestError(
|
|
f"source.type must be one of 'file', 'sql', 'http', got {source_type!r}",
|
|
code="source_type_unknown",
|
|
)
|
|
|
|
|
|
def _validate_base_url(value: object) -> str:
|
|
base_url = _require_str(value, "source.base_url")
|
|
# Credentials never live in the manifest (spec §4): no userinfo in the URL.
|
|
if urllib.parse.urlsplit(base_url).username is not None:
|
|
raise ManifestError(
|
|
"source.base_url must not embed credentials; use credential_ref",
|
|
code="credential_embedded",
|
|
)
|
|
return base_url
|
|
|
|
|
|
def _validate_extractions(data: object) -> tuple[Extraction, ...]:
|
|
if not isinstance(data, list) or not data:
|
|
raise ManifestError("extractions must be a non-empty list", code="manifest_schema")
|
|
extractions = tuple(_validate_extraction(entry, index) for index, entry in enumerate(data))
|
|
seen: set[str] = set()
|
|
for extraction in extractions:
|
|
if extraction.id in seen:
|
|
raise ManifestError(
|
|
f"duplicate extraction id {extraction.id!r}", code="extraction_id_duplicate"
|
|
)
|
|
seen.add(extraction.id)
|
|
return extractions
|
|
|
|
|
|
def _validate_extraction(data: object, index: int) -> Extraction:
|
|
label = f"extractions[{index}]"
|
|
obj = _require_object(data, label)
|
|
_require_keys(obj, label, {"id", "title", "query", "okf_type", "max_rows"})
|
|
|
|
title = _require_str(obj["title"], f"{label}.title")
|
|
if "\n" in title or "\r" in title:
|
|
raise ManifestError(f"{label}.title must be single-line", code="manifest_schema")
|
|
# `[`/`]` would break index-link and navigation parsing (ingest spec §4); the
|
|
# title is rendered verbatim into the index link and frontmatter (§5/§6), so the
|
|
# invariant is met by fail-fast validation here, never by downstream repair.
|
|
if "[" in title or "]" in title:
|
|
raise ManifestError(f"{label}.title must not contain '[' or ']'", code="manifest_schema")
|
|
|
|
okf_type = _require_str(obj["okf_type"], f"{label}.okf_type")
|
|
# The verdict layer is RESERVED (spec §3): the promotion gate is the only
|
|
# path into it — enforced here, fail-fast, before any source call. The
|
|
# profile decides which types a bundle admits; the door raises its own
|
|
# typed error, since Door B refuses the same type as a MaterializationError.
|
|
rejection = DEFAULT.types.rejection(okf_type)
|
|
if rejection is not None:
|
|
raise ManifestError(f"{label}.okf_type {rejection.reason}", code=rejection.code)
|
|
|
|
max_rows = obj["max_rows"]
|
|
if not _is_int(max_rows) or max_rows < 1:
|
|
raise ManifestError(
|
|
f"{label}.max_rows must be a positive integer, got {max_rows!r}",
|
|
code="manifest_schema",
|
|
)
|
|
|
|
return Extraction(
|
|
id=_validate_id(obj["id"], f"{label}.id"),
|
|
title=title,
|
|
query=_require_str(obj["query"], f"{label}.query"),
|
|
okf_type=okf_type,
|
|
max_rows=max_rows,
|
|
)
|
|
|
|
|
|
def _validate_id(value: object, label: str) -> str:
|
|
identifier = _require_str(value, label)
|
|
if not _ID_PATTERN.match(identifier):
|
|
raise ManifestError(
|
|
f"{label} must match [a-z0-9][a-z0-9-]*, got {identifier!r}", code="manifest_schema"
|
|
)
|
|
return identifier
|
|
|
|
|
|
def _require_object(data: object, label: str) -> dict[str, Any]:
|
|
if not isinstance(data, dict):
|
|
raise ManifestError(
|
|
f"{label} must be a JSON object, got {type(data).__name__}", code="manifest_schema"
|
|
)
|
|
return data
|
|
|
|
|
|
def _require_keys(
|
|
obj: dict[str, Any], label: str, required: set[str], optional: set[str] | None = None
|
|
) -> None:
|
|
missing = required - obj.keys()
|
|
if missing:
|
|
raise ManifestError(
|
|
f"{label} is missing required field(s): {', '.join(sorted(missing))}",
|
|
code="manifest_schema",
|
|
)
|
|
unknown = obj.keys() - required - (optional or set())
|
|
if unknown:
|
|
raise ManifestError(
|
|
f"{label} has unknown field(s): {', '.join(sorted(unknown))}", code="manifest_schema"
|
|
)
|
|
|
|
|
|
def _require_str(value: object, label: str, *, allow_empty: bool = False) -> str:
|
|
if not isinstance(value, str):
|
|
raise ManifestError(
|
|
f"{label} must be a string, got {type(value).__name__}", code="manifest_schema"
|
|
)
|
|
if not value and not allow_empty:
|
|
raise ManifestError(f"{label} must be a non-empty string", code="manifest_schema")
|
|
return value
|
|
|
|
|
|
def _is_int(value: object) -> bool:
|
|
return isinstance(value, int) and not isinstance(value, bool)
|