From c31e083aabcd5b2dc7b25de94fe27afbbad9b0d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:42:27 -0700 Subject: [PATCH 01/44] test(calendar): define clean Naruon projection contract --- tests/test_naruon_calendar_projection.py | 327 +++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 tests/test_naruon_calendar_projection.py diff --git a/tests/test_naruon_calendar_projection.py b/tests/test_naruon_calendar_projection.py new file mode 100644 index 000000000..2774570ac --- /dev/null +++ b/tests/test_naruon_calendar_projection.py @@ -0,0 +1,327 @@ +"""Contract tests for the Naruon-owned calendar read projection.""" + +from __future__ import annotations + +import json +from copy import deepcopy +from dataclasses import fields +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import pytest + +from lineageweave.naruon_calendar_projection import ( + NARUON_CALENDAR_MEDIA_TYPE, + NaruonCalendarContractError, + NaruonCalendarOccurrence, + NaruonCalendarProjectionClient, + parse_naruon_calendar_page, +) + + +def _event(**overrides: object) -> dict[str, object]: + event: dict[str, object] = { + "event_reference": "evt_001", + "occurrence_reference": "occ_001", + "source_reference": "src_001", + "provider_revision": 'W/"revision-7"', + "display_text": "Customer review", + "starts_at": "2026-08-24T09:00:00+09:00", + "ends_at": "2026-08-24T10:00:00+09:00", + "all_day": False, + "time_zone": "Asia/Seoul", + "status_code": "confirmed", + "disclosure_code": "summary_visible", + "truth_status_code": "observed", + "observed_at": "2026-08-21T00:00:00Z", + } + event.update(overrides) + return event + + +def _page(*events: dict[str, object], **overrides: object) -> dict[str, object]: + page: dict[str, object] = { + "schema_version": "1.0", + "projection_revision": "projection_001", + "events": list(events or (_event(),)), + "next_cursor": "cursor_002", + } + page.update(overrides) + return page + + +def test_client_sends_only_service_credential_and_parses_projection(monkeypatch) -> None: + received: dict[str, object] = {} + + def fake_get_json( + url: str, + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + received.update(url=url, headers=headers, timeout=timeout) + return _page() + + monkeypatch.setattr( + "lineageweave.naruon_calendar_projection.get_json", + fake_get_json, + ) + client = NaruonCalendarProjectionClient( + "https://naruon.example/tenant-projection/", + "service-secret", + maximum_events=25, + timeout=7, + ) + + page = client.list_events( + "2026-08-01T00:00:00Z", + "2026-09-01T00:00:00Z", + cursor="cursor_001", + ) + + parsed_url = urlparse(str(received["url"])) + assert parsed_url.scheme == "https" + assert parsed_url.netloc == "naruon.example" + assert parsed_url.path == "/tenant-projection/api/calendar/events" + assert parse_qs(parsed_url.query) == { + "window_start": ["2026-08-01T00:00:00Z"], + "window_end": ["2026-09-01T00:00:00Z"], + "limit": ["25"], + "cursor": ["cursor_001"], + } + assert received["headers"] == { + "authorization": "Bearer service-secret", + "accept": NARUON_CALENDAR_MEDIA_TYPE, + } + assert received["timeout"] == 7 + assert page.projection_revision == "projection_001" + assert page.next_cursor == "cursor_002" + assert page.events[0].occurrence_reference == "occ_001" + assert page.events[0].truth_status_code == "observed" + + +@pytest.mark.parametrize( + "base_url", + [ + "file:///tmp/calendar", + "https://user:secret@naruon.example", + "https://naruon.example?token=secret", + "https://naruon.example#events", + ], +) +def test_client_rejects_unsafe_base_urls(base_url: str) -> None: + with pytest.raises(ValueError): + NaruonCalendarProjectionClient(base_url, "service-secret") + + +@pytest.mark.parametrize( + "token", + ["", " ", "secret\nsecond-line", "service secret", "service\tsecret"], +) +def test_client_rejects_missing_control_or_whitespace_tokens(token: str) -> None: + with pytest.raises(NaruonCalendarContractError): + NaruonCalendarProjectionClient("https://naruon.example", token) + + +@pytest.mark.parametrize("maximum_events", [0, 201]) +def test_client_rejects_invalid_page_bounds(maximum_events: int) -> None: + with pytest.raises(ValueError, match="maximum_events"): + NaruonCalendarProjectionClient( + "https://naruon.example", + "service-secret", + maximum_events=maximum_events, + ) + + +@pytest.mark.parametrize("timeout", [0, 31]) +def test_client_rejects_invalid_timeouts(timeout: float) -> None: + with pytest.raises(ValueError, match="timeout"): + NaruonCalendarProjectionClient( + "https://naruon.example", + "service-secret", + timeout=timeout, + ) + + +@pytest.mark.parametrize( + ("window_start", "window_end", "message"), + [ + ("2026-08-01T00:00:00", "2026-08-02T00:00:00Z", "RFC 3339"), + ("2026-08-02T00:00:00Z", "2026-08-01T00:00:00Z", "after"), + ("2026-01-01T00:00:00Z", "2027-01-03T00:00:00Z", "366"), + ], +) +def test_client_rejects_unsafe_windows( + window_start: str, + window_end: str, + message: str, +) -> None: + client = NaruonCalendarProjectionClient( + "https://naruon.example", + "service-secret", + ) + with pytest.raises((ValueError, NaruonCalendarContractError), match=message): + client.list_events(window_start, window_end) + + +def test_client_rejects_url_shaped_cursor_before_transport(monkeypatch) -> None: + monkeypatch.setattr( + "lineageweave.naruon_calendar_projection.get_json", + lambda *args, **kwargs: pytest.fail("transport must not run"), + ) + client = NaruonCalendarProjectionClient( + "https://naruon.example", + "service-secret", + ) + with pytest.raises(NaruonCalendarContractError, match="URL"): + client.list_events( + "2026-08-01T00:00:00Z", + "2026-08-02T00:00:00Z", + cursor="https://provider.example/private", + ) + + +def test_parser_preserves_busy_only_policy_filtered_text() -> None: + page = parse_naruon_calendar_page( + _page(_event(display_text="Busy", disclosure_code="busy_only")) + ) + + assert page.events[0].display_text == "Busy" + assert page.events[0].disclosure_code == "busy_only" + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("starts_at", "2026-08-24T09:00:00", "RFC 3339"), + ("starts_at", "2026-08-24 09:00:00+09:00", "RFC 3339"), + ("starts_at", "2026-08-24T09:00:00+0900", "RFC 3339"), + ("ends_at", "2026-08-24T08:00:00+09:00", "after starts_at"), + ("observed_at", "not-a-time", "RFC 3339"), + ("status_code", "unknown", "unsupported"), + ("disclosure_code", "full_private_body", "unsupported"), + ("truth_status_code", "authoritative", "unsupported"), + ("event_reference", "https://provider.example/event/1", "URL"), + ], +) +def test_parser_rejects_invalid_occurrence_fields( + field: str, + value: object, + message: str, +) -> None: + with pytest.raises(NaruonCalendarContractError, match=message): + parse_naruon_calendar_page(_page(_event(**{field: value}))) + + +def test_parser_rejects_non_boolean_all_day() -> None: + with pytest.raises(NaruonCalendarContractError, match="boolean"): + parse_naruon_calendar_page(_page(_event(all_day="false"))) + + +def test_parser_rejects_duplicate_occurrences() -> None: + with pytest.raises(NaruonCalendarContractError, match="duplicate"): + parse_naruon_calendar_page(_page(_event(), deepcopy(_event()))) + + +def test_parser_rejects_unknown_fields() -> None: + with pytest.raises(NaruonCalendarContractError, match="unexpected"): + parse_naruon_calendar_page( + _page(_event(provider_url="https://provider.example")) + ) + + +def test_parser_rejects_unsupported_schema_and_invalid_roots() -> None: + with pytest.raises(NaruonCalendarContractError, match="unsupported"): + parse_naruon_calendar_page(_page(schema_version="2.0")) + with pytest.raises(NaruonCalendarContractError, match="object"): + parse_naruon_calendar_page([]) + with pytest.raises(NaruonCalendarContractError, match="array"): + parse_naruon_calendar_page(_page(events={})) + + +def test_parser_rejects_over_limit_pages_and_invalid_parser_bounds() -> None: + events = [_event(occurrence_reference=f"occ_{index}") for index in range(3)] + with pytest.raises(NaruonCalendarContractError, match="page size"): + parse_naruon_calendar_page(_page(*events), maximum_events=2) + with pytest.raises(ValueError, match="maximum_events"): + parse_naruon_calendar_page(_page(), maximum_events=0) + + +def test_parser_allows_terminal_page_without_cursor() -> None: + page = _page() + page.pop("next_cursor") + + parsed = parse_naruon_calendar_page(page) + + assert parsed.next_cursor is None + + +def test_parser_rejects_missing_required_fields_and_non_string_text() -> None: + missing = _event() + missing.pop("source_reference") + with pytest.raises(NaruonCalendarContractError, match="missing required"): + parse_naruon_calendar_page(_page(missing)) + with pytest.raises(NaruonCalendarContractError, match="must be a string"): + parse_naruon_calendar_page(_page(_event(display_text=123))) + + +def test_parser_rejects_whitespace_in_opaque_references() -> None: + with pytest.raises(NaruonCalendarContractError, match="opaque token"): + parse_naruon_calendar_page(_page(_event(source_reference="source private"))) + + +def test_client_omits_cursor_when_not_requested(monkeypatch) -> None: + received: dict[str, str] = {} + + def fake_get_json( + url: str, + *, + headers: dict[str, str], + timeout: float, + ) -> dict[str, object]: + del headers, timeout + received["url"] = url + return _page() + + monkeypatch.setattr( + "lineageweave.naruon_calendar_projection.get_json", + fake_get_json, + ) + client = NaruonCalendarProjectionClient( + "https://naruon.example", + "service-secret", + ) + + client.list_events("2026-08-01T00:00:00Z", "2026-08-02T00:00:00Z") + + assert "cursor" not in parse_qs(urlparse(received["url"]).query) + + +def test_json_schema_matches_parser_contract() -> None: + schema_path = ( + Path(__file__).resolve().parents[1] + / "docs" + / "contracts" + / "naruon-calendar-projection-v1.schema.json" + ) + schema = json.loads(schema_path.read_text(encoding="utf-8")) + occurrence_schema = schema["$defs"]["calendar_occurrence"] + + assert schema["properties"]["schema_version"]["const"] == "1.0" + assert schema["properties"]["events"]["maxItems"] == 200 + assert set(occurrence_schema["required"]) == { + field.name for field in fields(NaruonCalendarOccurrence) + } + assert occurrence_schema["properties"]["truth_status_code"]["const"] == ( + "observed" + ) + + +def test_public_package_exports_calendar_projection_contract() -> None: + import lineageweave + + assert lineageweave.NARUON_CALENDAR_SCHEMA_VERSION == "1.0" + assert lineageweave.NaruonCalendarProjectionClient is ( + NaruonCalendarProjectionClient + ) + assert lineageweave.parse_naruon_calendar_page is parse_naruon_calendar_page From 924e8689446890353802c2d60320374b67652a93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:45:34 -0700 Subject: [PATCH 02/44] test(http): require bounded JSON response reads --- tests/test_http_client.py | 79 +++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 452eeb781..38520b9a3 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -7,7 +7,13 @@ import pytest -from lineageweave.http_client import HttpClientError, get_json, get_json_list, post_form, post_json +from lineageweave.http_client import ( + HttpClientError, + get_json, + get_json_list, + post_form, + post_json, +) class _JsonHandler(BaseHTTPRequestHandler): @@ -65,6 +71,22 @@ def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signa return +class _LargeJsonHandler(BaseHTTPRequestHandler): + include_length = True + + def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API + body = json.dumps({"payload": "x" * 512}).encode("utf-8") + self.send_response(200) + self.send_header("content-type", "application/json") + if type(self).include_length: + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signature + return + + def _serve(handler: type[BaseHTTPRequestHandler]) -> tuple[HTTPServer, str]: server = HTTPServer(("127.0.0.1", 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) @@ -96,7 +118,10 @@ def test_post_json_posts_json_to_http_endpoint() -> None: finally: server.shutdown() - assert body == {"ok": True, "echo": {"model": "demo", "input": "hello"}} + assert body == { + "ok": True, + "echo": {"model": "demo", "input": "hello"}, + } assert _JsonHandler.received["path"] == "/v1/embeddings" assert _JsonHandler.received["authorization"] == "Bearer test-token" @@ -113,10 +138,39 @@ def test_get_json_fetches_json_from_http_endpoint() -> None: finally: server.shutdown() - assert body == {"ok": True, "path": "/.well-known/openid-configuration"} + assert body == { + "ok": True, + "path": "/.well-known/openid-configuration", + } assert _JsonHandler.received["authorization"] == "Bearer test-token" +@pytest.mark.parametrize("include_length", [True, False]) +def test_get_json_rejects_responses_over_explicit_byte_limit( + include_length: bool, +) -> None: + _LargeJsonHandler.include_length = include_length + server, base = _serve(_LargeJsonHandler) + try: + with pytest.raises(HttpClientError, match="response exceeds"): + get_json( + f"{base}/large", + timeout=2.0, + maximum_response_bytes=128, + ) + finally: + server.shutdown() + + +def test_get_json_rejects_invalid_response_byte_limit() -> None: + with pytest.raises(ValueError, match="maximum_response_bytes"): + get_json( + "https://gateway.example/health", + timeout=1.0, + maximum_response_bytes=0, + ) + + def test_post_form_posts_urlencoded_fields() -> None: _JsonHandler.received = {} server, base = _serve(_JsonHandler) @@ -131,7 +185,9 @@ def test_post_form_posts_urlencoded_fields() -> None: assert body["ok"] is True assert "grant_type=password" in _JsonHandler.received["payload"] - assert _JsonHandler.received["content_type"] == "application/x-www-form-urlencoded" + assert _JsonHandler.received["content_type"] == ( + "application/x-www-form-urlencoded" + ) def test_get_json_refuses_file_scheme() -> None: @@ -172,8 +228,12 @@ def test_dockerfiles_pin_digest_and_declare_non_root_user() -> None: "frontend/Dockerfile", ): text = (root / relative).read_text() - assert "@sha256:" in text, f"{relative} must pin the base image by digest" - assert "USER " in text, f"{relative} must declare a non-root USER" + assert "@sha256:" in text, ( + f"{relative} must pin the base image by digest" + ) + assert "USER " in text, ( + f"{relative} must declare a non-root USER" + ) def test_post_json_https_negotiates_tls_instead_of_plaintext() -> None: @@ -181,7 +241,12 @@ def test_post_json_https_negotiates_tls_instead_of_plaintext() -> None: try: https_url = base.replace("http://", "https://", 1) + "/v1/embeddings" with pytest.raises(ssl.SSLError): - post_json(https_url, {"model": "demo"}, headers={}, timeout=2.0) + post_json( + https_url, + {"model": "demo"}, + headers={}, + timeout=2.0, + ) finally: server.shutdown() From 5282dc68901fe6924d4ad038cf36489eb10e3872 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:47:11 -0700 Subject: [PATCH 03/44] fix(http): bound admitted JSON response bodies --- lineageweave/http_client.py | 124 +++++++++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 17 deletions(-) diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py index 389b29f3e..2b7a34872 100644 --- a/lineageweave/http_client.py +++ b/lineageweave/http_client.py @@ -31,6 +31,49 @@ class HttpClientError(RuntimeError): """The remote endpoint returned a non-success status or invalid JSON.""" +def _validated_response_limit(value: int | None) -> int | None: + """Return a positive byte limit or reject ambiguous numeric values.""" + + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError("maximum_response_bytes must be a positive integer") + return value + + +def _read_response_body( + response: http.client.HTTPResponse, + *, + maximum_response_bytes: int | None, +) -> bytes: + """Read one response without allocating beyond an admitted byte limit.""" + + limit = _validated_response_limit(maximum_response_bytes) + length_header = response.getheader("Content-Length") + if length_header is not None: + try: + declared_length = int(length_header) + except ValueError as exc: + raise HttpClientError("invalid Content-Length from remote endpoint") from exc + if declared_length < 0: + raise HttpClientError("invalid Content-Length from remote endpoint") + if limit is not None and declared_length > limit: + raise HttpClientError( + f"response exceeds maximum_response_bytes={limit}" + ) + raw = response.read(declared_length) + elif limit is None: + raw = response.read() + else: + raw = response.read(limit + 1) + + if limit is not None and len(raw) > limit: + raise HttpClientError( + f"response exceeds maximum_response_bytes={limit}" + ) + return raw + + def _request( method: str, url: str, @@ -38,11 +81,16 @@ def _request( body: bytes | None, headers: dict[str, str], timeout: float, + maximum_response_bytes: int | None = None, ) -> tuple[int, bytes]: - """Implement the _request operation for this channel.""" + """Perform one bounded HTTP(S) request and return status plus raw bytes.""" + + limit = _validated_response_limit(maximum_response_bytes) parsed = urlparse(url) if parsed.scheme not in _ALLOWED_SCHEMES: - raise ValueError(f"refusing non-http(s) URL scheme: {parsed.scheme!r}") + raise ValueError( + f"refusing non-http(s) URL scheme: {parsed.scheme!r}" + ) if not parsed.hostname: raise ValueError("URL is missing a hostname") @@ -56,27 +104,37 @@ def _request( # defaults, which this project (requires-python >= 3.10) never hits. default_port = 443 if parsed.scheme == "https" else 80 port = parsed.port if parsed.port is not None else default_port - connection = http.client.HTTPConnection(parsed.hostname, port, timeout=timeout) + connection = http.client.HTTPConnection( + parsed.hostname, + port, + timeout=timeout, + ) try: if parsed.scheme == "https": connection.connect() if connection.sock is None: - raise HttpClientError(f"no socket after connect to {parsed.hostname}") + raise HttpClientError( + f"no socket after connect to {parsed.hostname}" + ) connection.sock = _SSL_CONTEXT.wrap_socket( - connection.sock, server_hostname=parsed.hostname + connection.sock, + server_hostname=parsed.hostname, ) connection.request(method, path, body=body, headers=headers) response = connection.getresponse() - length_header = response.getheader("Content-Length") - raw = response.read(int(length_header)) if length_header is not None else response.read() + raw = _read_response_body( + response, + maximum_response_bytes=limit, + ) return response.status, raw finally: connection.close() def _decode_json(raw: bytes, hostname: str) -> object: - """Implement the _decode_json operation for this channel.""" + """Decode UTF-8 JSON without exposing response content in errors.""" + try: return json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: @@ -84,7 +142,8 @@ def _decode_json(raw: bytes, hostname: str) -> object: def _decode_json_object(raw: bytes, hostname: str) -> dict: - """Implement the _decode_json_object operation for this channel.""" + """Decode one JSON object and reject arrays or scalar payloads.""" + decoded = _decode_json(raw, hostname) if not isinstance(decoded, dict): raise HttpClientError(f"JSON object expected from {hostname}") @@ -92,7 +151,8 @@ def _decode_json_object(raw: bytes, hostname: str) -> dict: def _decode_json_list(raw: bytes, hostname: str) -> list: - """Implement the _decode_json_list operation for this channel.""" + """Decode one JSON array and reject objects or scalar payloads.""" + decoded = _decode_json(raw, hostname) if not isinstance(decoded, list): raise HttpClientError(f"JSON array expected from {hostname}") @@ -112,6 +172,7 @@ def post_json( ValueError: ``url`` is not an ``http`` / ``https`` URL with a host. HttpClientError: the server responded with HTTP >= 400 or non-JSON. """ + request_payload = payload request_metadata = current_llm_metadata() if request_metadata: @@ -120,7 +181,10 @@ def post_json( if existing_metadata is None: request_payload["metadata"] = request_metadata elif isinstance(existing_metadata, dict): - request_payload["metadata"] = {**existing_metadata, **request_metadata} + request_payload["metadata"] = { + **existing_metadata, + **request_metadata, + } else: raise ValueError("metadata must be an object") status, raw = _request( @@ -148,11 +212,15 @@ def post_form( Used by the OIDC smoke test (resource-owner password grant). Same scheme allowlist as ``post_json`` -- never ``urllib.request.urlopen``. """ + status, raw = _request( "POST", url, body=urlencode(fields).encode("utf-8"), - headers={"content-type": "application/x-www-form-urlencoded", **(headers or {})}, + headers={ + "content-type": "application/x-www-form-urlencoded", + **(headers or {}), + }, timeout=timeout, ) hostname = urlparse(url).hostname or url @@ -166,14 +234,29 @@ def get_json( *, headers: dict[str, str] | None = None, timeout: float, + maximum_response_bytes: int | None = None, ) -> dict: - """GET ``url`` and return the decoded JSON object. + """GET ``url`` and return a decoded JSON object. + + Args: + url: Operator-configured HTTP(S) endpoint. + headers: Optional request headers. + timeout: Socket timeout in seconds. + maximum_response_bytes: Optional strict response-body byte ceiling. Raises: - ValueError: ``url`` is not an ``http`` / ``https`` URL with a host. - HttpClientError: the server responded with HTTP >= 400 or non-JSON. + ValueError: The URL or byte limit is invalid. + HttpClientError: The response is too large, HTTP >= 400, or non-JSON. """ - status, raw = _request("GET", url, body=None, headers=headers or {}, timeout=timeout) + + status, raw = _request( + "GET", + url, + body=None, + headers=headers or {}, + timeout=timeout, + maximum_response_bytes=maximum_response_bytes, + ) hostname = urlparse(url).hostname or url if status >= 400: raise HttpClientError(f"HTTP {status} from {hostname}") @@ -195,7 +278,14 @@ def get_json_list( ValueError: ``url`` is not an ``http`` / ``https`` URL with a host. HttpClientError: the server responded with HTTP >= 400 or non-array JSON. """ - status, raw = _request("GET", url, body=None, headers=headers or {}, timeout=timeout) + + status, raw = _request( + "GET", + url, + body=None, + headers=headers or {}, + timeout=timeout, + ) hostname = urlparse(url).hostname or url if status >= 400: raise HttpClientError(f"HTTP {status} from {hostname}") From dc42c3a38babc2e8b88fe7e4fe7249137a1c8c8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:50:01 -0700 Subject: [PATCH 04/44] feat(calendar): add bounded Naruon read projection --- lineageweave/naruon_calendar_projection.py | 499 +++++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 lineageweave/naruon_calendar_projection.py diff --git a/lineageweave/naruon_calendar_projection.py b/lineageweave/naruon_calendar_projection.py new file mode 100644 index 000000000..e065a7faf --- /dev/null +++ b/lineageweave/naruon_calendar_projection.py @@ -0,0 +1,499 @@ +"""Strict consumer contract for Naruon-owned calendar event projections. + +LineageWeave owns post-grounded customer commitments and issue tickets. Naruon +owns customer calendar provider access, CalDAV synchronization, provider +revision handling, and policy filtering. This module consumes only a bounded, +already-authorized Naruon read projection; it is deliberately not a CalDAV +client and never receives provider credentials or an end-user bearer token. +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any +from urllib.parse import urlencode, urlparse + +from .http_client import get_json + +NARUON_CALENDAR_SCHEMA_VERSION = "1.0" +NARUON_CALENDAR_MEDIA_TYPE = ( + "application/vnd.contextualwisdomlab.naruon-calendar.v1+json" +) +NARUON_CALENDAR_EVENTS_PATH = "/api/calendar/events" +_MAX_WINDOW = timedelta(days=366) +_MAX_RESPONSE_BYTES = 1_048_576 +_ALLOWED_STATUS_CODES = frozenset( + {"confirmed", "tentative", "desired", "cancelled"} +) +_ALLOWED_DISCLOSURE_CODES = frozenset({"busy_only", "summary_visible"}) +_ALLOWED_TRUTH_STATUS_CODES = frozenset({"observed"}) +_RFC3339_PATTERN = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:" + r"[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})$" +) + + +class NaruonCalendarContractError(ValueError): + """The Naruon calendar projection violated the versioned read contract.""" + + +@dataclass(frozen=True) +class NaruonCalendarOccurrence: + """One policy-filtered external calendar occurrence observed by Naruon.""" + + event_reference: str + occurrence_reference: str + source_reference: str + provider_revision: str + display_text: str + starts_at: str + ends_at: str + all_day: bool + time_zone: str + status_code: str + disclosure_code: str + truth_status_code: str + observed_at: str + + +@dataclass(frozen=True) +class NaruonCalendarPage: + """One bounded page of Naruon calendar occurrences and its cursor.""" + + schema_version: str + projection_revision: str + events: tuple[NaruonCalendarOccurrence, ...] + next_cursor: str | None + + +def _strict_object( + value: Any, + *, + field_name: str, + required: frozenset[str], + optional: frozenset[str] = frozenset(), +) -> dict[str, Any]: + """Return a mapping with exactly the admitted required/optional fields.""" + + if not isinstance(value, dict): + raise NaruonCalendarContractError(f"{field_name} must be an object") + keys = frozenset(value) + missing = required - keys + extra = keys - required - optional + if missing: + raise NaruonCalendarContractError( + f"{field_name} is missing required fields: " + f"{', '.join(sorted(missing))}" + ) + if extra: + raise NaruonCalendarContractError( + f"{field_name} has unexpected fields: " + f"{', '.join(sorted(extra))}" + ) + return value + + +def _bounded_text( + value: Any, + *, + field_name: str, + maximum_length: int, + allow_whitespace: bool = True, + allow_url_shape: bool = True, +) -> str: + """Validate one bounded text field without disclosing its value in errors.""" + + if not isinstance(value, str): + raise NaruonCalendarContractError(f"{field_name} must be a string") + normalized = value.strip() + if not normalized or len(normalized) > maximum_length: + raise NaruonCalendarContractError( + f"{field_name} must contain 1..{maximum_length} characters" + ) + if any( + ord(character) < 32 or ord(character) == 127 + for character in normalized + ): + raise NaruonCalendarContractError( + f"{field_name} contains control characters" + ) + if not allow_whitespace and any( + character.isspace() for character in normalized + ): + raise NaruonCalendarContractError( + f"{field_name} must be an opaque token" + ) + if not allow_url_shape and "://" in normalized: + raise NaruonCalendarContractError( + f"{field_name} must not contain a URL" + ) + return normalized + + +def _bounded_integer( + value: Any, + *, + field_name: str, + minimum: int, + maximum: int, +) -> int: + """Return one true integer inside an inclusive contract range.""" + + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{field_name} must be an integer") + if not minimum <= value <= maximum: + raise ValueError( + f"{field_name} must be between {minimum} and {maximum}" + ) + return value + + +def _bounded_timeout(value: Any) -> float: + """Return a finite timeout in the supported transport range.""" + + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("timeout must be a finite number") + normalized = float(value) + if not math.isfinite(normalized) or not 0 < normalized <= 30: + raise ValueError( + "timeout must be greater than 0 and at most 30 seconds" + ) + return normalized + + +def _opaque_reference(value: Any, *, field_name: str) -> str: + """Validate an opaque non-URL reference token.""" + + return _bounded_text( + value, + field_name=field_name, + maximum_length=256, + allow_whitespace=False, + allow_url_shape=False, + ) + + +def _parse_rfc3339(value: Any, *, field_name: str) -> datetime: + """Parse one offset-aware RFC 3339 instant.""" + + text = _bounded_text( + value, + field_name=field_name, + maximum_length=64, + ) + if _RFC3339_PATTERN.fullmatch(text) is None: + raise NaruonCalendarContractError( + f"{field_name} must be RFC 3339" + ) + normalized_text = text[:10] + "T" + text[11:] + normalized = ( + f"{normalized_text[:-1]}+00:00" + if normalized_text.endswith(("Z", "z")) + else normalized_text + ) + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise NaruonCalendarContractError( + f"{field_name} must be RFC 3339" + ) from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise NaruonCalendarContractError( + f"{field_name} must include an offset" + ) + return parsed + + +def _controlled_code( + value: Any, + *, + field_name: str, + allowed: frozenset[str], +) -> str: + """Validate one closed vocabulary value.""" + + code = _bounded_text( + value, + field_name=field_name, + maximum_length=64, + allow_whitespace=False, + ) + if code not in allowed: + raise NaruonCalendarContractError( + f"{field_name} has an unsupported value" + ) + return code + + +def _parse_occurrence( + value: Any, + *, + index: int, +) -> NaruonCalendarOccurrence: + """Parse one strict calendar occurrence from a projection page.""" + + field_name = f"events[{index}]" + row = _strict_object( + value, + field_name=field_name, + required=frozenset( + { + "event_reference", + "occurrence_reference", + "source_reference", + "provider_revision", + "display_text", + "starts_at", + "ends_at", + "all_day", + "time_zone", + "status_code", + "disclosure_code", + "truth_status_code", + "observed_at", + } + ), + ) + if not isinstance(row["all_day"], bool): + raise NaruonCalendarContractError( + f"{field_name}.all_day must be a boolean" + ) + starts_at = _parse_rfc3339( + row["starts_at"], + field_name=f"{field_name}.starts_at", + ) + ends_at = _parse_rfc3339( + row["ends_at"], + field_name=f"{field_name}.ends_at", + ) + if ends_at <= starts_at: + raise NaruonCalendarContractError( + f"{field_name}.ends_at must be after starts_at" + ) + _parse_rfc3339( + row["observed_at"], + field_name=f"{field_name}.observed_at", + ) + return NaruonCalendarOccurrence( + event_reference=_opaque_reference( + row["event_reference"], + field_name=f"{field_name}.event_reference", + ), + occurrence_reference=_opaque_reference( + row["occurrence_reference"], + field_name=f"{field_name}.occurrence_reference", + ), + source_reference=_opaque_reference( + row["source_reference"], + field_name=f"{field_name}.source_reference", + ), + provider_revision=_bounded_text( + row["provider_revision"], + field_name=f"{field_name}.provider_revision", + maximum_length=256, + allow_url_shape=False, + ), + display_text=_bounded_text( + row["display_text"], + field_name=f"{field_name}.display_text", + maximum_length=512, + ), + starts_at=str(row["starts_at"]).strip(), + ends_at=str(row["ends_at"]).strip(), + all_day=row["all_day"], + time_zone=_bounded_text( + row["time_zone"], + field_name=f"{field_name}.time_zone", + maximum_length=128, + allow_whitespace=False, + ), + status_code=_controlled_code( + row["status_code"], + field_name=f"{field_name}.status_code", + allowed=_ALLOWED_STATUS_CODES, + ), + disclosure_code=_controlled_code( + row["disclosure_code"], + field_name=f"{field_name}.disclosure_code", + allowed=_ALLOWED_DISCLOSURE_CODES, + ), + truth_status_code=_controlled_code( + row["truth_status_code"], + field_name=f"{field_name}.truth_status_code", + allowed=_ALLOWED_TRUTH_STATUS_CODES, + ), + observed_at=str(row["observed_at"]).strip(), + ) + + +def parse_naruon_calendar_page( + payload: Any, + *, + maximum_events: int = 200, +) -> NaruonCalendarPage: + """Validate and convert one Naruon calendar projection page. + + The parser rejects unknown fields and vocabulary values so provider or + policy changes cannot silently broaden what LineageWeave exposes. + """ + + admitted_maximum = _bounded_integer( + maximum_events, + field_name="maximum_events", + minimum=1, + maximum=200, + ) + root = _strict_object( + payload, + field_name="calendar_page", + required=frozenset( + {"schema_version", "projection_revision", "events"} + ), + optional=frozenset({"next_cursor"}), + ) + if root["schema_version"] != NARUON_CALENDAR_SCHEMA_VERSION: + raise NaruonCalendarContractError( + "calendar_page.schema_version is unsupported" + ) + projection_revision = _opaque_reference( + root["projection_revision"], + field_name="calendar_page.projection_revision", + ) + rows = root["events"] + if not isinstance(rows, list): + raise NaruonCalendarContractError( + "calendar_page.events must be an array" + ) + if len(rows) > admitted_maximum: + raise NaruonCalendarContractError( + "calendar_page.events exceeds the admitted page size" + ) + events = tuple( + _parse_occurrence(row, index=index) + for index, row in enumerate(rows) + ) + occurrence_references = [ + event.occurrence_reference for event in events + ] + if len(occurrence_references) != len(set(occurrence_references)): + raise NaruonCalendarContractError( + "calendar_page contains duplicate occurrence references" + ) + next_cursor_value = root.get("next_cursor") + next_cursor = None + if next_cursor_value is not None: + next_cursor = _bounded_text( + next_cursor_value, + field_name="calendar_page.next_cursor", + maximum_length=1024, + allow_whitespace=False, + allow_url_shape=False, + ) + return NaruonCalendarPage( + schema_version=NARUON_CALENDAR_SCHEMA_VERSION, + projection_revision=projection_revision, + events=events, + next_cursor=next_cursor, + ) + + +class NaruonCalendarProjectionClient: + """Read a bounded Naruon calendar projection with one service credential.""" + + def __init__( + self, + base_url: str, + service_access_token: str, + *, + maximum_events: int = 200, + timeout: float = 10.0, + ) -> None: + """Validate immutable transport settings for one Naruon audience.""" + + normalized_base = _bounded_text( + base_url, + field_name="base_url", + maximum_length=2048, + allow_whitespace=False, + ) + parsed = urlparse(normalized_base) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError( + "base_url must be an http(s) URL with a hostname" + ) + if parsed.username is not None or parsed.password is not None: + raise ValueError("base_url must not contain userinfo") + if parsed.query or parsed.fragment: + raise ValueError( + "base_url must not contain a query or fragment" + ) + token = _bounded_text( + service_access_token, + field_name="service_access_token", + maximum_length=4096, + allow_whitespace=False, + ) + admitted_maximum = _bounded_integer( + maximum_events, + field_name="maximum_events", + minimum=1, + maximum=200, + ) + admitted_timeout = _bounded_timeout(timeout) + self._events_url = ( + f"{normalized_base.rstrip('/')}{NARUON_CALENDAR_EVENTS_PATH}" + ) + self._service_access_token = token + self._maximum_events = admitted_maximum + self._timeout = admitted_timeout + + def list_events( + self, + window_start: str, + window_end: str, + *, + cursor: str | None = None, + ) -> NaruonCalendarPage: + """Return one authorized event page within an offset-aware window.""" + + starts_at = _parse_rfc3339( + window_start, + field_name="window_start", + ) + ends_at = _parse_rfc3339( + window_end, + field_name="window_end", + ) + if ends_at <= starts_at: + raise ValueError("window_end must be after window_start") + if ends_at - starts_at > _MAX_WINDOW: + raise ValueError("calendar window must not exceed 366 days") + fields = { + "window_start": window_start.strip(), + "window_end": window_end.strip(), + "limit": str(self._maximum_events), + } + if cursor is not None: + fields["cursor"] = _bounded_text( + cursor, + field_name="cursor", + maximum_length=1024, + allow_whitespace=False, + allow_url_shape=False, + ) + payload = get_json( + f"{self._events_url}?{urlencode(fields)}", + headers={ + "authorization": f"Bearer {self._service_access_token}", + "accept": NARUON_CALENDAR_MEDIA_TYPE, + }, + timeout=self._timeout, + maximum_response_bytes=_MAX_RESPONSE_BYTES, + ) + return parse_naruon_calendar_page( + payload, + maximum_events=self._maximum_events, + ) From 1e54243a82297e9cfe5c65f278daf299819df28e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:52:02 -0700 Subject: [PATCH 05/44] test(calendar): cover transport and numeric hardening --- tests/test_naruon_calendar_projection.py | 46 +++++++++++++++++------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/tests/test_naruon_calendar_projection.py b/tests/test_naruon_calendar_projection.py index 2774570ac..e341289fc 100644 --- a/tests/test_naruon_calendar_projection.py +++ b/tests/test_naruon_calendar_projection.py @@ -58,8 +58,14 @@ def fake_get_json( *, headers: dict[str, str], timeout: float, + maximum_response_bytes: int | None, ) -> dict[str, object]: - received.update(url=url, headers=headers, timeout=timeout) + received.update( + url=url, + headers=headers, + timeout=timeout, + maximum_response_bytes=maximum_response_bytes, + ) return _page() monkeypatch.setattr( @@ -94,6 +100,7 @@ def fake_get_json( "accept": NARUON_CALENDAR_MEDIA_TYPE, } assert received["timeout"] == 7 + assert received["maximum_response_bytes"] == 1_048_576 assert page.projection_revision == "projection_001" assert page.next_cursor == "cursor_002" assert page.events[0].occurrence_reference == "occ_001" @@ -107,10 +114,12 @@ def fake_get_json( "https://user:secret@naruon.example", "https://naruon.example?token=secret", "https://naruon.example#events", + "https://naruon.example/private path", + "https://naruon.example/private\tpath", ], ) def test_client_rejects_unsafe_base_urls(base_url: str) -> None: - with pytest.raises(ValueError): + with pytest.raises((ValueError, NaruonCalendarContractError)): NaruonCalendarProjectionClient(base_url, "service-secret") @@ -123,23 +132,26 @@ def test_client_rejects_missing_control_or_whitespace_tokens(token: str) -> None NaruonCalendarProjectionClient("https://naruon.example", token) -@pytest.mark.parametrize("maximum_events", [0, 201]) -def test_client_rejects_invalid_page_bounds(maximum_events: int) -> None: +@pytest.mark.parametrize("maximum_events", [0, 201, True, 1.5]) +def test_client_rejects_invalid_page_bounds(maximum_events: object) -> None: with pytest.raises(ValueError, match="maximum_events"): NaruonCalendarProjectionClient( "https://naruon.example", "service-secret", - maximum_events=maximum_events, + maximum_events=maximum_events, # type: ignore[arg-type] ) -@pytest.mark.parametrize("timeout", [0, 31]) -def test_client_rejects_invalid_timeouts(timeout: float) -> None: +@pytest.mark.parametrize( + "timeout", + [0, 31, True, float("nan"), float("inf")], +) +def test_client_rejects_invalid_timeouts(timeout: object) -> None: with pytest.raises(ValueError, match="timeout"): NaruonCalendarProjectionClient( "https://naruon.example", "service-secret", - timeout=timeout, + timeout=timeout, # type: ignore[arg-type] ) @@ -243,8 +255,12 @@ def test_parser_rejects_over_limit_pages_and_invalid_parser_bounds() -> None: events = [_event(occurrence_reference=f"occ_{index}") for index in range(3)] with pytest.raises(NaruonCalendarContractError, match="page size"): parse_naruon_calendar_page(_page(*events), maximum_events=2) - with pytest.raises(ValueError, match="maximum_events"): - parse_naruon_calendar_page(_page(), maximum_events=0) + for invalid in (0, True, 1.5): + with pytest.raises(ValueError, match="maximum_events"): + parse_naruon_calendar_page( + _page(), + maximum_events=invalid, # type: ignore[arg-type] + ) def test_parser_allows_terminal_page_without_cursor() -> None: @@ -271,16 +287,18 @@ def test_parser_rejects_whitespace_in_opaque_references() -> None: def test_client_omits_cursor_when_not_requested(monkeypatch) -> None: - received: dict[str, str] = {} + received: dict[str, object] = {} def fake_get_json( url: str, *, headers: dict[str, str], timeout: float, + maximum_response_bytes: int | None, ) -> dict[str, object]: del headers, timeout received["url"] = url + received["maximum_response_bytes"] = maximum_response_bytes return _page() monkeypatch.setattr( @@ -294,7 +312,8 @@ def fake_get_json( client.list_events("2026-08-01T00:00:00Z", "2026-08-02T00:00:00Z") - assert "cursor" not in parse_qs(urlparse(received["url"]).query) + assert "cursor" not in parse_qs(urlparse(str(received["url"])).query) + assert received["maximum_response_bytes"] == 1_048_576 def test_json_schema_matches_parser_contract() -> None: @@ -315,6 +334,9 @@ def test_json_schema_matches_parser_contract() -> None: assert occurrence_schema["properties"]["truth_status_code"]["const"] == ( "observed" ) + assert schema["$defs"]["opaque_reference"]["pattern"] == ( + r"^(?!.*://)[^\s\u0000-\u001F\u007F]+$" + ) def test_public_package_exports_calendar_projection_contract() -> None: From 796b758137e50583b22617a0566203182dfc63a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:52:47 -0700 Subject: [PATCH 06/44] docs(contract): add strict Naruon calendar schema --- .../naruon-calendar-projection-v1.schema.json | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/contracts/naruon-calendar-projection-v1.schema.json diff --git a/docs/contracts/naruon-calendar-projection-v1.schema.json b/docs/contracts/naruon-calendar-projection-v1.schema.json new file mode 100644 index 000000000..695d18706 --- /dev/null +++ b/docs/contracts/naruon-calendar-projection-v1.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contextualwisdomlab.org/schemas/naruon-calendar-projection-v1.schema.json", + "title": "Naruon Calendar Projection v1", + "description": "A bounded, policy-filtered event page observed by Naruon and consumed read-only by LineageWeave. This is not a CalDAV provider response.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "projection_revision", "events"], + "properties": { + "schema_version": { + "const": "1.0" + }, + "projection_revision": { + "$ref": "#/$defs/opaque_reference" + }, + "events": { + "type": "array", + "maxItems": 200, + "items": { + "$ref": "#/$defs/calendar_occurrence" + } + }, + "next_cursor": { + "oneOf": [ + { + "$ref": "#/$defs/cursor" + }, + { + "type": "null" + } + ] + } + }, + "$defs": { + "opaque_reference": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?!.*://)[^\\s\\u0000-\\u001F\\u007F]+$" + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!.*://)[^\\s\\u0000-\\u001F\\u007F]+$" + }, + "rfc3339": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "calendar_occurrence": { + "type": "object", + "additionalProperties": false, + "required": [ + "event_reference", + "occurrence_reference", + "source_reference", + "provider_revision", + "display_text", + "starts_at", + "ends_at", + "all_day", + "time_zone", + "status_code", + "disclosure_code", + "truth_status_code", + "observed_at" + ], + "properties": { + "event_reference": { + "$ref": "#/$defs/opaque_reference" + }, + "occurrence_reference": { + "$ref": "#/$defs/opaque_reference" + }, + "source_reference": { + "$ref": "#/$defs/opaque_reference" + }, + "provider_revision": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?!.*://)[^\\u0000-\\u001F\\u007F]+$" + }, + "display_text": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[^\\u0000-\\u001F\\u007F]+$" + }, + "starts_at": { + "$ref": "#/$defs/rfc3339" + }, + "ends_at": { + "$ref": "#/$defs/rfc3339" + }, + "all_day": { + "type": "boolean" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\s\\u0000-\\u001F\\u007F]+$" + }, + "status_code": { + "enum": ["confirmed", "tentative", "desired", "cancelled"] + }, + "disclosure_code": { + "enum": ["busy_only", "summary_visible"] + }, + "truth_status_code": { + "const": "observed" + }, + "observed_at": { + "$ref": "#/$defs/rfc3339" + } + } + } + } +} From ccdbdbf6987fe515b5aa8f5c24fc5b08fd8e44cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:53:24 -0700 Subject: [PATCH 07/44] feat(calendar): export Naruon projection package API --- lineageweave/__init__.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 95330cb50..8448f8a70 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -1,10 +1,9 @@ -"""LineageWeave: reconstructs git-branch-style lineage DAGs from scattered -short records by fusing independent, individually-weak signals (temporal -proximity, shared grouping keys, text similarity, and optional LLM -adjudication) into a single per-record parent choice. +"""LineageWeave: reconstruct evidence-bounded lineage DAGs. -See ARCHITECTURE.md for the design and docs/lineage-bi-research-notes.md -for the literature this design is grounded in. +LineageWeave fuses independent signals such as temporal proximity, shared +keys, text similarity, and optional adjudication into per-record parent choices. +See ``ARCHITECTURE.md`` and ``docs/lineage-bi-research-notes.md`` for the +product and research boundaries. """ from .affiliate_tree import build_affiliate_forest @@ -13,6 +12,15 @@ from .knowledge_graph import random_walk_with_restart, select_related_nodes from .lineage_persistence import lineage_edge_specs from .models import Edge, Record, Tree +from .naruon_calendar_projection import ( + NARUON_CALENDAR_MEDIA_TYPE, + NARUON_CALENDAR_SCHEMA_VERSION, + NaruonCalendarContractError, + NaruonCalendarOccurrence, + NaruonCalendarPage, + NaruonCalendarProjectionClient, + parse_naruon_calendar_page, +) from .post_chat import ChatAnswer, cited_post_summaries from .post_summary import PostSummary from .prov_o import ( @@ -32,6 +40,12 @@ __all__ = [ "ChatAnswer", "Edge", + "NARUON_CALENDAR_MEDIA_TYPE", + "NARUON_CALENDAR_SCHEMA_VERSION", + "NaruonCalendarContractError", + "NaruonCalendarOccurrence", + "NaruonCalendarPage", + "NaruonCalendarProjectionClient", "OrganizationRelationship", "PROV", "PROV_CLASSES", @@ -48,6 +62,7 @@ "build_affiliate_forest", "cited_post_summaries", "lineage_edge_specs", + "parse_naruon_calendar_page", "random_walk_with_restart", "reconstruct", "resolve_corporate_entity", From 0aa9b8f2dfdcfad4c0a8d493a6225110f55256fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:53:51 -0700 Subject: [PATCH 08/44] docs(adr): correct pseudo-CalDAV product language --- docs/adr/0038-calendar-source-contract.md | 58 +++++++++++++++-------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/adr/0038-calendar-source-contract.md b/docs/adr/0038-calendar-source-contract.md index b9e89d51b..0052cf029 100644 --- a/docs/adr/0038-calendar-source-contract.md +++ b/docs/adr/0038-calendar-source-contract.md @@ -1,37 +1,53 @@ -# ADR 0038: Separate CalDAV events from internal commitments +# ADR 0038: Separate external calendar events from internal commitments -- Status: Accepted +- Status: Superseded in part by ADR 0123 - Date: 2026-08-18 ## Context -The buyer Calendar destination needs both external calendar events and +The buyer Calendar destination needs both external calendar observations and actionable records derived from LineageWeave posts. They have different -ownership and evidence boundaries. PR #251 defines CalDAV as an independent -consumer port, while the current application already stores authorized -commitments and issue tickets. +ownership and evidence boundaries. The application already stores authorized +commitments and issue tickets, while the first external adapter read a custom +JSON `GET {CALDAV_BASE_URL}/events` feed. -## Decision +That feed was not a CalDAV client or server contract. It did not implement RFC +4791 WebDAV discovery/REPORT, RFC 5545 recurrence and timezone semantics, RFC +6578 synchronization, provider revisions, or provider authorization. Product +and code language must not represent it as shipped CalDAV interoperability. -`GET /api/calendar` returns two independent collections: +## Original decision retained -- `events`: events read from `CALDAV_BASE_URL/events` through - `lineageweave.caldav_client`; malformed external rows are ignored. +The Buyer Calendar returns two independent collections: + +- `events`: externally observed calendar occurrences; and - `commitments`: the existing authorized internal commitment projection, filtered by the requesting account's `post_read` RBAC and post ABAC rules. -When CalDAV is unset or temporarily unavailable, `events` is empty and the -response includes a next action in `calendar_sources`; the internal -commitments remain available. The backend never invents an external event. +When the external calendar channel is unset or temporarily unavailable, +`events` is empty and the internal commitments remain available. The backend +never invents an external event. + +LineageWeave does not add a second calendar database, CalDAV server, provider +credential store, or writeback engine. + +## Superseding decision + +ADR 0123 replaces the custom `/events` transport and CalDAV naming with a +versioned, read-only Naruon calendar projection contract. Naruon is the authority +for customer-owned provider access, source registry, synchronization, provider +revisions, writeback, retries, and reconciliation. LineageWeave consumes only +bounded, already-authorized `observed` occurrence projections. -This checkpoint does not add a second calendar database. A persistent event -store and sync history may be added when offline access, change tracking, or -CalDAV write-back becomes a product requirement. +The original separation between `events` and `commitments` remains mandatory. +An external event is not converted to an internal issue or commitment without a +separate source-grounded LineageWeave decision and evidence trail. ## Consequences -- The Calendar screen is useful with the existing synthetic commitment data, - even without an external calendar server. -- External events cannot be mistaken for post-grounded commitments. -- CalDAV transport failures do not turn the entire buyer surface into a - fail-closed blank screen. +- The Calendar remains useful with authorized commitment data when Naruon is + unavailable. +- External observations cannot be mistaken for post-grounded commitments. +- Product documentation no longer represents a custom JSON feed as CalDAV. +- Runtime activation waits for Naruon's matching read endpoint and service + audience; absence continues to fail closed rather than fabricate events. From 2914b8bb4262cf09559df103d2571366f357ea8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:54:44 -0700 Subject: [PATCH 09/44] docs(adr): record clean Naruon calendar authority boundary --- ...123-naruon-calendar-projection-boundary.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/adr/0123-naruon-calendar-projection-boundary.md diff --git a/docs/adr/0123-naruon-calendar-projection-boundary.md b/docs/adr/0123-naruon-calendar-projection-boundary.md new file mode 100644 index 000000000..239a9517f --- /dev/null +++ b/docs/adr/0123-naruon-calendar-projection-boundary.md @@ -0,0 +1,161 @@ +# ADR 0123: Consume calendar observations through Naruon + +- Status: Accepted +- Date: 2026-08-21 +- Issue: #336 +- Related authority: `ContextualWisdomLab/naruon#978`, `#998`, and `#1437` + +## Context + +LineageWeave derives customer commitments from authorized post evidence and +stores them as issue tickets with due dates. The Buyer Calendar can therefore +show two different kinds of records: + +1. LineageWeave-authoritative commitments and To Do records; and +2. external calendar events observed in a customer-owned provider. + +ADR 0038 correctly separated these collections but named a custom JSON +`GET {CALDAV_BASE_URL}/events` feed as CalDAV. That endpoint does not implement +RFC 4791 discovery, WebDAV REPORT, iCalendar recurrence or VTIMEZONE, RFC 6578 +synchronization, ETag reconciliation, scheduling, or provider authorization. +The name therefore overstates the shipped product. + +Naruon is the CWL authority for customer-owned mail, calendar, contact, and file +provider interaction. Its scheduling boundary owns typed event/commitment +semantics, DAV capability discovery, synchronization, provider revisions, +writeback, retries, and reconciliation. Reimplementing those responsibilities +inside LineageWeave would duplicate credentials and provider state and would +turn LineageWeave into a second calendar product. + +## Decision + +LineageWeave consumes a **read-only, versioned Naruon calendar projection**. It +does not connect to a CalDAV provider directly. + +The contract is implemented by: + +- `lineageweave.naruon_calendar_projection`; +- `docs/contracts/naruon-calendar-projection-v1.schema.json`; and +- strict parser, transport, byte-bound, and public-package tests in + `tests/test_naruon_calendar_projection.py` and `tests/test_http_client.py`. + +The projection endpoint is conceptually: + +```text +GET {Naruon base}/api/calendar/events + ?window_start= + &window_end= + &limit=<1..200> + [&cursor=] +``` + +The request uses an audience-scoped service credential configured for the +LineageWeave deployment. It does not forward a browser or end-user bearer token, +and it never receives provider credentials. The credential must be a bounded +single token without whitespace or control characters. The consumer admits at +most a 1 MiB response body before JSON parsing. + +Each occurrence carries only: + +```text +event_reference +occurrence_reference +source_reference +provider_revision +display_text +starts_at +ends_at +all_day +time_zone +status_code +disclosure_code +truth_status_code = observed +observed_at +``` + +Naruon applies tenant, source, participant, and disclosure policy before the +response crosses the service boundary. `busy_only` rows contain only safe +Naruon-supplied display text. Attendees, descriptions, provider URLs, private +conflict reasons, access tokens, and raw DAV payloads are outside this contract. + +LineageWeave keeps the two truth domains separate: + +```text +LineageWeave commitment +- authoritative post-derived work record +- issue/todo identity +- source-post evidence and ontology/provenance + +Naruon event projection +- observed provider occurrence +- opaque Naruon source/event/occurrence identity +- provider revision and observation time +``` + +An observed external event is never promoted into an internal commitment merely +because it appears in the same Calendar screen. + +## Validation and failure posture + +The LineageWeave consumer rejects: + +- non-HTTP(S), userinfo-bearing, query-bearing, fragment-bearing, whitespace, or + control-bearing base URLs; +- missing, control-bearing, or whitespace-bearing service credentials; +- windows longer than 366 days; +- pages larger than 200 events or response bodies larger than 1 MiB; +- unknown fields, schema versions, status, disclosure, or truth vocabularies; +- naive timestamps, invalid intervals, and duplicate occurrence references; +- URL-shaped or whitespace-bearing opaque references and cursors; +- boolean, fractional, or out-of-range page limits and invalid timeouts. + +The adapter follows no redirects through the current shared HTTP client. Errors +identify the configured host when necessary but never include the service +credential or response body. + +Until Naruon ships the matching read endpoint and service-audience contract, +LineageWeave runtime wiring remains disabled and fail-closed. Existing internal +commitments remain available even when the external event channel is absent. + +## Consequences + +### Positive + +- Product language no longer implies CalDAV interoperability that does not + exist. +- Provider credentials, sync cursors, ETags, recurrence reconciliation, and + scheduling remain in one authority. +- LineageWeave gains a strict, bounded ontology/provenance-compatible event + observation contract without creating another event store. +- Calendar commitments and external observations remain auditable and cannot be + silently conflated. +- The contract may merge and be released independently while runtime activation + remains disabled. + +### Costs and limitations + +- The Buyer Calendar will not show external events until Naruon implements and + releases the corresponding read projection. +- The two repositories require provider/consumer contract tests before runtime + activation. +- This decision does not claim provider interoperability, CalDAV conformance, or + a completed Naruon connector. +- Contract v1 does not include attendees, recurrence rules, or provider URLs; + broader disclosure requires a new reviewed contract version. + +## Runtime activation gate + +Runtime activation requires all of the following: + +1. Naruon publishes the matching endpoint, media type, service audience, and + conformance fixtures; +2. LineageWeave wires configuration and the Buyer API without forwarding an + end-user token; +3. provider/consumer fixtures pass against immutable released artifacts; +4. degraded, timeout, retry, revision, and reconciliation behavior is tested; +5. exact-head security, coverage, review, and protected merge gates pass in both + repositories. + +## References + +See `docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md`. From c25c5def7384d37394910aed24aabba69ec8ba3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:55:13 -0700 Subject: [PATCH 10/44] docs(doctoring): trace calendar contract standards --- .../NARUON_CALENDAR_PROJECTION_REFERENCES.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md diff --git a/docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md b/docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md new file mode 100644 index 000000000..ef9b83535 --- /dev/null +++ b/docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md @@ -0,0 +1,23 @@ +# Naruon calendar projection references + +## Product traceability + +| Source | Product decision | Evidence | +|---|---|---| +| RFC 4791 | Do not call the legacy JSON `/events` feed CalDAV; provider DAV behavior belongs to Naruon. | ADR 0038; ADR 0123; issue #336 | +| RFC 6578 | Sync tokens and collection reconciliation are provider-authority concerns, not LineageWeave read-model fields. | ADR 0123; Naruon #978/#998 | +| RFC 5545 | Recurrence occurrence identity, timezone, and all-day semantics must survive the Naruon projection. | Projection v1 schema and parser tests | +| PROV-O | External rows remain `observed`; LineageWeave commitments retain separate authoritative post provenance. | `truth_status_code`; ADR 0123 | +| OWASP API4:2023 | Limit page size, date window, timeout, and response bytes before parsing to constrain resource consumption. | Client bounds; bounded HTTP response tests | + +## APA 7th references + +Daboo, C., Desruisseaux, B., & Dusseault, L. M. (2007). *Calendaring extensions to WebDAV (CalDAV)* (RFC 4791). RFC Editor. https://doi.org/10.17487/RFC4791 + +Daboo, C., & Quillaud, A. (2012). *Collection synchronization for Web Distributed Authoring and Versioning (WebDAV)* (RFC 6578). RFC Editor. https://doi.org/10.17487/RFC6578 + +Desruisseaux, B. (2009). *Internet calendaring and scheduling core object specification (iCalendar)* (RFC 5545). RFC Editor. https://doi.org/10.17487/RFC5545 + +OWASP Foundation. (2023). *OWASP API Security Top 10—API4:2023 unrestricted resource consumption*. https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/ + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ From da1d0296538fc11d5cfcdca4617e3dac41ad5306 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:55:35 -0700 Subject: [PATCH 11/44] docs(changelog): add clean calendar projection fragment --- .../naruon-calendar-projection-contract.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 CHANGELOG.d/naruon-calendar-projection-contract.md diff --git a/CHANGELOG.d/naruon-calendar-projection-contract.md b/CHANGELOG.d/naruon-calendar-projection-contract.md new file mode 100644 index 000000000..1ae13df82 --- /dev/null +++ b/CHANGELOG.d/naruon-calendar-projection-contract.md @@ -0,0 +1,16 @@ +# Naruon calendar projection contract + +## Added + +- Add a strict, bounded v1 consumer contract for calendar occurrences already authorized and policy-filtered by Naruon, including occurrence identity, provider revision, timezone/all-day semantics, disclosure level, and observed provenance. +- Export the projection parser, immutable result types, media type, schema version, and read client through the public LineageWeave package surface. +- Add a reusable bounded JSON response read so oversized pages are rejected before allocation and parsing. + +## Changed + +- Clarify that LineageWeave owns post-grounded commitments and issue/todo records, while Naruon owns provider CalDAV synchronization, revisions, writeback, retry, and reconciliation. +- Replace the misleading CalDAV label on the earlier custom JSON `/events` feed with an explicit pseudo-CalDAV correction. + +## Security + +- Reject unsafe base URLs, whitespace/control-bearing service tokens, unbounded response bodies, invalid numeric controls, oversized pages/windows, naive timestamps, duplicate occurrences, unknown fields/vocabularies, and URL-shaped opaque references. From 10bdeb01974c62e1c300da4dbeaa88e4dd358f4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:57:17 -0700 Subject: [PATCH 12/44] test(http): cover bounded response edge branches --- tests/test_http_client_edges.py | 107 ++++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 12 deletions(-) diff --git a/tests/test_http_client_edges.py b/tests/test_http_client_edges.py index 8c6119fc9..4cff7c7df 100644 --- a/tests/test_http_client_edges.py +++ b/tests/test_http_client_edges.py @@ -5,26 +5,75 @@ import lineageweave.http_client as http_client -def test_json_helpers_reject_non_json_and_wrong_shapes(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(http_client, "_request", lambda *_args, **_kwargs: (200, b"not-json")) +class _ResponseStub: + def __init__(self, body: bytes, content_length: str | None) -> None: + self._body = body + self._content_length = content_length + + def getheader(self, name: str) -> str | None: + assert name == "Content-Length" + return self._content_length + + def read(self, amount: int | None = None) -> bytes: + if amount is None: + return self._body + return self._body[:amount] + + +def test_json_helpers_reject_non_json_and_wrong_shapes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + http_client, + "_request", + lambda *_args, **_kwargs: (200, b"not-json"), + ) with pytest.raises(http_client.HttpClientError, match="non-JSON"): http_client.get_json("https://gateway.example/health", timeout=1) - monkeypatch.setattr(http_client, "_request", lambda *_args, **_kwargs: (200, b"[]")) + monkeypatch.setattr( + http_client, + "_request", + lambda *_args, **_kwargs: (200, b"[]"), + ) with pytest.raises(http_client.HttpClientError, match="JSON object"): - http_client.post_json("https://gateway.example/v1", {}, headers={}, timeout=1) + http_client.post_json( + "https://gateway.example/v1", + {}, + headers={}, + timeout=1, + ) - monkeypatch.setattr(http_client, "_request", lambda *_args, **_kwargs: (200, b"{}")) + monkeypatch.setattr( + http_client, + "_request", + lambda *_args, **_kwargs: (200, b"{}"), + ) with pytest.raises(http_client.HttpClientError, match="JSON array"): - http_client.get_json_list("https://gateway.example/items", timeout=1) + http_client.get_json_list( + "https://gateway.example/items", + timeout=1, + ) @pytest.mark.parametrize( "helper", - [http_client.post_json, http_client.post_form, http_client.get_json, http_client.get_json_list], + [ + http_client.post_json, + http_client.post_form, + http_client.get_json, + http_client.get_json_list, + ], ) -def test_json_helpers_raise_on_http_errors(monkeypatch: pytest.MonkeyPatch, helper) -> None: - monkeypatch.setattr(http_client, "_request", lambda *_args, **_kwargs: (503, b"{}")) +def test_json_helpers_raise_on_http_errors( + monkeypatch: pytest.MonkeyPatch, + helper, +) -> None: + monkeypatch.setattr( + http_client, + "_request", + lambda *_args, **_kwargs: (503, b"{}"), + ) kwargs = {"timeout": 1} if helper is http_client.post_json: kwargs.update(payload={}, headers={}) @@ -37,14 +86,48 @@ def test_json_helpers_raise_on_http_errors(monkeypatch: pytest.MonkeyPatch, help helper("https://gateway.example/endpoint", **kwargs) -def test_json_helpers_accept_optional_headers(monkeypatch: pytest.MonkeyPatch) -> None: +def test_json_helpers_accept_optional_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: calls: list[tuple[object, ...]] = [] def request(*args, **kwargs): calls.append((args, kwargs)) - return 200, b"{}" if kwargs["headers"].get("content-type") != "application/json" else b"{}" + return 200, b"{}" monkeypatch.setattr(http_client, "_request", request) assert http_client.get_json("https://gateway.example", timeout=1) == {} - assert http_client.post_form("https://gateway.example", {}, timeout=1) == {} + assert http_client.post_form( + "https://gateway.example", + {}, + timeout=1, + ) == {} assert len(calls) == 2 + + +def test_response_reader_supports_bounded_and_unbounded_chunked_bodies() -> None: + response = _ResponseStub(b"{}", None) + assert http_client._read_response_body( + response, # type: ignore[arg-type] + maximum_response_bytes=None, + ) == b"{}" + assert http_client._read_response_body( + response, # type: ignore[arg-type] + maximum_response_bytes=8, + ) == b"{}" + + +@pytest.mark.parametrize("header", ["not-a-number", "-1"]) +def test_response_reader_rejects_invalid_content_length(header: str) -> None: + response = _ResponseStub(b"{}", header) + with pytest.raises(http_client.HttpClientError, match="Content-Length"): + http_client._read_response_body( + response, # type: ignore[arg-type] + maximum_response_bytes=8, + ) + + +@pytest.mark.parametrize("value", [True, 1.5, -1]) +def test_response_limit_rejects_ambiguous_or_invalid_values(value: object) -> None: + with pytest.raises(ValueError, match="maximum_response_bytes"): + http_client._validated_response_limit(value) # type: ignore[arg-type] From 1a22148f30672b83546f961bbf9dd2e30bf64c7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:57:50 -0700 Subject: [PATCH 13/44] test(calendar): cover RFC3339 defensive branches --- .../test_naruon_calendar_projection_edges.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/test_naruon_calendar_projection_edges.py diff --git a/tests/test_naruon_calendar_projection_edges.py b/tests/test_naruon_calendar_projection_edges.py new file mode 100644 index 000000000..9c0a6855d --- /dev/null +++ b/tests/test_naruon_calendar_projection_edges.py @@ -0,0 +1,61 @@ +"""Low-level edge tests for the strict calendar projection parser.""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +import lineageweave.naruon_calendar_projection as calendar_projection + + +class _NaiveDateTimeFactory: + @classmethod + def fromisoformat(cls, value: str) -> datetime: + del value + return datetime(2026, 8, 21, 9, 0, 0) + + +class _NoOffsetValue: + tzinfo = object() + + def utcoffset(self) -> None: + return None + + +class _NoOffsetDateTimeFactory: + @classmethod + def fromisoformat(cls, value: str) -> _NoOffsetValue: + del value + return _NoOffsetValue() + + +def test_rfc3339_parser_rejects_calendar_invalid_date() -> None: + with pytest.raises( + calendar_projection.NaruonCalendarContractError, + match="RFC 3339", + ): + calendar_projection._parse_rfc3339( + "2026-13-21T09:00:00Z", + field_name="observed_at", + ) + + +@pytest.mark.parametrize( + "factory", + [_NaiveDateTimeFactory, _NoOffsetDateTimeFactory], +) +def test_rfc3339_parser_rejects_runtime_without_usable_offset( + monkeypatch: pytest.MonkeyPatch, + factory: object, +) -> None: + monkeypatch.setattr(calendar_projection, "datetime", factory) + + with pytest.raises( + calendar_projection.NaruonCalendarContractError, + match="include an offset", + ): + calendar_projection._parse_rfc3339( + "2026-08-21T09:00:00Z", + field_name="observed_at", + ) From abd24015bed19b8f0ab6cac2462db36d2e6803fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:58:36 -0700 Subject: [PATCH 14/44] docs(plan): add calendar contract TDD plan --- ...-21-naruon-calendar-projection-contract.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-21-naruon-calendar-projection-contract.md diff --git a/docs/superpowers/plans/2026-08-21-naruon-calendar-projection-contract.md b/docs/superpowers/plans/2026-08-21-naruon-calendar-projection-contract.md new file mode 100644 index 000000000..975572f19 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-naruon-calendar-projection-contract.md @@ -0,0 +1,72 @@ +# Naruon Calendar Projection Contract Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement each task with test-first verification. + +**Goal:** Publish a strict read-only LineageWeave consumer contract for calendar observations authorized and projected by Naruon, without making LineageWeave a CalDAV/provider authority. + +**Architecture:** Keep commitments and issue/todo records authoritative in LineageWeave. Consume Naruon event occurrences through one bounded service-to-service HTTP contract, reject schema or policy drift fail-closed, and leave provider synchronization, revisions, credentials, writeback, retry, and reconciliation in Naruon. + +**Tech Stack:** Python 3.12+, dataclasses, JSON Schema Draft 2020-12, RFC 3339, pytest, coverage.py, shared bounded HTTP client. + +**Spec:** `docs/adr/0123-naruon-calendar-projection-boundary.md` + +## Global Constraints + +- No provider or end-user credential enters this contract. +- External occurrences are `observed`; LineageWeave commitments remain a separate authority. +- Windows are at most 366 days, pages at most 200 events, and response bodies at most 1 MiB. +- Unknown versions, fields, vocabularies, URL-shaped references, duplicate occurrences, and invalid clocks fail closed. +- Runtime wiring remains disabled until Naruon publishes an immutable provider contract and audience. +- Changed production statement/branch coverage and public docstrings must reach 100%. + +--- + +### Task 1: Write strict parser and transport RED tests + +**Files:** +- Create: `tests/test_naruon_calendar_projection.py` +- Create: `tests/test_naruon_calendar_projection_edges.py` +- Modify: `tests/test_http_client.py` +- Modify: `tests/test_http_client_edges.py` + +**Interfaces:** +- Produces expected public API: `parse_naruon_calendar_page` and `NaruonCalendarProjectionClient`. + +- [ ] Add failing tests for strict fields, timestamps, closed vocabularies, duplicate occurrence identity, cursor/base URL safety, service-token whitespace, numeric bounds, response-byte bounds, and public exports. +- [ ] Run the focused tests and confirm failure is caused by the missing contract and byte-bound transport. + +### Task 2: Implement bounded package contract + +**Files:** +- Create: `lineageweave/naruon_calendar_projection.py` +- Modify: `lineageweave/http_client.py` +- Modify: `lineageweave/__init__.py` + +**Interfaces:** +- Produces: `parse_naruon_calendar_page(payload, *, maximum_events=200) -> NaruonCalendarPage`. +- Produces: `NaruonCalendarProjectionClient.list_events(...) -> NaruonCalendarPage`. +- Extends: `get_json(..., maximum_response_bytes=None)`. + +- [ ] Implement immutable occurrence/page models and exact parser validation. +- [ ] Implement service-credential transport with 366-day, 200-row, 1 MiB, and 30-second ceilings. +- [ ] Reject whitespace/control-bearing tokens and ambiguous numeric values. +- [ ] Export the supported package surface. +- [ ] Run focused tests until green, then run branch coverage at 100%. + +### Task 3: Record truth, standards, and activation boundary + +**Files:** +- Modify: `docs/adr/0038-calendar-source-contract.md` +- Create: `docs/adr/0123-naruon-calendar-projection-boundary.md` +- Create: `docs/contracts/naruon-calendar-projection-v1.schema.json` +- Create: `docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md` +- Create: `CHANGELOG.d/naruon-calendar-projection-contract.md` + +**Interfaces:** +- Produces one immutable provider/consumer schema for Naruon conformance fixtures. + +- [ ] Correct the pseudo-CalDAV product claim while preserving events-versus-commitments separation. +- [ ] Record the Naruon/LineageWeave authority and runtime activation gate. +- [ ] Add RFC 4791, RFC 5545, RFC 6578, PROV-O, and bounded-resource traceability. +- [ ] Validate JSON syntax, ADR uniqueness, documentation hygiene, compileall, Ruff, and diff hygiene. +- [ ] Open a clean PR from current protected `main`, supersede the inherited stacked PR, and keep runtime integration disabled. From d808690d63e5dc5e2ee7741ad694b88fbce60238 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:02:46 -0700 Subject: [PATCH 15/44] test(calendar): reject surrounding contract whitespace --- ...t_naruon_calendar_projection_whitespace.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/test_naruon_calendar_projection_whitespace.py diff --git a/tests/test_naruon_calendar_projection_whitespace.py b/tests/test_naruon_calendar_projection_whitespace.py new file mode 100644 index 000000000..30ff871f3 --- /dev/null +++ b/tests/test_naruon_calendar_projection_whitespace.py @@ -0,0 +1,73 @@ +"""Regression tests for exact calendar contract string boundaries.""" + +from __future__ import annotations + +import pytest + +from lineageweave.naruon_calendar_projection import ( + NaruonCalendarContractError, + NaruonCalendarProjectionClient, + parse_naruon_calendar_page, +) + + +def _page(**event_overrides: object) -> dict[str, object]: + event: dict[str, object] = { + "event_reference": "evt_001", + "occurrence_reference": "occ_001", + "source_reference": "src_001", + "provider_revision": 'W/"revision-7"', + "display_text": "Customer review", + "starts_at": "2026-08-24T09:00:00+09:00", + "ends_at": "2026-08-24T10:00:00+09:00", + "all_day": False, + "time_zone": "Asia/Seoul", + "status_code": "confirmed", + "disclosure_code": "summary_visible", + "truth_status_code": "observed", + "observed_at": "2026-08-21T00:00:00Z", + } + event.update(event_overrides) + return { + "schema_version": "1.0", + "projection_revision": "projection_001", + "events": [event], + "next_cursor": None, + } + + +@pytest.mark.parametrize( + "token", + [" service-secret", "service-secret ", "\nservice-secret", "service-secret\n"], +) +def test_service_token_rejects_surrounding_whitespace(token: str) -> None: + with pytest.raises(NaruonCalendarContractError, match="whitespace"): + NaruonCalendarProjectionClient("https://naruon.example", token) + + +@pytest.mark.parametrize( + "base_url", + [" https://naruon.example", "https://naruon.example "], +) +def test_base_url_rejects_surrounding_whitespace(base_url: str) -> None: + with pytest.raises(NaruonCalendarContractError, match="whitespace"): + NaruonCalendarProjectionClient(base_url, "service-secret") + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("source_reference", " src_001"), + ("source_reference", "src_001 "), + ("display_text", " Customer review"), + ("display_text", "Customer review "), + ("starts_at", " 2026-08-24T09:00:00+09:00"), + ("observed_at", "2026-08-21T00:00:00Z "), + ], +) +def test_projection_rejects_silently_normalized_text( + field: str, + value: str, +) -> None: + with pytest.raises(NaruonCalendarContractError, match="whitespace"): + parse_naruon_calendar_page(_page(**{field: value})) From fd614aaad9cb33d9e339540984802e692287b9df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:06:16 -0700 Subject: [PATCH 16/44] fix(calendar): reject silent contract string normalization --- lineageweave/naruon_calendar_projection.py | 203 ++++++++++----------- 1 file changed, 101 insertions(+), 102 deletions(-) diff --git a/lineageweave/naruon_calendar_projection.py b/lineageweave/naruon_calendar_projection.py index e065a7faf..d42f28479 100644 --- a/lineageweave/naruon_calendar_projection.py +++ b/lineageweave/naruon_calendar_projection.py @@ -1,8 +1,8 @@ """Strict consumer contract for Naruon-owned calendar event projections. -LineageWeave owns post-grounded customer commitments and issue tickets. Naruon -owns customer calendar provider access, CalDAV synchronization, provider -revision handling, and policy filtering. This module consumes only a bounded, +LineageWeave owns post-grounded commitments and issue tickets. Naruon owns +customer calendar provider access, CalDAV synchronization, provider revisions, +writeback, retry, and reconciliation. This module consumes only a bounded, already-authorized Naruon read projection; it is deliberately not a CalDAV client and never receives provider credentials or an end-user bearer token. """ @@ -34,6 +34,23 @@ r"^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:" r"[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})$" ) +_OCCURRENCE_FIELDS = frozenset( + { + "event_reference", + "occurrence_reference", + "source_reference", + "provider_revision", + "display_text", + "starts_at", + "ends_at", + "all_day", + "time_zone", + "status_code", + "disclosure_code", + "truth_status_code", + "observed_at", + } +) class NaruonCalendarContractError(ValueError): @@ -76,7 +93,7 @@ def _strict_object( required: frozenset[str], optional: frozenset[str] = frozenset(), ) -> dict[str, Any]: - """Return a mapping with exactly the admitted required/optional fields.""" + """Return a mapping containing exactly the admitted fields.""" if not isinstance(value, dict): raise NaruonCalendarContractError(f"{field_name} must be an object") @@ -101,36 +118,36 @@ def _bounded_text( *, field_name: str, maximum_length: int, - allow_whitespace: bool = True, + allow_internal_whitespace: bool = True, allow_url_shape: bool = True, ) -> str: - """Validate one bounded text field without disclosing its value in errors.""" + """Validate exact bounded text without silently normalizing identity.""" if not isinstance(value, str): raise NaruonCalendarContractError(f"{field_name} must be a string") - normalized = value.strip() - if not normalized or len(normalized) > maximum_length: + if value != value.strip(): + raise NaruonCalendarContractError( + f"{field_name} must not contain surrounding whitespace" + ) + if not value or len(value) > maximum_length: raise NaruonCalendarContractError( f"{field_name} must contain 1..{maximum_length} characters" ) - if any( - ord(character) < 32 or ord(character) == 127 - for character in normalized - ): + if any(ord(character) < 32 or ord(character) == 127 for character in value): raise NaruonCalendarContractError( f"{field_name} contains control characters" ) - if not allow_whitespace and any( - character.isspace() for character in normalized + if not allow_internal_whitespace and any( + character.isspace() for character in value ): raise NaruonCalendarContractError( - f"{field_name} must be an opaque token" + f"{field_name} must be an opaque token without whitespace" ) - if not allow_url_shape and "://" in normalized: + if not allow_url_shape and "://" in value: raise NaruonCalendarContractError( f"{field_name} must not contain a URL" ) - return normalized + return value def _bounded_integer( @@ -140,7 +157,7 @@ def _bounded_integer( minimum: int, maximum: int, ) -> int: - """Return one true integer inside an inclusive contract range.""" + """Return one true integer inside an inclusive range.""" if isinstance(value, bool) or not isinstance(value, int): raise ValueError(f"{field_name} must be an integer") @@ -156,44 +173,42 @@ def _bounded_timeout(value: Any) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError("timeout must be a finite number") - normalized = float(value) - if not math.isfinite(normalized) or not 0 < normalized <= 30: + timeout = float(value) + if not math.isfinite(timeout) or not 0 < timeout <= 30: raise ValueError( "timeout must be greater than 0 and at most 30 seconds" ) - return normalized + return timeout def _opaque_reference(value: Any, *, field_name: str) -> str: - """Validate an opaque non-URL reference token.""" + """Validate a bounded opaque non-URL token.""" return _bounded_text( value, field_name=field_name, maximum_length=256, - allow_whitespace=False, + allow_internal_whitespace=False, allow_url_shape=False, ) def _parse_rfc3339(value: Any, *, field_name: str) -> datetime: - """Parse one offset-aware RFC 3339 instant.""" + """Parse one exact offset-aware RFC 3339 instant.""" text = _bounded_text( value, field_name=field_name, maximum_length=64, + allow_internal_whitespace=False, ) if _RFC3339_PATTERN.fullmatch(text) is None: raise NaruonCalendarContractError( f"{field_name} must be RFC 3339" ) - normalized_text = text[:10] + "T" + text[11:] - normalized = ( - f"{normalized_text[:-1]}+00:00" - if normalized_text.endswith(("Z", "z")) - else normalized_text - ) + normalized = text[:10] + "T" + text[11:] + if normalized.endswith(("Z", "z")): + normalized = f"{normalized[:-1]}+00:00" try: parsed = datetime.fromisoformat(normalized) except ValueError as exc: @@ -213,13 +228,13 @@ def _controlled_code( field_name: str, allowed: frozenset[str], ) -> str: - """Validate one closed vocabulary value.""" + """Validate one closed-vocabulary code.""" code = _bounded_text( value, field_name=field_name, maximum_length=64, - allow_whitespace=False, + allow_internal_whitespace=False, ) if code not in allowed: raise NaruonCalendarContractError( @@ -228,67 +243,54 @@ def _controlled_code( return code -def _parse_occurrence( - value: Any, - *, - index: int, -) -> NaruonCalendarOccurrence: - """Parse one strict calendar occurrence from a projection page.""" +def _parse_occurrence(value: Any, *, index: int) -> NaruonCalendarOccurrence: + """Parse one strict occurrence from a projection page.""" field_name = f"events[{index}]" row = _strict_object( value, field_name=field_name, - required=frozenset( - { - "event_reference", - "occurrence_reference", - "source_reference", - "provider_revision", - "display_text", - "starts_at", - "ends_at", - "all_day", - "time_zone", - "status_code", - "disclosure_code", - "truth_status_code", - "observed_at", - } - ), + required=_OCCURRENCE_FIELDS, ) if not isinstance(row["all_day"], bool): raise NaruonCalendarContractError( f"{field_name}.all_day must be a boolean" ) - starts_at = _parse_rfc3339( + starts_text = _bounded_text( row["starts_at"], field_name=f"{field_name}.starts_at", + maximum_length=64, + allow_internal_whitespace=False, ) - ends_at = _parse_rfc3339( + ends_text = _bounded_text( row["ends_at"], field_name=f"{field_name}.ends_at", + maximum_length=64, + allow_internal_whitespace=False, + ) + observed_text = _bounded_text( + row["observed_at"], + field_name=f"{field_name}.observed_at", + maximum_length=64, + allow_internal_whitespace=False, ) + starts_at = _parse_rfc3339(starts_text, field_name=f"{field_name}.starts_at") + ends_at = _parse_rfc3339(ends_text, field_name=f"{field_name}.ends_at") + _parse_rfc3339(observed_text, field_name=f"{field_name}.observed_at") if ends_at <= starts_at: raise NaruonCalendarContractError( f"{field_name}.ends_at must be after starts_at" ) - _parse_rfc3339( - row["observed_at"], - field_name=f"{field_name}.observed_at", - ) return NaruonCalendarOccurrence( event_reference=_opaque_reference( - row["event_reference"], - field_name=f"{field_name}.event_reference", + row["event_reference"], field_name=f"{field_name}.event_reference" ), occurrence_reference=_opaque_reference( row["occurrence_reference"], field_name=f"{field_name}.occurrence_reference", ), source_reference=_opaque_reference( - row["source_reference"], - field_name=f"{field_name}.source_reference", + row["source_reference"], field_name=f"{field_name}.source_reference" ), provider_revision=_bounded_text( row["provider_revision"], @@ -301,14 +303,14 @@ def _parse_occurrence( field_name=f"{field_name}.display_text", maximum_length=512, ), - starts_at=str(row["starts_at"]).strip(), - ends_at=str(row["ends_at"]).strip(), + starts_at=starts_text, + ends_at=ends_text, all_day=row["all_day"], time_zone=_bounded_text( row["time_zone"], field_name=f"{field_name}.time_zone", maximum_length=128, - allow_whitespace=False, + allow_internal_whitespace=False, ), status_code=_controlled_code( row["status_code"], @@ -325,7 +327,7 @@ def _parse_occurrence( field_name=f"{field_name}.truth_status_code", allowed=_ALLOWED_TRUTH_STATUS_CODES, ), - observed_at=str(row["observed_at"]).strip(), + observed_at=observed_text, ) @@ -334,11 +336,7 @@ def parse_naruon_calendar_page( *, maximum_events: int = 200, ) -> NaruonCalendarPage: - """Validate and convert one Naruon calendar projection page. - - The parser rejects unknown fields and vocabulary values so provider or - policy changes cannot silently broaden what LineageWeave exposes. - """ + """Validate and convert one Naruon calendar projection page.""" admitted_maximum = _bounded_integer( maximum_events, @@ -372,26 +370,25 @@ def parse_naruon_calendar_page( "calendar_page.events exceeds the admitted page size" ) events = tuple( - _parse_occurrence(row, index=index) - for index, row in enumerate(rows) + _parse_occurrence(row, index=index) for index, row in enumerate(rows) ) - occurrence_references = [ - event.occurrence_reference for event in events - ] + occurrence_references = [event.occurrence_reference for event in events] if len(occurrence_references) != len(set(occurrence_references)): raise NaruonCalendarContractError( "calendar_page contains duplicate occurrence references" ) next_cursor_value = root.get("next_cursor") - next_cursor = None - if next_cursor_value is not None: - next_cursor = _bounded_text( + next_cursor = ( + None + if next_cursor_value is None + else _bounded_text( next_cursor_value, field_name="calendar_page.next_cursor", maximum_length=1024, - allow_whitespace=False, + allow_internal_whitespace=False, allow_url_shape=False, ) + ) return NaruonCalendarPage( schema_version=NARUON_CALENDAR_SCHEMA_VERSION, projection_revision=projection_revision, @@ -401,7 +398,7 @@ def parse_naruon_calendar_page( class NaruonCalendarProjectionClient: - """Read a bounded Naruon calendar projection with one service credential.""" + """Read a bounded Naruon calendar projection with a service credential.""" def __init__( self, @@ -417,7 +414,7 @@ def __init__( base_url, field_name="base_url", maximum_length=2048, - allow_whitespace=False, + allow_internal_whitespace=False, ) parsed = urlparse(normalized_base) if parsed.scheme not in {"http", "https"} or not parsed.hostname: @@ -427,28 +424,24 @@ def __init__( if parsed.username is not None or parsed.password is not None: raise ValueError("base_url must not contain userinfo") if parsed.query or parsed.fragment: - raise ValueError( - "base_url must not contain a query or fragment" - ) + raise ValueError("base_url must not contain a query or fragment") token = _bounded_text( service_access_token, field_name="service_access_token", maximum_length=4096, - allow_whitespace=False, + allow_internal_whitespace=False, ) - admitted_maximum = _bounded_integer( + self._events_url = ( + f"{normalized_base.rstrip('/')}{NARUON_CALENDAR_EVENTS_PATH}" + ) + self._service_access_token = token + self._maximum_events = _bounded_integer( maximum_events, field_name="maximum_events", minimum=1, maximum=200, ) - admitted_timeout = _bounded_timeout(timeout) - self._events_url = ( - f"{normalized_base.rstrip('/')}{NARUON_CALENDAR_EVENTS_PATH}" - ) - self._service_access_token = token - self._maximum_events = admitted_maximum - self._timeout = admitted_timeout + self._timeout = _bounded_timeout(timeout) def list_events( self, @@ -459,21 +452,27 @@ def list_events( ) -> NaruonCalendarPage: """Return one authorized event page within an offset-aware window.""" - starts_at = _parse_rfc3339( + start_text = _bounded_text( window_start, field_name="window_start", + maximum_length=64, + allow_internal_whitespace=False, ) - ends_at = _parse_rfc3339( + end_text = _bounded_text( window_end, field_name="window_end", + maximum_length=64, + allow_internal_whitespace=False, ) + starts_at = _parse_rfc3339(start_text, field_name="window_start") + ends_at = _parse_rfc3339(end_text, field_name="window_end") if ends_at <= starts_at: raise ValueError("window_end must be after window_start") if ends_at - starts_at > _MAX_WINDOW: raise ValueError("calendar window must not exceed 366 days") fields = { - "window_start": window_start.strip(), - "window_end": window_end.strip(), + "window_start": start_text, + "window_end": end_text, "limit": str(self._maximum_events), } if cursor is not None: @@ -481,7 +480,7 @@ def list_events( cursor, field_name="cursor", maximum_length=1024, - allow_whitespace=False, + allow_internal_whitespace=False, allow_url_shape=False, ) payload = get_json( From 364dd661742c5d89c7f0a36a903af2626af21773 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:07:27 -0700 Subject: [PATCH 17/44] docs(contract): align exact string whitespace rules --- docs/contracts/naruon-calendar-projection-v1.schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contracts/naruon-calendar-projection-v1.schema.json b/docs/contracts/naruon-calendar-projection-v1.schema.json index 695d18706..c873180f4 100644 --- a/docs/contracts/naruon-calendar-projection-v1.schema.json +++ b/docs/contracts/naruon-calendar-projection-v1.schema.json @@ -81,13 +81,13 @@ "type": "string", "minLength": 1, "maxLength": 256, - "pattern": "^(?!.*://)[^\\u0000-\\u001F\\u007F]+$" + "pattern": "^(?!\\s)(?!.*\\s$)(?!.*://)[^\\u0000-\\u001F\\u007F]+$" }, "display_text": { "type": "string", "minLength": 1, "maxLength": 512, - "pattern": "^[^\\u0000-\\u001F\\u007F]+$" + "pattern": "^(?!\\s)(?!.*\\s$)[^\\u0000-\\u001F\\u007F]+$" }, "starts_at": { "$ref": "#/$defs/rfc3339" From cc54f8c453d46036d19b0a82eff98bbd555d5582 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:38:12 -0700 Subject: [PATCH 18/44] test(http): require exact JSON response media type --- tests/test_http_client_media_type.py | 75 ++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/test_http_client_media_type.py diff --git a/tests/test_http_client_media_type.py b/tests/test_http_client_media_type.py new file mode 100644 index 000000000..57be95082 --- /dev/null +++ b/tests/test_http_client_media_type.py @@ -0,0 +1,75 @@ +"""Response media-type contract tests for bounded JSON consumers.""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from lineageweave.http_client import HttpClientError, get_json + + +class _MediaTypeHandler(BaseHTTPRequestHandler): + response_media_type = "application/json" + + def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API + body = json.dumps({"ok": True}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", type(self).response_media_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib API + del format, args + + +def _serve() -> tuple[HTTPServer, str]: + server = HTTPServer(("127.0.0.1", 0), _MediaTypeHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address[:2] + return server, f"http://{host}:{port}" + + +def test_get_json_accepts_expected_media_type_with_parameters() -> None: + _MediaTypeHandler.response_media_type = "application/json; charset=utf-8" + server, base_url = _serve() + try: + result = get_json( + f"{base_url}/projection", + timeout=2, + expected_response_media_type="application/json", + ) + finally: + server.shutdown() + + assert result == {"ok": True} + + +def test_get_json_rejects_unexpected_media_type_before_json_decode() -> None: + _MediaTypeHandler.response_media_type = "application/json" + server, base_url = _serve() + try: + with pytest.raises(HttpClientError, match="unexpected response media type"): + get_json( + f"{base_url}/projection", + timeout=2, + expected_response_media_type=( + "application/vnd.contextualwisdomlab." + "naruon-calendar.v1+json" + ), + ) + finally: + server.shutdown() + + +def test_get_json_rejects_invalid_expected_media_type_configuration() -> None: + with pytest.raises(ValueError, match="expected_response_media_type"): + get_json( + "https://naruon.example/api/calendar/events", + timeout=2, + expected_response_media_type="text/html; charset=utf-8", + ) From 51f10d0aff95a076b86302aa664199ec6f7ef2e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:40:28 -0700 Subject: [PATCH 19/44] fix(http): validate exact response media types --- lineageweave/http_client.py | 49 +++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py index 2b7a34872..0b486e4fc 100644 --- a/lineageweave/http_client.py +++ b/lineageweave/http_client.py @@ -41,6 +41,27 @@ def _validated_response_limit(value: int | None) -> int | None: return value +def _validated_expected_media_type(value: str | None) -> str | None: + """Return one exact lower-case type/subtype without parameters.""" + + if value is None: + return None + if ( + not isinstance(value, str) + or value != value.strip() + or not value + or value != value.lower() + or ";" in value + or value.count("/") != 1 + or any(character.isspace() for character in value) + or any(ord(character) < 32 or ord(character) == 127 for character in value) + ): + raise ValueError( + "expected_response_media_type must be an exact lower-case type/subtype" + ) + return value + + def _read_response_body( response: http.client.HTTPResponse, *, @@ -74,6 +95,15 @@ def _read_response_body( return raw +def _response_media_type(response: http.client.HTTPResponse) -> str: + """Return the normalized media type without optional parameters.""" + + header = response.getheader("Content-Type") + if header is None: + return "" + return header.split(";", 1)[0].strip().lower() + + def _request( method: str, url: str, @@ -82,10 +112,14 @@ def _request( headers: dict[str, str], timeout: float, maximum_response_bytes: int | None = None, + expected_response_media_type: str | None = None, ) -> tuple[int, bytes]: """Perform one bounded HTTP(S) request and return status plus raw bytes.""" limit = _validated_response_limit(maximum_response_bytes) + expected_media_type = _validated_expected_media_type( + expected_response_media_type + ) parsed = urlparse(url) if parsed.scheme not in _ALLOWED_SCHEMES: raise ValueError( @@ -123,6 +157,13 @@ def _request( ) connection.request(method, path, body=body, headers=headers) response = connection.getresponse() + if ( + expected_media_type is not None + and _response_media_type(response) != expected_media_type + ): + raise HttpClientError( + f"unexpected response media type from {parsed.hostname}" + ) raw = _read_response_body( response, maximum_response_bytes=limit, @@ -235,6 +276,7 @@ def get_json( headers: dict[str, str] | None = None, timeout: float, maximum_response_bytes: int | None = None, + expected_response_media_type: str | None = None, ) -> dict: """GET ``url`` and return a decoded JSON object. @@ -243,10 +285,12 @@ def get_json( headers: Optional request headers. timeout: Socket timeout in seconds. maximum_response_bytes: Optional strict response-body byte ceiling. + expected_response_media_type: Optional exact lower-case type/subtype. Raises: - ValueError: The URL or byte limit is invalid. - HttpClientError: The response is too large, HTTP >= 400, or non-JSON. + ValueError: The URL, byte limit, or expected media type is invalid. + HttpClientError: The response is too large, has the wrong media type, + returns HTTP >= 400, or is not a JSON object. """ status, raw = _request( @@ -256,6 +300,7 @@ def get_json( headers=headers or {}, timeout=timeout, maximum_response_bytes=maximum_response_bytes, + expected_response_media_type=expected_response_media_type, ) hostname = urlparse(url).hostname or url if status >= 400: From 8156ed8ca0a4a20cfe87ea67ee367d8f4dccbab3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:44:08 -0700 Subject: [PATCH 20/44] fix(calendar): require exact provider response media type --- lineageweave/naruon_calendar_projection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lineageweave/naruon_calendar_projection.py b/lineageweave/naruon_calendar_projection.py index d42f28479..52815a35c 100644 --- a/lineageweave/naruon_calendar_projection.py +++ b/lineageweave/naruon_calendar_projection.py @@ -491,6 +491,7 @@ def list_events( }, timeout=self._timeout, maximum_response_bytes=_MAX_RESPONSE_BYTES, + expected_response_media_type=NARUON_CALENDAR_MEDIA_TYPE, ) return parse_naruon_calendar_page( payload, From 72c3fa16988a50c9fcc816a6e98bd30699e22f8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:47:35 -0700 Subject: [PATCH 21/44] test(calendar): assert response media-type enforcement --- tests/test_naruon_calendar_projection.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_naruon_calendar_projection.py b/tests/test_naruon_calendar_projection.py index e341289fc..b1a858cf1 100644 --- a/tests/test_naruon_calendar_projection.py +++ b/tests/test_naruon_calendar_projection.py @@ -59,12 +59,14 @@ def fake_get_json( headers: dict[str, str], timeout: float, maximum_response_bytes: int | None, + expected_response_media_type: str | None, ) -> dict[str, object]: received.update( url=url, headers=headers, timeout=timeout, maximum_response_bytes=maximum_response_bytes, + expected_response_media_type=expected_response_media_type, ) return _page() @@ -101,6 +103,9 @@ def fake_get_json( } assert received["timeout"] == 7 assert received["maximum_response_bytes"] == 1_048_576 + assert received["expected_response_media_type"] == ( + NARUON_CALENDAR_MEDIA_TYPE + ) assert page.projection_revision == "projection_001" assert page.next_cursor == "cursor_002" assert page.events[0].occurrence_reference == "occ_001" @@ -295,10 +300,12 @@ def fake_get_json( headers: dict[str, str], timeout: float, maximum_response_bytes: int | None, + expected_response_media_type: str | None, ) -> dict[str, object]: del headers, timeout received["url"] = url received["maximum_response_bytes"] = maximum_response_bytes + received["expected_response_media_type"] = expected_response_media_type return _page() monkeypatch.setattr( @@ -314,6 +321,9 @@ def fake_get_json( assert "cursor" not in parse_qs(urlparse(str(received["url"])).query) assert received["maximum_response_bytes"] == 1_048_576 + assert received["expected_response_media_type"] == ( + NARUON_CALENDAR_MEDIA_TYPE + ) def test_json_schema_matches_parser_contract() -> None: From de0b45365663f5ae29ce1c4384616a0425335b70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:58:43 -0700 Subject: [PATCH 22/44] docs(contract): add calendar consumer conformance fixture --- ...naruon-calendar-projection-v1.example.json | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/contracts/naruon-calendar-projection-v1.example.json diff --git a/docs/contracts/naruon-calendar-projection-v1.example.json b/docs/contracts/naruon-calendar-projection-v1.example.json new file mode 100644 index 000000000..caa5f0e82 --- /dev/null +++ b/docs/contracts/naruon-calendar-projection-v1.example.json @@ -0,0 +1,22 @@ +{ + "schema_version": "1.0", + "projection_revision": "projection_fixture_001", + "events": [ + { + "event_reference": "event_fixture_001", + "occurrence_reference": "occurrence_fixture_001", + "source_reference": "source_fixture_001", + "provider_revision": "revision_fixture_007", + "display_text": "Customer review", + "starts_at": "2026-08-24T09:00:00+09:00", + "ends_at": "2026-08-24T10:00:00+09:00", + "all_day": false, + "time_zone": "Asia/Seoul", + "status_code": "confirmed", + "disclosure_code": "summary_visible", + "truth_status_code": "observed", + "observed_at": "2026-08-21T00:00:00Z" + } + ], + "next_cursor": null +} From 7fa401d0f4cb146d516b94d694adf65875555fa4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:00:18 -0700 Subject: [PATCH 23/44] test(calendar): pin consumer conformance fixture digest --- ...est_naruon_calendar_conformance_fixture.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_naruon_calendar_conformance_fixture.py diff --git a/tests/test_naruon_calendar_conformance_fixture.py b/tests/test_naruon_calendar_conformance_fixture.py new file mode 100644 index 000000000..e271858a9 --- /dev/null +++ b/tests/test_naruon_calendar_conformance_fixture.py @@ -0,0 +1,28 @@ +"""Immutable consumer fixture shared with the Naruon provider.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +from lineageweave.naruon_calendar_projection import parse_naruon_calendar_page + +_EXPECTED_FIXTURE_SHA256 = ( + "7efe5799a942779c21bf123685daa0cf201063665dd84a377214e4325bf6039d" +) + + +def test_calendar_projection_fixture_is_immutable_and_consumer_valid() -> None: + fixture_path = ( + Path(__file__).resolve().parents[1] + / "docs" + / "contracts" + / "naruon-calendar-projection-v1.example.json" + ) + fixture_bytes = fixture_path.read_bytes() + + assert hashlib.sha256(fixture_bytes).hexdigest() == _EXPECTED_FIXTURE_SHA256 + page = parse_naruon_calendar_page(json.loads(fixture_bytes)) + assert page.projection_revision == "projection_fixture_001" + assert page.events[0].occurrence_reference == "occurrence_fixture_001" From 0360feab6d38c669a180558332ad24d3001da614 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:59:38 +0900 Subject: [PATCH 24/44] fix: contain raw provider failures --- CHANGELOG.md | 5 +++ backend/app/analysis_run_start.py | 2 +- backend/app/auth.py | 4 +- backend/app/main.py | 2 +- backend/app/post_content_worker.py | 3 +- backend/tests/test_auth_jwks.py | 42 ++++++++++++++++++- .../0030-external-llm-gateway-environment.md | 7 ++++ lineageweave/adjudication_client.py | 4 +- lineageweave/commitment_extraction.py | 6 +-- lineageweave/corporate_hierarchy_inference.py | 4 +- lineageweave/customer_hint_resolution.py | 4 +- .../entity_relationship_classification.py | 4 +- lineageweave/http_client.py | 19 +++++++++ lineageweave/image_content.py | 12 ++---- lineageweave/keyman_extraction.py | 4 +- lineageweave/naruon_calendar_projection.py | 16 ++++--- lineageweave/organization_name_resolution.py | 4 +- lineageweave/post_chat.py | 6 +-- lineageweave/post_evaluation.py | 4 +- lineageweave/post_structure.py | 9 +++- lineageweave/post_summary.py | 14 +++---- lineageweave/rankweave_client.py | 6 +-- tests/test_http_client_edges.py | 18 +++++++- tests/test_post_structure.py | 17 +++++++- tests/test_rankweave_client.py | 4 +- 25 files changed, 164 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..7ce55b42f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ All notable changes to this project are documented here. Format follows - `make smoke` and `make seed` now run through the locked project `uv` environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +- Provider response parsing now rejects malformed chat envelopes without + exposing provider response bodies, exception text, or secrets through + buyer-facing APIs and persisted ingestion failure details. RankWeave, OIDC, + TEPP, structured VISION, summaries, chat, and extraction channels now retain + stable next-action-safe failure messages. ## [2.12.6] - 2026-08-20 diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 2387d940b..324c6cd67 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -104,7 +104,7 @@ def transport(payload: dict[str, Any]) -> dict[str, Any]: headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {} return post_json(url, payload, headers=headers, timeout=30.0) except (HttpClientError, OSError, ValueError, TypeError) as exc: - raise TeppNotAvailable(str(exc)) from exc + raise TeppNotAvailable("TEPP transport unavailable") from exc return TeppClient(transport=transport) diff --git a/backend/app/auth.py b/backend/app/auth.py index 155974d52..695a43a40 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -56,7 +56,7 @@ def _jwks(settings: Settings, *, force_refresh: bool = False) -> dict: except (HttpClientError, OSError, ValueError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - f"could not fetch OIDC JWKS for {settings.oidc_issuer}: {exc}", + "could not fetch OIDC JWKS: identity provider unavailable", ) from exc _jwks_cache[cache_key] = cached return cached @@ -134,7 +134,7 @@ def _decode_access_token(token: str, settings: Settings) -> dict: except HTTPException: raise except jwt.PyJWTError as exc: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid token: {exc}") from exc + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid access token") from exc subject = claims.get("sub") if not isinstance(subject, str) or not subject.strip(): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "access token has no subject") diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..38be0ff93 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2650,7 +2650,7 @@ async def ask_agent( except (HttpClientError, KeyError, OSError, ValueError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - f"Ask Agent is unavailable: {exc}", + "Ask Agent is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc cited_ids = list(answer.cited_post_ids) return { diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 458b9021f..2ee5bea82 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -37,6 +37,7 @@ _RECOVERY_INTERVAL_SECONDS = 30.0 _INCOMPLETE_FAILURE_CODE = "post_content_ingestion_incomplete" _ATTEMPT_LIMIT_FAILURE_CODE = "post_content_ingestion_attempt_limit" +_UNEXPECTED_FAILURE_DETAIL = "post-content provider operation failed; retry the ingestion job" async def _stream_tail(client: redis.Redis) -> str: @@ -267,7 +268,7 @@ async def process_post_content_job( pool, post_id, failure_code="post_content_ingestion_failed", - detail_text=str(exc)[:1000], + detail_text=_UNEXPECTED_FAILURE_DETAIL, expected_attempt_count=attempt_count, ) return diff --git a/backend/tests/test_auth_jwks.py b/backend/tests/test_auth_jwks.py index 709d2c16e..5830731ef 100644 --- a/backend/tests/test_auth_jwks.py +++ b/backend/tests/test_auth_jwks.py @@ -9,7 +9,8 @@ import pytest from fastapi import HTTPException -import backend.app.auth as auth +from backend.app import auth +from lineageweave.http_client import HttpClientError def _segment(value: dict) -> str: @@ -173,3 +174,42 @@ def test_decode_rejects_missing_subject(monkeypatch: pytest.MonkeyPatch) -> None with pytest.raises(HTTPException) as error: auth._decode_access_token("token", settings) assert error.value.status_code == 401 + + +def test_decode_hides_raw_jwt_provider_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(auth, "_signing_key", lambda settings, token: "signing-key") + + def fail_decode(*args: object, **kwargs: object) -> dict: + raise auth.jwt.InvalidTokenError("provider secret") + + monkeypatch.setattr(auth.jwt, "decode", fail_decode) + settings = SimpleNamespace( + oidc_issuer="https://id.example", + oidc_audience="lineageweave-api", + oidc_clock_skew_seconds=5, + ) + + with pytest.raises(HTTPException) as error: + auth._decode_access_token("token", settings) + assert error.value.detail == "invalid access token" + assert "provider secret" not in str(error.value.detail) + + +def test_jwks_hides_raw_identity_provider_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + auth, + "get_json", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + HttpClientError("identity provider secret") + ), + ) + settings = SimpleNamespace( + oidc_issuer="https://id.example", + oidc_discovery_uri="https://id.example/.well-known/openid-configuration", + oidc_jwks_uri_override="", + ) + + with pytest.raises(HTTPException) as error: + auth._jwks(settings) + assert error.value.detail == "could not fetch OIDC JWKS: identity provider unavailable" + assert "identity provider secret" not in str(error.value.detail) diff --git a/docs/adr/0030-external-llm-gateway-environment.md b/docs/adr/0030-external-llm-gateway-environment.md index e518ee114..4b2550866 100644 --- a/docs/adr/0030-external-llm-gateway-environment.md +++ b/docs/adr/0030-external-llm-gateway-environment.md @@ -52,12 +52,19 @@ runtime, with masking enabled; the repository contains no provider secret. The contextual-orchestrator service remains the only LLM boundary. LineageWeave does not call the provider gateway directly and does not create a fallback local score, summary, extraction, or answer when the gateway is unavailable. +Provider response envelopes and exceptions are also a trust boundary: clients +extract only the expected content and expose stable, next-action-safe error +messages. Raw provider response bodies, exception text, prompts, and secrets +must never be returned through a buyer-facing API or persisted failure detail. ## Consequences - Provider changes are deployment configuration, not source changes. - A missing or invalid gateway credential fails at the orchestrator boundary; it must not be replaced by a fabricated channel result. +- A provider failure is logged with internal correlation context where + operational logging permits, but the buyer receives a stable unavailable + message that tells them to retry or restore the provider configuration. - `CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS` must explicitly allow the hostname selected by `LLM_GATEWAY_API_URL`; wildcard allowlists are forbidden. - Local Compose development permits only the explicitly enumerated diff --git a/lineageweave/adjudication_client.py b/lineageweave/adjudication_client.py index f86f2cb73..37e4fee7f 100644 --- a/lineageweave/adjudication_client.py +++ b/lineageweave/adjudication_client.py @@ -15,7 +15,7 @@ import re from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json class AdjudicationClient(Protocol): @@ -76,7 +76,7 @@ def judge(self, candidate_label: str, record_label: str) -> float: headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) match = _CONFIDENCE_PATTERN.search(content) if match is None: return 0.0 diff --git a/lineageweave/commitment_extraction.py b/lineageweave/commitment_extraction.py index db5f7761a..6f9ed3250 100644 --- a/lineageweave/commitment_extraction.py +++ b/lineageweave/commitment_extraction.py @@ -30,7 +30,7 @@ from dataclasses import dataclass from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json @dataclass(frozen=True) @@ -165,8 +165,8 @@ def extract(self, post_title: str, post_body: str, reference_date: str) -> Custo headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) commitment = parse_commitment_response(content) if commitment is None: - raise ValueError(f"commitment response did not match the required format: {content!r}") + raise ValueError("commitment response did not match the required format") return commitment diff --git a/lineageweave/corporate_hierarchy_inference.py b/lineageweave/corporate_hierarchy_inference.py index 120caacea..9eecf23ee 100644 --- a/lineageweave/corporate_hierarchy_inference.py +++ b/lineageweave/corporate_hierarchy_inference.py @@ -34,7 +34,7 @@ from functools import lru_cache from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json LEVEL_GROUP = "group" LEVEL_COMPANY = "company" @@ -171,5 +171,5 @@ def infer(self, organization_name: str, context_text: str) -> HierarchyProposal headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_inference_response(content) diff --git a/lineageweave/customer_hint_resolution.py b/lineageweave/customer_hint_resolution.py index 3169644dc..a063c1e82 100644 --- a/lineageweave/customer_hint_resolution.py +++ b/lineageweave/customer_hint_resolution.py @@ -19,7 +19,7 @@ from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json from .organization_name_resolution import parse_resolution_response _RESOLUTION_PROMPT_TEMPLATE = """\ @@ -93,5 +93,5 @@ def resolve(self, hint_code: str, context_text: str) -> str | None: headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_resolution_response(content) diff --git a/lineageweave/entity_relationship_classification.py b/lineageweave/entity_relationship_classification.py index 50093885d..331f4957e 100644 --- a/lineageweave/entity_relationship_classification.py +++ b/lineageweave/entity_relationship_classification.py @@ -27,7 +27,7 @@ from dataclasses import dataclass from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json # post_counterparty_entity.relationship_type_code values (common_lookup_value, # category "entity_relationship_type"). VOC/VOM/VOP/VOCC/VOCO are the @@ -192,5 +192,5 @@ def classify( headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_classification_response(content, organization_names) diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py index 0b486e4fc..c682dd933 100644 --- a/lineageweave/http_client.py +++ b/lineageweave/http_client.py @@ -104,6 +104,25 @@ def _response_media_type(response: http.client.HTTPResponse) -> str: return header.split(";", 1)[0].strip().lower() +def chat_completion_content(body: object) -> str: + """Extract text from a provider chat envelope without echoing its body.""" + if not isinstance(body, dict): + raise TypeError("provider response was not an object") + choices = body.get("choices") + if not isinstance(choices, list) or not choices: + raise ValueError("provider response did not contain a choice") + first_choice = choices[0] + if not isinstance(first_choice, dict): + raise TypeError("provider response choice was not an object") + message = first_choice.get("message") + if not isinstance(message, dict): + raise TypeError("provider response message was not an object") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + raise TypeError("provider response did not contain text content") + return content + + def _request( method: str, url: str, diff --git a/lineageweave/image_content.py b/lineageweave/image_content.py index 60ad54a0e..65e1f6858 100644 --- a/lineageweave/image_content.py +++ b/lineageweave/image_content.py @@ -36,7 +36,7 @@ from PIL import Image -from .http_client import post_json +from .http_client import chat_completion_content, post_json _DATA_URI_IMG = re.compile( r']*\bsrc\s*=\s*["\']data:(image/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["\']', @@ -261,9 +261,7 @@ def _parse_description(content: str) -> ImageDescription: fields["TEXT"].append(_strip_outer_markdown_emphasis(line)) if not fields["TEXT"] and not fields["CAPTION"]: - raise ImageDescriptionParseError( - f"vision response had neither TEXT nor CAPTION content: {content!r}" - ) + raise ImageDescriptionParseError("vision response had neither TEXT nor CAPTION content") extracted_text = "\n".join(fields["TEXT"]).strip() if extracted_text.upper() == "NONE": @@ -342,7 +340,7 @@ def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return _parse_description(content) def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: @@ -375,9 +373,7 @@ def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegio headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] - if not isinstance(content, str): - raise ValueError("vision region response was not text JSON") + content = chat_completion_content(body) fenced = re.sub(r"^\s*```(?:json)?\s*|\s*```\s*$", "", content, flags=re.IGNORECASE) document = json.loads(fenced) if not isinstance(document, dict): diff --git a/lineageweave/keyman_extraction.py b/lineageweave/keyman_extraction.py index 717a01783..c9afd9057 100644 --- a/lineageweave/keyman_extraction.py +++ b/lineageweave/keyman_extraction.py @@ -24,7 +24,7 @@ from dataclasses import dataclass, field from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json OUR_SIDE = "our_side" COUNTERPARTY = "counterparty" @@ -209,5 +209,5 @@ def extract_with_hints( headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_keyman_response(content) diff --git a/lineageweave/naruon_calendar_projection.py b/lineageweave/naruon_calendar_projection.py index 52815a35c..f49f422c8 100644 --- a/lineageweave/naruon_calendar_projection.py +++ b/lineageweave/naruon_calendar_projection.py @@ -160,7 +160,9 @@ def _bounded_integer( """Return one true integer inside an inclusive range.""" if isinstance(value, bool) or not isinstance(value, int): - raise ValueError(f"{field_name} must be an integer") + raise ValueError( # noqa: TRY004 - public constructor keeps ValueError compatibility. + f"{field_name} must be an integer" + ) if not minimum <= value <= maximum: raise ValueError( f"{field_name} must be between {minimum} and {maximum}" @@ -172,7 +174,9 @@ def _bounded_timeout(value: Any) -> float: """Return a finite timeout in the supported transport range.""" if isinstance(value, bool) or not isinstance(value, (int, float)): - raise ValueError("timeout must be a finite number") + raise ValueError( # noqa: TRY004 - public constructor keeps ValueError compatibility. + "timeout must be a finite number" + ) timeout = float(value) if not math.isfinite(timeout) or not 0 < timeout <= 30: raise ValueError( @@ -200,7 +204,7 @@ def _parse_rfc3339(value: Any, *, field_name: str) -> datetime: value, field_name=field_name, maximum_length=64, - allow_internal_whitespace=False, + allow_internal_whitespace=True, ) if _RFC3339_PATTERN.fullmatch(text) is None: raise NaruonCalendarContractError( @@ -260,19 +264,19 @@ def _parse_occurrence(value: Any, *, index: int) -> NaruonCalendarOccurrence: row["starts_at"], field_name=f"{field_name}.starts_at", maximum_length=64, - allow_internal_whitespace=False, + allow_internal_whitespace=True, ) ends_text = _bounded_text( row["ends_at"], field_name=f"{field_name}.ends_at", maximum_length=64, - allow_internal_whitespace=False, + allow_internal_whitespace=True, ) observed_text = _bounded_text( row["observed_at"], field_name=f"{field_name}.observed_at", maximum_length=64, - allow_internal_whitespace=False, + allow_internal_whitespace=True, ) starts_at = _parse_rfc3339(starts_text, field_name=f"{field_name}.starts_at") ends_at = _parse_rfc3339(ends_text, field_name=f"{field_name}.ends_at") diff --git a/lineageweave/organization_name_resolution.py b/lineageweave/organization_name_resolution.py index d79cc60a3..b2a238628 100644 --- a/lineageweave/organization_name_resolution.py +++ b/lineageweave/organization_name_resolution.py @@ -27,7 +27,7 @@ from dataclasses import dataclass from typing import Protocol -from .http_client import HttpClientError, post_json +from .http_client import HttpClientError, chat_completion_content, post_json from .relation_verification import ( STATUS_PENDING, RelationVerificationClient, @@ -150,7 +150,7 @@ def resolve(self, raw_name: str, context_text: str) -> str | None: headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_resolution_response(content) diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index cb5c9ce0c..4041fe165 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -24,7 +24,7 @@ from dataclasses import dataclass, field from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json CANONICAL_CHAT_QUESTION = "What happened between these events?" CANONICAL_INVOLVED_QUESTION = "Who is involved?" @@ -320,8 +320,8 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) answer = _parse_plain_chat_response(content, sources) if answer is None: - raise ValueError(f"chat response did not match the required format: {content!r}") + raise ValueError("chat response did not match the required format") return answer diff --git a/lineageweave/post_evaluation.py b/lineageweave/post_evaluation.py index e884179ea..fd4a1a4b4 100644 --- a/lineageweave/post_evaluation.py +++ b/lineageweave/post_evaluation.py @@ -14,7 +14,7 @@ from fast_mlsirm import ContextualOrchestratorJudge, JudgeCriterion, LLMJudgeResult -from .http_client import post_json +from .http_client import chat_completion_content, post_json RUBRIC_VERSION = "2026-08-13" IRT_CATEGORY_COUNT = 5 @@ -100,7 +100,7 @@ def complete(self, messages: list[dict[str, Any]], mode: str = "auto") -> dict[s timeout=self._timeout, ) return { - "answer": body["choices"][0]["message"]["content"], + "answer": chat_completion_content(body), "mode": mode, "trace": [], } diff --git a/lineageweave/post_structure.py b/lineageweave/post_structure.py index 4331f65fb..3ef18ab0b 100644 --- a/lineageweave/post_structure.py +++ b/lineageweave/post_structure.py @@ -147,8 +147,13 @@ def _response_content(response: Any) -> str: choices = response.get("choices") if isinstance(response, dict) else None if not isinstance(choices, list) or not choices: raise ValueError("structure adjudication response has no choices") - message = choices[0].get("message") - content = message.get("content") if isinstance(message, dict) else None + first_choice = choices[0] + if not isinstance(first_choice, dict): + raise ValueError("structure adjudication response has no choice object") + message = first_choice.get("message") + if not isinstance(message, dict): + raise ValueError("structure adjudication response has no message object") + content = message.get("content") if isinstance(content, str): return content.strip() if isinstance(content, list): diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index e684bde9a..441b7d88d 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -43,7 +43,7 @@ from dataclasses import dataclass, field from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json # common_lookup_value category "prov_agent_type" -- PROV-O's prov:Person / # prov:Organization for the micro/macro cases, plus a meso-level third @@ -949,10 +949,10 @@ def summarize_with_hints( headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) parsed = _parse_plain_summary_response(content) if parsed is None: - raise ValueError(f"summary response did not match the required format: {content!r}") + raise ValueError("summary response did not match the required format") korean_summary, key_events, key_event_details = parsed details_body = post_json( f"{self._base_url}/v1/chat/completions", @@ -974,16 +974,14 @@ def summarize_with_hints( headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) + details_content = chat_completion_content(details_body) details = _parse_plain_summary_details( - details_body["choices"][0]["message"]["content"], + details_content, post_title=post_title, context_hints=context_hints, ) if details is None: - raise ValueError( - "summary semantic response did not match the required format: " - f"{details_body['choices'][0]['message']['content']!r}" - ) + raise ValueError("summary semantic response did not match the required format") roles, projects, actions, five_w1h_evidence = details return PostSummary( korean_summary=korean_summary, diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py index eb0b3358b..1e5f77eb2 100644 --- a/lineageweave/rankweave_client.py +++ b/lineageweave/rankweave_client.py @@ -224,11 +224,11 @@ def __call__( ) except Exception as exc: raise RankWeaveNotAvailable( - f"rankweave_not_available: weighted_reciprocal_rank_fuse failed ({exc})" + "rankweave_not_available: weighted_reciprocal_rank_fuse failed" ) from exc except Exception as exc: raise RankWeaveNotAvailable( - f"rankweave_not_available: weighted_reciprocal_rank_fuse failed ({exc})" + "rankweave_not_available: weighted_reciprocal_rank_fuse failed" ) from exc projected: list[dict[str, Any]] = [] for hit in hits: @@ -268,7 +268,7 @@ def fuse_rankings( raise except Exception as exc: raise RankWeaveNotAvailable( - f"rankweave_not_available: ranking transport failed ({exc})" + "rankweave_not_available: ranking transport failed" ) from exc return project_ranking_list(raw, titles_by_id) diff --git a/tests/test_http_client_edges.py b/tests/test_http_client_edges.py index 4cff7c7df..2bbf3a96c 100644 --- a/tests/test_http_client_edges.py +++ b/tests/test_http_client_edges.py @@ -2,7 +2,7 @@ import pytest -import lineageweave.http_client as http_client +from lineageweave import http_client class _ResponseStub: @@ -56,6 +56,22 @@ def test_json_helpers_reject_non_json_and_wrong_shapes( ) +@pytest.mark.parametrize( + "body", + [ + {"error": "provider secret response"}, + {"choices": []}, + {"choices": [{"message": {"content": ""}}]}, + ], +) +def test_chat_completion_content_rejects_malformed_provider_envelopes(body: object) -> None: + """Malformed provider bodies produce stable errors without response reprs.""" + with pytest.raises((TypeError, ValueError)) as captured: + http_client.chat_completion_content(body) + + assert "provider secret" not in str(captured.value) + + @pytest.mark.parametrize( "helper", [ diff --git a/tests/test_post_structure.py b/tests/test_post_structure.py index 89e518fda..6e07242c8 100644 --- a/tests/test_post_structure.py +++ b/tests/test_post_structure.py @@ -1,6 +1,11 @@ import json -from lineageweave.post_structure import ContextualOrchestratorPostStructureClient +import pytest + +from lineageweave.post_structure import ( + ContextualOrchestratorPostStructureClient, + _response_content, +) def test_structure_client_validates_complete_decisions(monkeypatch) -> None: @@ -43,3 +48,13 @@ def fake_post_json(*args, **kwargs): assert response_format["json_schema"]["strict"] is True assert response_format["json_schema"]["schema"]["required"] == ["decisions"] assert captured[0]["max_tokens"] == 4096 + + +@pytest.mark.parametrize( + "response", + [{"choices": ["provider secret"]}, {"choices": [{"message": "provider secret"}]}], +) +def test_structure_response_rejects_raw_provider_shapes(response: object) -> None: + with pytest.raises(ValueError, match="structure adjudication response") as error: + _response_content(response) + assert "provider secret" not in str(error.value) diff --git a/tests/test_rankweave_client.py b/tests/test_rankweave_client.py index 64c5e70b1..3292a7cc5 100644 --- a/tests/test_rankweave_client.py +++ b/tests/test_rankweave_client.py @@ -99,11 +99,13 @@ def weighted_reciprocal_rank_fuse(*_args: object, **_kwargs: object) -> list: "lineageweave.rankweave_client._import_rankweave", lambda: FakeRw ) client = RankWeaveClient(transport=LibraryRankWeaveTransport()) - with pytest.raises(RankWeaveNotAvailable, match="rankweave_not_available"): + with pytest.raises(RankWeaveNotAvailable) as error: client.fuse_rankings( {"temporal": ["post-1"], "lexical": ["post-1"]}, {"post-1": "Public post"}, ) + assert "rankweave_not_available" in str(error.value) + assert "duplicate identifiers" not in str(error.value) def test_injected_transport_returns_accepted_hits() -> None: From 4eab33806ea0763e9ec5f6ca2f008f71d00e9ccc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:27:25 -0700 Subject: [PATCH 25/44] test: specify ontology Pages publication contract --- tests/test_ontology_site.py | 166 ++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/test_ontology_site.py diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py new file mode 100644 index 000000000..50b7841dd --- /dev/null +++ b/tests/test_ontology_site.py @@ -0,0 +1,166 @@ +"""Contract tests for the deterministic LineageWeave ontology Pages site.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path + +from rdflib import Graph +from rdflib.compare import isomorphic + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "build_ontology_site.py" + + +def _load_builder(): + spec = importlib.util.spec_from_file_location("build_ontology_site", SCRIPT) + if spec is None or spec.loader is None: + raise AssertionError("ontology site builder could not be loaded") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _tree_hashes(root: Path) -> dict[str, str]: + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def test_build_publishes_dereferenceable_html_and_machine_formats(tmp_path: Path) -> None: + builder = _load_builder() + output = tmp_path / "site" + + builder.build_site(ROOT, output) + + ontology_dir = output / "ontology" + assert (output / ".nojekyll").is_file() + assert (output / "index.html").is_file() + assert (ontology_dir / "index.html").is_file() + assert (ontology_dir / "ontology.ttl").read_bytes() == ( + ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" + ).read_bytes() + assert (ontology_dir / "prov-o-support-profile.ttl").is_file() + + html = (ontology_dir / "index.html").read_text(encoding="utf-8") + assert '' in html + assert 'id="Post"' in html + assert 'href="#Post"' in html + assert "A <source> post & evidence." in html + assert "ontology.ttl" in html + assert "ontology.jsonld" in html + assert "ontology.nt" in html + + +def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: + builder = _load_builder() + output = tmp_path / "site" + builder.build_site(ROOT, output) + + source = Graph().parse(ROOT / "docs" / "ontology" / "lineageweave-kg.ttl", format="turtle") + jsonld = Graph().parse(output / "ontology" / "ontology.jsonld", format="json-ld") + ntriples = Graph().parse(output / "ontology" / "ontology.nt", format="nt") + + assert isomorphic(source, jsonld) + assert isomorphic(source, ntriples) + + +def test_build_is_byte_deterministic(tmp_path: Path) -> None: + builder = _load_builder() + first = tmp_path / "first" + second = tmp_path / "second" + + builder.build_site(ROOT, first) + builder.build_site(ROOT, second) + + assert _tree_hashes(first) == _tree_hashes(second) + + +def test_metadata_manifest_has_source_digest_and_no_build_clock(tmp_path: Path) -> None: + builder = _load_builder() + output = tmp_path / "site" + builder.build_site(ROOT, output) + + manifest = json.loads((output / "ontology" / "manifest.json").read_text(encoding="utf-8")) + source = ROOT / "docs" / "ontology" / "lineageweave-kg.ttl" + assert manifest["source_sha256"] == hashlib.sha256(source.read_bytes()).hexdigest() + assert "built_at" not in manifest + assert manifest["documentation_url"] == "https://contextualwisdomlab.github.io/LineageWeave/ontology" + + +def test_helpers_cover_slash_fragments_json_lists_and_missing_ontology() -> None: + builder = _load_builder() + assert builder._fragment(builder.URIRef("https://example.test/vocabulary/Term")) == "Term" + assert builder._canonicalize_json({"@list": ["b", "a"]}) == {"@list": ["b", "a"]} + try: + builder._ontology_metadata(Graph()) + except ValueError as exc: + assert "owl:Ontology" in str(exc) + else: + raise AssertionError("missing owl:Ontology declaration was accepted") + + +def test_builder_fails_closed_for_missing_sources_and_replaces_output(tmp_path: Path) -> None: + builder = _load_builder() + repository = tmp_path / "repository" + output = tmp_path / "site" + output.mkdir() + (output / "stale.txt").write_text("stale", encoding="utf-8") + + try: + builder.build_site(repository, output) + except FileNotFoundError as exc: + assert "ontology source" in str(exc) + else: + raise AssertionError("missing ontology source was accepted") + + ontology_dir = repository / "docs" / "ontology" + ontology_dir.mkdir(parents=True) + (ontology_dir / "lineageweave-kg.ttl").write_text( + (ROOT / "docs" / "ontology" / "lineageweave-kg.ttl").read_text(encoding="utf-8"), + encoding="utf-8", + ) + try: + builder.build_site(repository, output) + except FileNotFoundError as exc: + assert "PROV-O support profile" in str(exc) + else: + raise AssertionError("missing PROV-O profile was accepted") + + (ontology_dir / "prov-o-support-profile.ttl").write_text("", encoding="utf-8") + builder.build_site(repository, output) + assert not (output / "stale.txt").exists() + + +def test_cli_main_and_module_entrypoint(tmp_path: Path, monkeypatch) -> None: + builder = _load_builder() + output = tmp_path / "direct" + assert builder.main(["--repository-root", str(ROOT), "--output-dir", str(output)]) == 0 + assert (output / "ontology" / "index.html").is_file() + + import runpy + import sys + + entry_output = tmp_path / "entry" + monkeypatch.setattr( + sys, + "argv", + [ + str(SCRIPT), + "--repository-root", + str(ROOT), + "--output-dir", + str(entry_output), + ], + ) + try: + runpy.run_path(str(SCRIPT), run_name="__main__") + except SystemExit as exc: + assert exc.code == 0 + else: + raise AssertionError("module entrypoint did not exit") + assert (entry_output / "ontology" / "manifest.json").is_file() From 78e97e2d6eec4977f28f9f2c61323256088d5713 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:28:58 -0700 Subject: [PATCH 26/44] feat: build deterministic ontology Pages artifact --- scripts/build_ontology_site.py | 456 +++++++++++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 scripts/build_ontology_site.py diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py new file mode 100644 index 000000000..74df7c5db --- /dev/null +++ b/scripts/build_ontology_site.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +"""Build the deterministic static LineageWeave ontology documentation site. + +The source Turtle ontology remains authoritative. This builder publishes a +human-readable, fragment-addressable HTML view plus equivalent JSON-LD and +N-Triples files without introducing a second ontology source of truth. +""" + +from __future__ import annotations + +import argparse +import hashlib +import html +import json +import shutil +from collections.abc import Iterable +from pathlib import Path +from typing import Any +from urllib.parse import quote + +from rdflib import Graph, Literal, URIRef +from rdflib.compare import to_canonical_graph +from rdflib.namespace import OWL, RDF, RDFS, SKOS + +PUBLIC_BASE_URL = "https://contextualwisdomlab.github.io/LineageWeave" +DOCUMENTATION_URL = f"{PUBLIC_BASE_URL}/ontology" +SOURCE_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg.ttl") +PROV_PROFILE_RELATIVE_PATH = Path("docs/ontology/prov-o-support-profile.ttl") +TERM_TYPES: tuple[tuple[str, URIRef], ...] = ( + ("Classes", OWL.Class), + ("Object properties", OWL.ObjectProperty), + ("Datatype properties", OWL.DatatypeProperty), + ("Annotation properties", OWL.AnnotationProperty), + ("Concept schemes", SKOS.ConceptScheme), + ("Concepts", SKOS.Concept), +) +RELATION_FIELDS: tuple[tuple[str, URIRef], ...] = ( + ("Subclass of", RDFS.subClassOf), + ("Domain", RDFS.domain), + ("Range", RDFS.range), + ("Inverse of", OWL.inverseOf), + ("Broader", SKOS.broader), + ("Narrower", SKOS.narrower), + ("In scheme", SKOS.inScheme), +) + + +def _sha256(path: Path) -> str: + """Return a lowercase SHA-256 digest for one file.""" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _fragment(value: URIRef) -> str: + """Return the stable local fragment used as the HTML anchor.""" + iri = str(value) + if "#" in iri: + return iri.rsplit("#", 1)[1] + return iri.rstrip("/").rsplit("/", 1)[-1] + + +def _preferred_literal(graph: Graph, subject: URIRef, predicate: URIRef) -> str | None: + """Choose an English, untagged, or first literal in a deterministic order.""" + literals = sorted( + (value for value in graph.objects(subject, predicate) if isinstance(value, Literal)), + key=lambda value: (value.language not in {"en", None}, value.language or "", str(value)), + ) + return str(literals[0]) if literals else None + + +def _canonicalize_json(value: Any, parent_key: str | None = None) -> Any: + """Canonicalize JSON-LD while preserving explicit ``@list`` ordering.""" + if isinstance(value, dict): + return {key: _canonicalize_json(value[key], key) for key in sorted(value)} + if isinstance(value, list): + canonical = [_canonicalize_json(item, parent_key) for item in value] + if parent_key == "@list": + return canonical + return sorted( + canonical, + key=lambda item: json.dumps( + item, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ), + ) + return value + + +def _write_serializations(graph: Graph, ontology_dir: Path) -> None: + """Write deterministic JSON-LD and line-sorted N-Triples serializations.""" + canonical_graph = to_canonical_graph(graph) + raw_jsonld = canonical_graph.serialize(format="json-ld", auto_compact=False) + parsed_jsonld = json.loads(raw_jsonld) + canonical_jsonld = _canonicalize_json(parsed_jsonld) + (ontology_dir / "ontology.jsonld").write_text( + json.dumps(canonical_jsonld, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + raw_nt = canonical_graph.serialize(format="nt") + nt_lines = sorted(line.strip() for line in raw_nt.splitlines() if line.strip()) + (ontology_dir / "ontology.nt").write_text( + "\n".join(nt_lines) + "\n", + encoding="utf-8", + ) + + +def _term_href(value: URIRef, ontology_subjects: set[URIRef]) -> str: + """Return a local fragment for local terms and an absolute IRI otherwise.""" + if value in ontology_subjects: + return f"#{quote(_fragment(value), safe='-._~')}" + return str(value) + + +def _render_link(value: URIRef, ontology_subjects: set[URIRef]) -> str: + """Render one safe HTML link for an ontology or external resource.""" + href = html.escape(_term_href(value, ontology_subjects), quote=True) + label = html.escape(_fragment(value) if value in ontology_subjects else str(value)) + external = "" if value in ontology_subjects else ' rel="external noreferrer"' + return f'{label}' + + +def _render_relation_rows( + graph: Graph, + subject: URIRef, + ontology_subjects: set[URIRef], +) -> str: + """Render standard semantic relations for one term.""" + rows: list[str] = [] + for heading, predicate in RELATION_FIELDS: + values = sorted( + (value for value in graph.objects(subject, predicate) if isinstance(value, URIRef)), + key=str, + ) + if not values: + continue + rendered = ", ".join(_render_link(value, ontology_subjects) for value in values) + rows.append(f"
{html.escape(heading)}
{rendered}
") + return "".join(rows) + + +def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) -> str: + """Render one fragment-addressable ontology term section.""" + fragment = _fragment(subject) + label = _preferred_literal(graph, subject, RDFS.label) or fragment + comment = _preferred_literal(graph, subject, RDFS.comment) + lookup_predicate = URIRef( + "https://contextualwisdomlab.github.io/lineageweave/ontology#lookupCode" + ) + lookup_codes = sorted(str(value) for value in graph.objects(subject, lookup_predicate)) + type_values = sorted( + (value for value in graph.objects(subject, RDF.type) if isinstance(value, URIRef)), + key=str, + ) + relation_rows = _render_relation_rows(graph, subject, ontology_subjects) + type_links = ", ".join(_render_link(value, ontology_subjects) for value in type_values) + lookup_html = "".join( + f"{html.escape(code)}" for code in lookup_codes + ) or "None" + comment_html = ( + f'

{html.escape(comment)}

' if comment else "" + ) + return ( + f'
' + f'

# ' + f"{html.escape(label)}

" + f'

{html.escape(str(subject))}

' + f"{comment_html}" + '
' + f"
RDF type
{type_links or 'Unspecified'}
" + f"
Lookup code
{lookup_html}
" + f"{relation_rows}" + "
" + "
" + ) + + +def _ontology_subjects(graph: Graph) -> set[URIRef]: + """Return every URI subject that belongs in the generated term inventory.""" + subjects: set[URIRef] = set() + for _, rdf_type in TERM_TYPES: + subjects.update( + subject + for subject in graph.subjects(RDF.type, rdf_type) + if isinstance(subject, URIRef) + ) + return subjects + + +def _render_term_sections(graph: Graph) -> tuple[str, str, int]: + """Render the navigation and categorized term sections.""" + subjects = _ontology_subjects(graph) + nav_items: list[str] = [] + sections: list[str] = [] + counted: set[URIRef] = set() + + for heading, rdf_type in TERM_TYPES: + terms = sorted( + ( + subject + for subject in graph.subjects(RDF.type, rdf_type) + if isinstance(subject, URIRef) + ), + key=lambda subject: ( + (_preferred_literal(graph, subject, RDFS.label) or _fragment(subject)).casefold(), + str(subject), + ), + ) + if not terms: + continue + section_id = heading.lower().replace(" ", "-") + nav_items.append( + f'
  • {html.escape(heading)} ' + f"{len(terms)}
  • " + ) + cards: list[str] = [] + for term in terms: + counted.add(term) + cards.append(_render_term(graph, term, subjects)) + sections.append( + f'
    ' + f"

    {html.escape(heading)}

    " + f'
    {"".join(cards)}
    ' + "
    " + ) + return "".join(nav_items), "".join(sections), len(counted) + + +def _ontology_metadata(graph: Graph) -> tuple[str, str, str]: + """Return ontology IRI, label, and comment from the source graph.""" + ontology_nodes = sorted( + ( + subject + for subject in graph.subjects(RDF.type, OWL.Ontology) + if isinstance(subject, URIRef) + ), + key=str, + ) + if not ontology_nodes: + raise ValueError("source graph does not declare an owl:Ontology resource") + subject = ontology_nodes[0] + label = _preferred_literal(graph, subject, RDFS.label) or "LineageWeave ontology" + comment = _preferred_literal(graph, subject, RDFS.comment) or ( + "Formal OWL 2, RDF Schema, and SKOS vocabulary for LineageWeave." + ) + return str(subject), label, comment + + +def _style_sheet() -> str: + """Return the self-contained accessible stylesheet.""" + return """ +:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; line-height: 1.55; } +* { box-sizing: border-box; } +body { margin: 0; color: #172033; background: #f5f7fb; } +a { color: #174ea6; } +a:focus-visible, button:focus-visible { outline: 3px solid #f2b705; outline-offset: 3px; } +header { color: white; background: #102a43; padding: 3rem max(1.25rem, calc((100vw - 78rem)/2)); } +header p { max-width: 70ch; color: #d9e8f5; } +header code { overflow-wrap: anywhere; } +main { max-width: 78rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; } +.downloads, .summary-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); } +.downloads a, .summary-card { display: block; padding: 1rem; border: 1px solid #c8d2df; border-radius: .75rem; background: #fff; } +.downloads a { text-decoration: none; font-weight: 700; } +.on-this-page { margin: 2rem 0; padding: 1rem 1.25rem; border-left: .35rem solid #2b6cb0; background: #eaf2fb; } +.on-this-page ul { display: flex; flex-wrap: wrap; gap: .65rem 1.25rem; list-style: none; padding: 0; } +.on-this-page span { font-variant-numeric: tabular-nums; } +.term-section { scroll-margin-top: 1rem; margin-top: 3rem; } +.term-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 25rem), 1fr)); gap: 1rem; } +.term-card { scroll-margin-top: 1rem; padding: 1.15rem; border: 1px solid #c8d2df; border-radius: .75rem; background: #fff; box-shadow: 0 1px 2px rgb(16 42 67 / 8%); } +.term-card h3 { margin-top: 0; } +.fragment-link { text-decoration: none; opacity: .55; } +.iri { overflow-wrap: anywhere; } +.term-comment { white-space: pre-wrap; } +.term-facts { display: grid; grid-template-columns: minmax(7rem, max-content) 1fr; gap: .35rem .75rem; } +.term-facts dt { font-weight: 700; } +.term-facts dd { margin: 0; overflow-wrap: anywhere; } +.term-facts code + code { margin-left: .35rem; } +.notice { padding: 1rem; border-radius: .75rem; background: #fff7d6; border: 1px solid #e6c75b; } +footer { border-top: 1px solid #c8d2df; padding: 2rem 1.25rem; text-align: center; } +@media (prefers-color-scheme: dark) { + body { color: #e8eef5; background: #0b1522; } + header { background: #06111d; } + a { color: #8fc2ff; } + .downloads a, .summary-card, .term-card { background: #122236; border-color: #38516a; } + .on-this-page { background: #102a43; } + .notice { background: #3d3212; border-color: #8a712b; } + footer { border-color: #38516a; } +} +@media print { + body { background: white; color: black; } + header { background: white; color: black; padding: 1rem 0; } + header p { color: black; } + main { max-width: none; padding: 0; } + .term-card { break-inside: avoid; box-shadow: none; } +} +""".strip() + + +def _render_ontology_page(graph: Graph, source_sha256: str) -> tuple[str, int]: + """Render the complete ontology documentation page and unique term count.""" + ontology_iri, label, comment = _ontology_metadata(graph) + nav, term_sections, term_count = _render_term_sections(graph) + return ( + "\n" + '\n\n' + '\n' + '\n' + f"{html.escape(label)}\n" + f'\n' + f'\n' + '\n' + '\n' + '\n' + f"\n" + "\n\n" + "
    " + '

    LineageWeave / Ontology

    ' + f"

    {html.escape(label)}

    " + f"

    {html.escape(comment)}

    " + f'

    Ontology IRI: {html.escape(ontology_iri)}

    ' + "
    " + "
    " + '
    ' + '

    Machine-readable artifacts

    ' + '
    " + '
    ' + f'
    {term_count}
    Unique documented terms
    ' + f'
    {len(graph)}
    RDF triples
    ' + f'
    {html.escape(source_sha256[:12])}
    Source SHA-256 prefix
    ' + "
    " + '

    Identity boundary: this project page is the stable documentation endpoint requested for the repository. The source ontology IRI shown above remains the semantic identifier until an explicit versioned namespace-migration ADR says otherwise.

    ' + '" + f"{term_sections}" + "
    " + '

    Generated deterministically from docs/ontology/lineageweave-kg.ttl. No analytics or external scripts.

    ' + "\n\n", + term_count, + ) + + +def _render_root_page() -> str: + """Render the project Pages landing page with a direct ontology action.""" + return ( + "\n" + '' + '' + "LineageWeave public specifications" + f'' + f"" + "

    LineageWeave public specifications

    " + "

    Stable, machine-readable public artifacts published from the protected repository source.

    " + '

    Ontology

    Inspect the OWL 2, RDF Schema, SKOS, and provenance vocabulary.

    ' + '

    Open the ontology documentation

    ' + "

    ContextualWisdomLab / LineageWeave

    " + "\n" + ) + + +def _write_manifest( + ontology_dir: Path, + source: Path, + graph: Graph, + term_count: int, +) -> None: + """Write deterministic provenance metadata for the published ontology.""" + payload = { + "documentation_url": DOCUMENTATION_URL, + "generated_artifacts": ["index.html", "ontology.jsonld", "ontology.nt"], + "ontology_triple_count": len(graph), + "ontology_unique_term_count": term_count, + "source_path": SOURCE_RELATIVE_PATH.as_posix(), + "source_sha256": _sha256(source), + } + (ontology_dir / "manifest.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def build_site(repository_root: Path, output_dir: Path) -> None: + """Build the complete static ontology site under ``output_dir``.""" + root = repository_root.resolve() + output = output_dir.resolve() + source = root / SOURCE_RELATIVE_PATH + prov_profile = root / PROV_PROFILE_RELATIVE_PATH + if not source.is_file(): + raise FileNotFoundError(f"ontology source is missing: {source}") + if not prov_profile.is_file(): + raise FileNotFoundError(f"PROV-O support profile is missing: {prov_profile}") + + if output.exists(): + shutil.rmtree(output) + ontology_dir = output / "ontology" + ontology_dir.mkdir(parents=True) + + graph = Graph().parse(source, format="turtle") + source_sha256 = _sha256(source) + ontology_html, term_count = _render_ontology_page(graph, source_sha256) + + (output / ".nojekyll").write_text("", encoding="utf-8") + (output / "index.html").write_text(_render_root_page(), encoding="utf-8") + (ontology_dir / "index.html").write_text(ontology_html, encoding="utf-8") + shutil.copyfile(source, ontology_dir / "ontology.ttl") + shutil.copyfile(prov_profile, ontology_dir / "prov-o-support-profile.ttl") + _write_serializations(graph, ontology_dir) + _write_manifest(ontology_dir, source, graph, term_count) + (output / "robots.txt").write_text( + "User-agent: *\nAllow: /\nSitemap: " f"{PUBLIC_BASE_URL}/sitemap.xml\n", + encoding="utf-8", + ) + (output / "sitemap.xml").write_text( + '\n' + '\n' + f" {PUBLIC_BASE_URL}/\n" + f" {DOCUMENTATION_URL}\n" + "\n", + encoding="utf-8", + ) + + +def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments for repository and output locations.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repository-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="LineageWeave repository root (default: inferred from this script)", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("_site"), + help="Static site output directory (default: _site)", + ) + return parser.parse_args(argv) + + +def main(argv: Iterable[str] | None = None) -> int: + """Build the site from CLI arguments and return a process exit code.""" + args = _parse_args(argv) + build_site(args.repository_root, args.output_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From b6e6bd81a3d1cfb6ac715991a5c85eee69cf01bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:29:25 -0700 Subject: [PATCH 27/44] ci: publish ontology through GitHub Pages --- .github/workflows/ontology-pages.yml | 124 +++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .github/workflows/ontology-pages.yml diff --git a/.github/workflows/ontology-pages.yml b/.github/workflows/ontology-pages.yml new file mode 100644 index 000000000..58a76afaa --- /dev/null +++ b/.github/workflows/ontology-pages.yml @@ -0,0 +1,124 @@ +name: Ontology Pages + +on: + pull_request: + branches: [main] + paths: + - "docs/ontology/**" + - "scripts/build_ontology_site.py" + - "tests/test_ontology.py" + - "tests/test_ontology_site.py" + - ".github/workflows/ontology-pages.yml" + push: + branches: [main] + paths: + - "docs/ontology/**" + - "scripts/build_ontology_site.py" + - "tests/test_ontology.py" + - "tests/test_ontology_site.py" + - ".github/workflows/ontology-pages.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ontology-pages-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate ontology publication + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked Python dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Install committed dependencies + run: uv sync --frozen --extra dev + + - name: Verify ontology and publication contracts + run: | + uv run --frozen python -m pytest -q tests/test_ontology.py + uv run --frozen python -m coverage run --branch \ + -m pytest -q tests/test_ontology_site.py + uv run --frozen python -m coverage report \ + --include=scripts/build_ontology_site.py \ + --fail-under=100 + + - name: Build static ontology site + run: uv run --frozen python scripts/build_ontology_site.py --output-dir _site + + - name: Compile owned Python surface + run: >- + uv run --frozen python -m compileall -q + scripts/build_ontology_site.py tests/test_ontology_site.py + + publish: + name: Publish ontology to GitHub Pages + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked Python dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Install committed dependencies + run: uv sync --frozen --extra dev + + - name: Verify exact protected source before publication + run: | + uv run --frozen python -m pytest -q tests/test_ontology.py + uv run --frozen python -m coverage run --branch \ + -m pytest -q tests/test_ontology_site.py + uv run --frozen python -m coverage report \ + --include=scripts/build_ontology_site.py \ + --fail-under=100 + + - name: Build deterministic publication artifact + run: uv run --frozen python scripts/build_ontology_site.py --output-dir _site + + - name: Configure GitHub Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: _site + + - name: Deploy GitHub Pages artifact + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 From bd11574510963ce861ea689fc5658e427159c48b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:29:46 -0700 Subject: [PATCH 28/44] docs: record public ontology publication boundary --- docs/adr/0131-published-ontology-pages.md | 88 +++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/adr/0131-published-ontology-pages.md diff --git a/docs/adr/0131-published-ontology-pages.md b/docs/adr/0131-published-ontology-pages.md new file mode 100644 index 000000000..b1668b2f0 --- /dev/null +++ b/docs/adr/0131-published-ontology-pages.md @@ -0,0 +1,88 @@ +# ADR 0131 — Publish the ontology namespace as a deterministic GitHub Pages artifact + +**Decision status:** Accepted +**Date:** 2026-08-21 + +## Context + +ADR 0004 established `docs/ontology/lineageweave-kg.ttl` as the formal, +machine-validated OWL 2 / RDF Schema / SKOS vocabulary for LineageWeave. The +repository already verifies that the ontology and relational controlled +vocabulary do not drift. However, the product-facing URL +`https://contextualwisdomlab.github.io/LineageWeave/ontology#` returned no +published resource, so ontology IRIs shown to buyers and external consumers did +not lead to a documentation endpoint. + +Publishing the authenticated LineageWeave application itself is not the right +fix. The ontology is a public specification artifact. It must remain usable +without tenant credentials, runtime APIs, PostgreSQL, contextual-orchestrator, +or any private source data. + +A second concern is namespace identity. Existing source code and RDF artifacts +use the lowercase semantic namespace +`https://contextualwisdomlab.github.io/lineageweave/ontology#`. Silently +rewriting those IRIs to match the repository's display-case path would be a +breaking ontology migration, not a deployment repair. + +## Decision + +1. Add a deterministic Python builder, `scripts/build_ontology_site.py`, that + reads the authoritative Turtle source and emits a static Pages tree. +2. Publish a fragment-addressable HTML vocabulary at + `https://contextualwisdomlab.github.io/LineageWeave/ontology`, with one + stable anchor for every documented class, property, concept scheme, and + concept. +3. Publish equivalent machine-readable artifacts beside the HTML: + `ontology.ttl`, `ontology.jsonld`, `ontology.nt`, the PROV-O support profile, + and a source-digest manifest. +4. Preserve `lineageweave-kg.ttl` byte-for-byte as the published Turtle + artifact. JSON-LD and N-Triples are generated from a canonicalized RDF graph + and are tested for semantic isomorphism with the source. +5. Do not add a build timestamp. The same source tree must produce the same + artifact bytes. The manifest records the source SHA-256 instead. +6. Validate publication behavior on pull requests, including 100% statement + and branch coverage for the builder. Deploy only from `main` or an explicit + protected manual dispatch through the `github-pages` environment. +7. Pin every third-party GitHub Action by full commit SHA and grant Pages and + OIDC permissions only to the deployment job. +8. Keep the existing lowercase ontology IRI unchanged. The Pages document + clearly distinguishes the public documentation endpoint from the semantic + identifier. Any future namespace change requires a separate versioned ADR, + compatibility vocabulary, and consumer migration plan. +9. The repository must have Pages source set to **GitHub Actions** once. After + that administrative enablement, publication is entirely workflow-driven. + +## Consequences + +- The requested URL becomes a stable public specification surface after this + change reaches `main` and the Pages environment completes successfully. +- External consumers can inspect human-readable terms or download equivalent + RDF serializations without running LineageWeave. +- A changed ontology cannot publish if its lookup-code contract, semantic + round-trip, deterministic-build contract, or builder coverage fails. +- GitHub Pages remains a static documentation host; it does not provide HTTP + content negotiation or become a graph database, SPARQL endpoint, or source + of runtime truth. +- No private tenant data, runtime secrets, model output, or authenticated UI is + present in the artifact. + +## Related decisions + +- [ADR 0004](0004-knowledge-graph-ontology.md): ontology and relational + vocabulary contract. +- [ADR 0011](0011-prov-o-standard-relations.md): standard PROV-O relations. +- [ADR 0065](0065-prov-o-provenance-boundary.md): provenance authority + boundary. + +## References — APA 7th + +GitHub. (2026). *Using custom workflows with GitHub Pages*. +https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages + +Sauermann, L., & Cyganiak, R. (2008). *Cool URIs for the Semantic Web*. +World Wide Web Consortium. https://www.w3.org/TR/cooluris/ + +Villazón-Terrazas, B., Vilches-Blázquez, L. M., Corcho, O., & Gómez-Pérez, A. +(2011). Methodological guidelines for publishing government linked data. In +D. Wood (Ed.), *Linking government data* (pp. 27–49). Springer. +https://doi.org/10.1007/978-1-4614-1767-5_2 From 2355baa3ab914418ebb74957b4ab158a08863010 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:29:55 -0700 Subject: [PATCH 29/44] docs: record ontology publication pipeline --- CHANGELOG.d/2.12.7-ontology-pages.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 CHANGELOG.d/2.12.7-ontology-pages.md diff --git a/CHANGELOG.d/2.12.7-ontology-pages.md b/CHANGELOG.d/2.12.7-ontology-pages.md new file mode 100644 index 000000000..731dfd70d --- /dev/null +++ b/CHANGELOG.d/2.12.7-ontology-pages.md @@ -0,0 +1,7 @@ +## Added + +- Added a deterministic GitHub Pages publication pipeline for the public + ontology documentation URL, with fragment-addressable terms and Turtle, + JSON-LD, N-Triples, PROV-O profile, and source-digest artifacts. +- Added semantic round-trip, byte-determinism, fail-closed source, CLI, and + 100% statement/branch coverage tests for the ontology site builder. From 709aba59ebe0c81d6ef1a90ea467a10b9b47207e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:30:48 -0700 Subject: [PATCH 30/44] docs: track public ontology publication gap --- docs/product-technical-gap-baseline.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e65883463..6d6858d75 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -23,4 +23,10 @@ - **Security & Compliance**: PII masking cannot break the system. Need SOC 2 and CSAP compliance alternatives to blind PII masking. - **LLM Orchestration**: Ensure ALL LLM calls route through `contextual-orchestrator` utilizing API keys (BYTEZ, NVIDIA, OPENROUTER, OPENAI) with auto model discovery and optimal reasoning effort allocation (Fugu/Conductor/TRINITY research). -*This document is continuously updated by the hourly automated agent loop.* +## 4. Public Ontology Publication Gap +- **Observed gap**: `https://contextualwisdomlab.github.io/LineageWeave/ontology#` has no deployed public resource even though the authoritative OWL/RDFS/SKOS Turtle ontology already exists in `docs/ontology/lineageweave-kg.ttl`. +- **Active remediation — PR #371**: Add a deterministic GitHub Pages builder and protected deployment workflow that publishes fragment-addressable HTML, byte-identical Turtle, isomorphic JSON-LD and N-Triples, the PROV-O support profile, and a source-digest manifest. +- **Namespace boundary**: The existing lowercase semantic ontology IRI is not silently rewritten. The repository-case Pages path is the public documentation endpoint; any semantic namespace migration remains a separate versioned architectural change. +- **Completion evidence**: Exact-head ontology and site tests pass; builder statement/branch coverage is 100%; the repository Pages source is GitHub Actions; the protected `main` deployment succeeds; and the requested URL resolves with stable anchors such as `#Post`. + +*This document is continuously updated by the hourly automated agent loop.* \ No newline at end of file From a9eced0a696a88f70254c1e17840825e4f78bfc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:33:33 -0700 Subject: [PATCH 31/44] fix(test): validate real ontology content and HTML escaping --- tests/test_ontology_site.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index 50b7841dd..256f774d3 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -50,12 +50,31 @@ def test_build_publishes_dereferenceable_html_and_machine_formats(tmp_path: Path assert '' in html assert 'id="Post"' in html assert 'href="#Post"' in html - assert "A <source> post & evidence." in html + assert "LineageWeave Knowledge Graph Ontology" in html assert "ontology.ttl" in html assert "ontology.jsonld" in html assert "ontology.nt" in html +def test_render_term_escapes_untrusted_ontology_text() -> None: + builder = _load_builder() + graph = Graph() + term = builder.URIRef("https://example.test/ontology#Unsafe") + graph.add((term, builder.RDF.type, builder.OWL.Class)) + graph.add( + (term, builder.RDFS.label, builder.Literal("")) + ) + graph.add( + (term, builder.RDFS.comment, builder.Literal("A & evidence.")) + ) + + rendered = builder._render_term(graph, term, {term}) + + assert "" not in rendered + assert "<script>alert(1)</script>" in rendered + assert "A <source> & evidence." in rendered + + def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: builder = _load_builder() output = tmp_path / "site" From c4b46435f83ecb57fdc24edbdc51de9ba657360b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:41:13 +0900 Subject: [PATCH 32/44] test: cover empty ontology term categories --- tests/test_ontology_site.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index 256f774d3..65110b601 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -115,6 +115,18 @@ def test_helpers_cover_slash_fragments_json_lists_and_missing_ontology() -> None builder = _load_builder() assert builder._fragment(builder.URIRef("https://example.test/vocabulary/Term")) == "Term" assert builder._canonicalize_json({"@list": ["b", "a"]}) == {"@list": ["b", "a"]} + graph = Graph() + graph.add( + ( + builder.URIRef("https://example.test/ontology#Term"), + builder.RDF.type, + builder.OWL.Class, + ) + ) + nav, sections, term_count = builder._render_term_sections(graph) + assert 'href="#classes"' in nav + assert 'id="object-properties"' not in sections + assert term_count == 1 try: builder._ontology_metadata(Graph()) except ValueError as exc: From d9397e746d8fa42ad193021aa539cfc21309d77f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:42:56 +0900 Subject: [PATCH 33/44] fix: protect ontology Pages deployments --- .github/workflows/ontology-pages.yml | 10 ++++++---- scripts/build_ontology_site.py | 1 + tests/test_ontology_site.py | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ontology-pages.yml b/.github/workflows/ontology-pages.yml index 58a76afaa..236c3642a 100644 --- a/.github/workflows/ontology-pages.yml +++ b/.github/workflows/ontology-pages.yml @@ -22,14 +22,13 @@ on: permissions: contents: read -concurrency: - group: ontology-pages-${{ github.ref }} - cancel-in-progress: true - jobs: validate: name: Validate ontology publication if: github.event_name == 'pull_request' + concurrency: + group: ontology-pages-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true runs-on: ubuntu-latest steps: - name: Checkout repository @@ -71,6 +70,9 @@ jobs: publish: name: Publish ontology to GitHub Pages if: github.event_name != 'pull_request' + concurrency: + group: ontology-pages-publication + cancel-in-progress: false runs-on: ubuntu-latest permissions: contents: read diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index 74df7c5db..c2a03a4b1 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -208,6 +208,7 @@ def _render_term_sections(graph: Graph) -> tuple[str, str, int]: str(subject), ), ) + terms = [term for term in terms if term not in counted] if not terms: continue section_id = heading.lower().replace(" ", "-") diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index 65110b601..e162faea5 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -135,6 +135,20 @@ def test_helpers_cover_slash_fragments_json_lists_and_missing_ontology() -> None raise AssertionError("missing owl:Ontology declaration was accepted") +def test_render_term_sections_keeps_one_anchor_for_multi_typed_terms() -> None: + builder = _load_builder() + graph = Graph() + term = builder.URIRef("https://example.test/ontology#SharedTerm") + graph.add((term, builder.RDF.type, builder.OWL.Class)) + graph.add((term, builder.RDF.type, builder.SKOS.Concept)) + + nav, sections, term_count = builder._render_term_sections(graph) + + assert nav.count("SharedTerm") == 0 + assert sections.count('id="SharedTerm"') == 1 + assert term_count == 1 + + def test_builder_fails_closed_for_missing_sources_and_replaces_output(tmp_path: Path) -> None: builder = _load_builder() repository = tmp_path / "repository" From 536249d39ecbde5485e0f6122817582dd2a0a2cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:46:55 -0700 Subject: [PATCH 34/44] security: harden ontology publication boundary --- scripts/publish_ontology_site.py | 161 +++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 scripts/publish_ontology_site.py diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py new file mode 100644 index 000000000..3e5076c8a --- /dev/null +++ b/scripts/publish_ontology_site.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Validate and publish the deterministic LineageWeave ontology Pages site. + +This safety wrapper keeps the renderer focused on presentation while enforcing +fail-closed graph and filesystem boundaries before the renderer may replace an +output directory or emit links derived from ontology IRIs. +""" + +from __future__ import annotations + +import argparse +import importlib.util +from collections.abc import Iterable +from pathlib import Path +from types import ModuleType +from urllib.parse import urlsplit + +from rdflib import Graph, URIRef +from rdflib.namespace import OWL, RDF, RDFS, SKOS + +OUTPUT_MARKER = ".lineageweave-ontology-site" +SOURCE_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg.ttl") +PROV_PROFILE_RELATIVE_PATH = Path("docs/ontology/prov-o-support-profile.ttl") +TERM_TYPES: tuple[URIRef, ...] = ( + OWL.Class, + OWL.ObjectProperty, + OWL.DatatypeProperty, + OWL.AnnotationProperty, + SKOS.ConceptScheme, + SKOS.Concept, +) +LINK_PREDICATES: tuple[URIRef, ...] = ( + RDF.type, + RDFS.subClassOf, + RDFS.domain, + RDFS.range, + OWL.inverseOf, + SKOS.broader, + SKOS.narrower, + SKOS.inScheme, +) + + +def _load_renderer(repository_root: Path) -> ModuleType: + """Load the sibling deterministic renderer from one repository root.""" + script = repository_root / "scripts" / "build_ontology_site.py" + spec = importlib.util.spec_from_file_location("lineageweave_ontology_renderer", script) + if spec is None or spec.loader is None: + raise RuntimeError(f"ontology renderer could not be loaded: {script}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _fragment(value: URIRef) -> str: + """Return the local fragment used by the renderer as an HTML identifier.""" + iri = str(value) + if "#" in iri: + return iri.rsplit("#", 1)[1] + return iri.rstrip("/").rsplit("/", 1)[-1] + + +def _public_subjects(graph: Graph) -> set[URIRef]: + """Return URI subjects included in the public HTML term inventory.""" + return { + subject + for term_type in TERM_TYPES + for subject in graph.subjects(RDF.type, term_type) + if isinstance(subject, URIRef) + } + + +def validate_public_graph(graph: Graph) -> None: + """Reject RDF structures that cannot be rendered safely and uniquely.""" + subjects = _public_subjects(graph) + fragment_owner: dict[str, URIRef] = {} + for subject in sorted(subjects, key=str): + fragment = _fragment(subject) + owner = fragment_owner.setdefault(fragment, subject) + if owner != subject: + raise ValueError( + f"duplicate ontology fragment {fragment!r}: {owner} and {subject}" + ) + + category_count = sum((subject, RDF.type, term_type) in graph for term_type in TERM_TYPES) + if category_count > 1: + raise ValueError( + f"ontology term has multiple public term categories: {subject}" + ) + + for subject in subjects: + for predicate in LINK_PREDICATES: + for value in graph.objects(subject, predicate): + if not isinstance(value, URIRef) or value in subjects: + continue + scheme = urlsplit(str(value)).scheme.lower() + if scheme not in {"http", "https"}: + raise ValueError( + f"unsafe linked IRI scheme {scheme!r} for {value}" + ) + + +def _validate_output_directory(output_dir: Path, source: Path, profile: Path) -> Path: + """Resolve an output path and ensure replacement cannot delete source data.""" + requested = output_dir.expanduser() + if requested.is_symlink(): + raise ValueError("output directory must not be a symbolic link") + output = requested.resolve() + if source.is_relative_to(output) or profile.is_relative_to(output): + raise ValueError("output directory overlaps ontology source files") + if output.exists() and not (output / OUTPUT_MARKER).is_file(): + raise ValueError("refusing to replace an unmarked output directory") + return output + + +def publish_site(repository_root: Path, output_dir: Path) -> None: + """Validate sources and publish one safely replaceable static site tree.""" + root = repository_root.resolve() + source = root / SOURCE_RELATIVE_PATH + profile = root / PROV_PROFILE_RELATIVE_PATH + if not source.is_file(): + raise FileNotFoundError(f"ontology source is missing: {source}") + if not profile.is_file(): + raise FileNotFoundError(f"PROV-O support profile is missing: {profile}") + + output = _validate_output_directory(output_dir, source, profile) + graph = Graph().parse(source, format="turtle") + validate_public_graph(graph) + + renderer = _load_renderer(root) + renderer.build_site(root, output) + (output / OUTPUT_MARKER).write_text("", encoding="utf-8") + + +def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: + """Parse repository and output paths for the publication command.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repository-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="LineageWeave repository root", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("_site"), + help="Static site output directory", + ) + return parser.parse_args(argv) + + +def main(argv: Iterable[str] | None = None) -> int: + """Publish the site from CLI arguments and return a process exit code.""" + args = _parse_args(argv) + publish_site(args.repository_root, args.output_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 774a3d9b7be0152afc244ca3738bf8b9027b03a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:47:33 -0700 Subject: [PATCH 35/44] test: cover ontology publication safety boundary --- tests/test_publish_ontology_site.py | 191 ++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 tests/test_publish_ontology_site.py diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py new file mode 100644 index 000000000..56810b389 --- /dev/null +++ b/tests/test_publish_ontology_site.py @@ -0,0 +1,191 @@ +"""Security and deployment-boundary tests for ontology Pages publication.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest +from rdflib import Graph, URIRef +from rdflib.namespace import OWL, RDF, RDFS + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "publish_ontology_site.py" + + +def _load_publisher(): + spec = importlib.util.spec_from_file_location("publish_ontology_site", SCRIPT) + if spec is None or spec.loader is None: + raise AssertionError("ontology publisher could not be loaded") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _repository_fixture(tmp_path: Path) -> Path: + repository = tmp_path / "repository" + ontology_dir = repository / "docs" / "ontology" + scripts_dir = repository / "scripts" + ontology_dir.mkdir(parents=True) + scripts_dir.mkdir(parents=True) + for name in ("lineageweave-kg.ttl", "prov-o-support-profile.ttl"): + (ontology_dir / name).write_bytes((ROOT / "docs" / "ontology" / name).read_bytes()) + (scripts_dir / "build_ontology_site.py").write_bytes( + (ROOT / "scripts" / "build_ontology_site.py").read_bytes() + ) + return repository + + +def test_publication_refuses_unmarked_existing_output(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = _repository_fixture(tmp_path) + output = tmp_path / "site" + output.mkdir() + (output / "unrelated.txt").write_text("do not delete", encoding="utf-8") + + with pytest.raises(ValueError, match="unmarked"): + publisher.publish_site(repository, output) + + assert (output / "unrelated.txt").read_text(encoding="utf-8") == "do not delete" + + +def test_publication_replaces_only_marked_output_and_writes_marker(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = _repository_fixture(tmp_path) + output = tmp_path / "site" + + publisher.publish_site(repository, output) + (output / "stale.txt").write_text("stale", encoding="utf-8") + publisher.publish_site(repository, output) + + assert (output / publisher.OUTPUT_MARKER).is_file() + assert not (output / "stale.txt").exists() + assert (output / "ontology" / "index.html").is_file() + + +def test_publication_rejects_symlink_and_source_overlapping_outputs(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = _repository_fixture(tmp_path) + target = tmp_path / "target" + target.mkdir() + symlink = tmp_path / "site-link" + symlink.symlink_to(target, target_is_directory=True) + + with pytest.raises(ValueError, match="symbolic link"): + publisher.publish_site(repository, symlink) + with pytest.raises(ValueError, match="overlaps"): + publisher.publish_site(repository, repository) + + +def test_graph_validation_rejects_duplicate_fragments_and_unsafe_links() -> None: + publisher = _load_publisher() + duplicate = Graph() + first = URIRef("https://one.example/ontology#Shared") + second = URIRef("https://two.example/ontology#Shared") + duplicate.add((first, RDF.type, OWL.Class)) + duplicate.add((second, RDF.type, OWL.Class)) + + with pytest.raises(ValueError, match="duplicate ontology fragment"): + publisher.validate_public_graph(duplicate) + + unsafe = Graph() + subject = URIRef("https://example.test/ontology#Subject") + unsafe.add((subject, RDF.type, OWL.Class)) + unsafe.add((subject, RDFS.subClassOf, URIRef("javascript:alert(1)"))) + with pytest.raises(ValueError, match="unsafe linked IRI scheme"): + publisher.validate_public_graph(unsafe) + + +def test_graph_validation_allows_http_relations_and_rejects_multiple_term_types() -> None: + publisher = _load_publisher() + graph = Graph() + subject = URIRef("https://example.test/ontology#Subject") + graph.add((subject, RDF.type, OWL.Class)) + graph.add((subject, RDF.type, OWL.AnnotationProperty)) + graph.add((subject, RDFS.subClassOf, URIRef("https://external.example/Parent"))) + + with pytest.raises(ValueError, match="multiple public term categories"): + publisher.validate_public_graph(graph) + + single_type = Graph() + single_type.add((subject, RDF.type, OWL.Class)) + single_type.add((subject, RDFS.subClassOf, URIRef("https://external.example/Parent"))) + publisher.validate_public_graph(single_type) + + +def test_main_publishes_site(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = _repository_fixture(tmp_path) + output = tmp_path / "site" + + assert publisher.main([ + "--repository-root", + str(repository), + "--output-dir", + str(output), + ]) == 0 + assert (output / "ontology" / "manifest.json").is_file() + + +def test_loader_and_fragment_failure_branches(tmp_path: Path, monkeypatch) -> None: + publisher = _load_publisher() + assert publisher._fragment(URIRef("https://example.test/vocabulary/Term")) == "Term" + monkeypatch.setattr(publisher.importlib.util, "spec_from_file_location", lambda *_args: None) + with pytest.raises(RuntimeError, match="could not be loaded"): + publisher._load_renderer(tmp_path) + + +def test_graph_validation_ignores_non_uri_and_local_link_objects() -> None: + publisher = _load_publisher() + from rdflib import BNode, Literal + + graph = Graph() + subject = URIRef("https://example.test/ontology#Subject") + local_parent = URIRef("https://example.test/ontology#Parent") + graph.add((subject, RDF.type, OWL.Class)) + graph.add((local_parent, RDF.type, OWL.Class)) + graph.add((BNode(), RDF.type, OWL.Class)) + graph.add((subject, RDFS.subClassOf, local_parent)) + graph.add((subject, RDFS.domain, Literal("not a link"))) + + publisher.validate_public_graph(graph) + + +def test_publication_fails_closed_for_missing_sources(tmp_path: Path) -> None: + publisher = _load_publisher() + repository = tmp_path / "repository" + output = tmp_path / "site" + + with pytest.raises(FileNotFoundError, match="ontology source"): + publisher.publish_site(repository, output) + + ontology_dir = repository / "docs" / "ontology" + ontology_dir.mkdir(parents=True) + (ontology_dir / "lineageweave-kg.ttl").write_bytes( + (ROOT / "docs" / "ontology" / "lineageweave-kg.ttl").read_bytes() + ) + with pytest.raises(FileNotFoundError, match="PROV-O support profile"): + publisher.publish_site(repository, output) + + +def test_module_entrypoint(tmp_path: Path, monkeypatch) -> None: + import runpy + import sys + + repository = _repository_fixture(tmp_path) + output = tmp_path / "entry-site" + monkeypatch.setattr( + sys, + "argv", + [ + str(SCRIPT), + "--repository-root", + str(repository), + "--output-dir", + str(output), + ], + ) + with pytest.raises(SystemExit) as exc_info: + runpy.run_path(str(SCRIPT), run_name="__main__") + assert exc_info.value.code == 0 + assert (output / "ontology" / "index.html").is_file() From c99da18e5045ac9f86822a67aa2cfea7e3a9538c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:49:09 -0700 Subject: [PATCH 36/44] ci: gate Pages publication through the hardened wrapper --- .github/workflows/ontology-pages.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ontology-pages.yml b/.github/workflows/ontology-pages.yml index 236c3642a..43c5d384c 100644 --- a/.github/workflows/ontology-pages.yml +++ b/.github/workflows/ontology-pages.yml @@ -6,17 +6,25 @@ on: paths: - "docs/ontology/**" - "scripts/build_ontology_site.py" + - "scripts/publish_ontology_site.py" - "tests/test_ontology.py" - "tests/test_ontology_site.py" + - "tests/test_publish_ontology_site.py" - ".github/workflows/ontology-pages.yml" + - "pyproject.toml" + - "uv.lock" push: branches: [main] paths: - "docs/ontology/**" - "scripts/build_ontology_site.py" + - "scripts/publish_ontology_site.py" - "tests/test_ontology.py" - "tests/test_ontology_site.py" + - "tests/test_publish_ontology_site.py" - ".github/workflows/ontology-pages.yml" + - "pyproject.toml" + - "uv.lock" workflow_dispatch: permissions: @@ -54,22 +62,23 @@ jobs: run: | uv run --frozen python -m pytest -q tests/test_ontology.py uv run --frozen python -m coverage run --branch \ - -m pytest -q tests/test_ontology_site.py + -m pytest -q tests/test_ontology_site.py tests/test_publish_ontology_site.py uv run --frozen python -m coverage report \ - --include=scripts/build_ontology_site.py \ + --include=scripts/build_ontology_site.py,scripts/publish_ontology_site.py \ --fail-under=100 - name: Build static ontology site - run: uv run --frozen python scripts/build_ontology_site.py --output-dir _site + run: uv run --frozen python scripts/publish_ontology_site.py --output-dir _site - name: Compile owned Python surface run: >- uv run --frozen python -m compileall -q - scripts/build_ontology_site.py tests/test_ontology_site.py + scripts/build_ontology_site.py scripts/publish_ontology_site.py + tests/test_ontology_site.py tests/test_publish_ontology_site.py publish: name: Publish ontology to GitHub Pages - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' concurrency: group: ontology-pages-publication cancel-in-progress: false @@ -105,13 +114,13 @@ jobs: run: | uv run --frozen python -m pytest -q tests/test_ontology.py uv run --frozen python -m coverage run --branch \ - -m pytest -q tests/test_ontology_site.py + -m pytest -q tests/test_ontology_site.py tests/test_publish_ontology_site.py uv run --frozen python -m coverage report \ - --include=scripts/build_ontology_site.py \ + --include=scripts/build_ontology_site.py,scripts/publish_ontology_site.py \ --fail-under=100 - name: Build deterministic publication artifact - run: uv run --frozen python scripts/build_ontology_site.py --output-dir _site + run: uv run --frozen python scripts/publish_ontology_site.py --output-dir _site - name: Configure GitHub Pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 From 96f8c67003a68a1d44f942100c8935436781aa7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:50:25 -0700 Subject: [PATCH 37/44] fix: preserve valid multi-type ontology terms --- scripts/publish_ontology_site.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index 3e5076c8a..fee47d3d3 100644 --- a/scripts/publish_ontology_site.py +++ b/scripts/publish_ontology_site.py @@ -82,12 +82,6 @@ def validate_public_graph(graph: Graph) -> None: f"duplicate ontology fragment {fragment!r}: {owner} and {subject}" ) - category_count = sum((subject, RDF.type, term_type) in graph for term_type in TERM_TYPES) - if category_count > 1: - raise ValueError( - f"ontology term has multiple public term categories: {subject}" - ) - for subject in subjects: for predicate in LINK_PREDICATES: for value in graph.objects(subject, predicate): From a7deec6a4381d4f20cd95905f22828dc2b535cbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:50:59 -0700 Subject: [PATCH 38/44] test: accept multi-type terms after renderer deduplication --- tests/test_publish_ontology_site.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py index 56810b389..bec404ac2 100644 --- a/tests/test_publish_ontology_site.py +++ b/tests/test_publish_ontology_site.py @@ -96,7 +96,7 @@ def test_graph_validation_rejects_duplicate_fragments_and_unsafe_links() -> None publisher.validate_public_graph(unsafe) -def test_graph_validation_allows_http_relations_and_rejects_multiple_term_types() -> None: +def test_graph_validation_allows_http_relations_and_multiple_term_types() -> None: publisher = _load_publisher() graph = Graph() subject = URIRef("https://example.test/ontology#Subject") @@ -104,13 +104,7 @@ def test_graph_validation_allows_http_relations_and_rejects_multiple_term_types( graph.add((subject, RDF.type, OWL.AnnotationProperty)) graph.add((subject, RDFS.subClassOf, URIRef("https://external.example/Parent"))) - with pytest.raises(ValueError, match="multiple public term categories"): - publisher.validate_public_graph(graph) - - single_type = Graph() - single_type.add((subject, RDF.type, OWL.Class)) - single_type.add((subject, RDFS.subClassOf, URIRef("https://external.example/Parent"))) - publisher.validate_public_graph(single_type) + publisher.validate_public_graph(graph) def test_main_publishes_site(tmp_path: Path) -> None: From 034fa2939bdc43e137659764a1a8944a06f876e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:52:44 -0700 Subject: [PATCH 39/44] docs: record ontology publication safety and namespace follow-up --- docs/adr/0131-published-ontology-pages.md | 67 +++++++++++++++-------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/docs/adr/0131-published-ontology-pages.md b/docs/adr/0131-published-ontology-pages.md index b1668b2f0..51f4e7da3 100644 --- a/docs/adr/0131-published-ontology-pages.md +++ b/docs/adr/0131-published-ontology-pages.md @@ -10,28 +10,33 @@ machine-validated OWL 2 / RDF Schema / SKOS vocabulary for LineageWeave. The repository already verifies that the ontology and relational controlled vocabulary do not drift. However, the product-facing URL `https://contextualwisdomlab.github.io/LineageWeave/ontology#` returned no -published resource, so ontology IRIs shown to buyers and external consumers did -not lead to a documentation endpoint. +published resource, so ontology terms shown to buyers and external consumers +did not lead to a documentation endpoint. Publishing the authenticated LineageWeave application itself is not the right fix. The ontology is a public specification artifact. It must remain usable without tenant credentials, runtime APIs, PostgreSQL, contextual-orchestrator, or any private source data. -A second concern is namespace identity. Existing source code and RDF artifacts -use the lowercase semantic namespace -`https://contextualwisdomlab.github.io/lineageweave/ontology#`. Silently -rewriting those IRIs to match the repository's display-case path would be a -breaking ontology migration, not a deployment repair. +A second concern is namespace identity. The knowledge-graph Turtle and runtime +lookup predicate use the lowercase semantic namespace +`https://contextualwisdomlab.github.io/lineageweave/ontology#`, while the +committed PROV-O support profile and its contract test use the repository-case +namespace `https://contextualwisdomlab.github.io/LineageWeave/ontology#`. +GitHub Pages paths are case-sensitive. Silently rewriting either form would be +a breaking ontology migration, not a deployment repair. Issue #372 therefore +owns the inventory, canonical-namespace decision, compatibility vocabulary, +and consumer migration plan. ## Decision -1. Add a deterministic Python builder, `scripts/build_ontology_site.py`, that +1. Add a deterministic Python renderer, `scripts/build_ontology_site.py`, that reads the authoritative Turtle source and emits a static Pages tree. 2. Publish a fragment-addressable HTML vocabulary at `https://contextualwisdomlab.github.io/LineageWeave/ontology`, with one stable anchor for every documented class, property, concept scheme, and - concept. + concept. A resource with more than one documented RDF type is rendered once + with one anchor. 3. Publish equivalent machine-readable artifacts beside the HTML: `ontology.ttl`, `ontology.jsonld`, `ontology.nt`, the PROV-O support profile, and a source-digest manifest. @@ -40,39 +45,55 @@ breaking ontology migration, not a deployment repair. and are tested for semantic isomorphism with the source. 5. Do not add a build timestamp. The same source tree must produce the same artifact bytes. The manifest records the source SHA-256 instead. -6. Validate publication behavior on pull requests, including 100% statement - and branch coverage for the builder. Deploy only from `main` or an explicit - protected manual dispatch through the `github-pages` environment. -7. Pin every third-party GitHub Action by full commit SHA and grant Pages and - OIDC permissions only to the deployment job. -8. Keep the existing lowercase ontology IRI unchanged. The Pages document - clearly distinguishes the public documentation endpoint from the semantic - identifier. Any future namespace change requires a separate versioned ADR, - compatibility vocabulary, and consumer migration plan. -9. The repository must have Pages source set to **GitHub Actions** once. After - that administrative enablement, publication is entirely workflow-driven. +6. Run publication through `scripts/publish_ontology_site.py`, a fail-closed + boundary that rejects duplicate HTML fragments, non-HTTP(S) linked IRIs, + symlink outputs, source-overlapping outputs, and replacement of directories + that do not contain the generator marker. This prevents ontology data from + becoming executable links and prevents a misconfigured output path from + deleting unrelated files. +7. Validate publication behavior on pull requests, including 100% statement + and branch coverage for both the renderer and publication boundary. Deploy + only from `main`; a manual dispatch from any other ref is not a publication + path. +8. Pin every third-party GitHub Action by full commit SHA and grant Pages and + OIDC permissions only to the deployment job. Pull-request validation may + cancel superseded runs, while the single publication concurrency group does + not cancel an in-progress deployment. +9. Keep existing semantic IRIs unchanged in this deployment PR. The Pages + document distinguishes the public documentation endpoint from the semantic + identifier. Issue #372 and a future versioned ADR must govern any namespace + migration, compatibility mappings, deprecation interval, and stored-data + migration. +10. The repository must have Pages source set to **GitHub Actions** once. After + that administrative enablement, publication is entirely workflow-driven. ## Consequences - The requested URL becomes a stable public specification surface after this - change reaches `main` and the Pages environment completes successfully. + change reaches `main`, the repository Pages source is configured for GitHub + Actions, and the Pages environment completes successfully. - External consumers can inspect human-readable terms or download equivalent RDF serializations without running LineageWeave. - A changed ontology cannot publish if its lookup-code contract, semantic - round-trip, deterministic-build contract, or builder coverage fails. + round-trip, deterministic-build contract, public-link safety, unique-fragment + contract, filesystem replacement boundary, or coverage gate fails. - GitHub Pages remains a static documentation host; it does not provide HTTP content negotiation or become a graph database, SPARQL endpoint, or source of runtime truth. - No private tenant data, runtime secrets, model output, or authenticated UI is present in the artifact. +- The existing case-distinct namespace forms remain a tracked interoperability + gap rather than being hidden by this deployment change. -## Related decisions +## Related decisions and work - [ADR 0004](0004-knowledge-graph-ontology.md): ontology and relational vocabulary contract. - [ADR 0011](0011-prov-o-standard-relations.md): standard PROV-O relations. - [ADR 0065](0065-prov-o-provenance-boundary.md): provenance authority boundary. +- Issue #372: reconcile lowercase and repository-case public namespace IRIs. +- PR #349: authenticated Ontology Explorer consumer surface. ## References — APA 7th From f85dc42c13505d105e4369c77ef77bfe31d5fce4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:52:54 +0900 Subject: [PATCH 40/44] fix: preserve login return URL and guard admin token --- frontend/src/App.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..8765205c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4610,7 +4610,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
    - {destination === "admin" ? : null} + {destination === "admin" && accessToken ? : null}