From c31e083aabcd5b2dc7b25de94fe27afbbad9b0d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:42:27 -0700 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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 b566c6d2d1778a3d97a43a4f8c0abe3cb422e6f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:52:54 +0900 Subject: [PATCH 25/37] 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}