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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 87383d244..1384aee23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,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. - Ontology neighborhoods now enforce request bounds before database access, apply node-level ABAC, omit unlabeled endpoints, preserve catalog-owned node metadata, and keep typed endpoint IDs unambiguous. Workspace CSV and JSON-LD 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 8aec031d1..fb894c944 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2763,7 +2763,7 @@ async def chat_about_post( try: with use_llm_metadata(post_metadata): answer = await asyncio.to_thread(client.answer, question, sources) - except (HttpClientError, KeyError, OSError, ValueError) as exc: + except (HttpClientError, KeyError, OSError, TypeError, ValueError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Post chat is unavailable: contextual-orchestrator returned no complete evidence object", diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 9089298da..b00d748d5 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" +_SOURCE_BODY_MISSING_FAILURE_CODE = "post_content_source_body_missing" _UNEXPECTED_FAILURE_DETAIL = "post-content provider operation failed; retry the ingestion job" @@ -229,10 +230,22 @@ async def process_post_content_job( if row is None: return attempt_count = int(row["job_attempt_count"]) + 1 + raw_body = row["post_body"] + if not isinstance(raw_body, str) or not raw_body.strip(): + _logger.warning( + "post content ingestion skipped: source post has no body", + extra={"post_id": post_id}, + ) + await _finish_job( + pool, + post_id, + FAILED, + failure_code=_SOURCE_BODY_MISSING_FAILURE_CODE, + detail_text="source post has no body", + expected_attempt_count=attempt_count, + ) + return try: - raw_body = row["post_body"] - if not isinstance(raw_body, str) or not raw_body.strip(): - raise ValueError("source post has no body") metadata = build_post_llm_metadata(post_id, row) embedding_client = embedding_factory() structure_client = structure_factory() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4cdf0729b..ce471f6ec 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -3547,6 +3547,29 @@ def answer(self, question: str, sources) -> ChatAnswer: assert "What happened here that no seed already answers?" in events[0]["summary"] +def test_post_chat_malformed_provider_reply_is_unavailable( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A malformed provider envelope must not escape as an HTTP 500.""" + + class _MalformedChatClient: + available = True + + def answer(self, question: str, sources) -> None: + del question, sources + raise TypeError("provider message content is not a string") + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _MalformedChatClient()) + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/chat", + json={"question": "What happened here?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "no complete evidence object" in response.json()["detail"] + + def test_live_chat_provider_error_does_not_leak_raw_error( client, demo_analyst_token, seeded_db, monkeypatch ) -> None: 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/docs/adr/0038-calendar-source-contract.md b/docs/adr/0038-calendar-source-contract.md index b9e89d51b..58f0efed9 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 0203 - 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 0203 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. diff --git a/docs/adr/0203-naruon-calendar-projection-boundary.md b/docs/adr/0203-naruon-calendar-projection-boundary.md new file mode 100644 index 000000000..ef2764e74 --- /dev/null +++ b/docs/adr/0203-naruon-calendar-projection-boundary.md @@ -0,0 +1,161 @@ +# ADR 0203: 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`. 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 +} 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..c873180f4 --- /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": "^(?!\\s)(?!.*\\s$)(?!.*://)[^\\u0000-\\u001F\\u007F]+$" + }, + "display_text": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^(?!\\s)(?!.*\\s$)[^\\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" + } + } + } + } +} diff --git a/docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md b/docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md new file mode 100644 index 000000000..fb8a5d4ff --- /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 0203; issue #336 | +| RFC 6578 | Sync tokens and collection reconciliation are provider-authority concerns, not LineageWeave read-model fields. | ADR 0203; 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 0203 | +| 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/ 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..29e818ce9 --- /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/0203-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/0203-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. diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 8775fe6c6..3bd503126 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", diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py index e62639ece..5edc3e696 100644 --- a/lineageweave/http_client.py +++ b/lineageweave/http_client.py @@ -31,6 +31,98 @@ class HttpClientError(RuntimeError): """The remote endpoint failed, 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 _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, + *, + 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 _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 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, @@ -38,11 +130,20 @@ def _request( body: bytes | None, headers: dict[str, str], timeout: float, + maximum_response_bytes: int | None = None, + expected_response_media_type: str | None = None, ) -> tuple[int, bytes]: - """Perform one exchange without exposing provider transport exception details.""" + """Perform one bounded HTTP(S) request without exposing provider transport exception details.""" + + 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(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,21 +157,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: 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() + 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, + ) return response.status, raw except (OSError, ValueError, http.client.HTTPException) as exc: # Chain internally for operator logging; the exposed @@ -81,7 +198,8 @@ def _request( 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: @@ -89,7 +207,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}") @@ -97,38 +216,14 @@ 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}") return decoded -def chat_completion_content(body: object) -> str: - """Extract text from a provider chat-completion envelope safely. - - Provider error bodies and malformed success bodies must never be echoed by - a consumer through ``KeyError`` or a repr of the response. The caller - receives only a stable validation error and can translate it at its own - product boundary. - """ - 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 post_json( url: str, payload: dict, @@ -142,6 +237,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: @@ -150,7 +246,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( @@ -178,11 +277,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 @@ -196,14 +299,33 @@ 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 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. + expected_response_media_type: Optional exact lower-case type/subtype. 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, 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("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, + expected_response_media_type=expected_response_media_type, + ) hostname = urlparse(url).hostname or url if status >= 400: raise HttpClientError(f"HTTP {status} from {hostname}") @@ -225,7 +347,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}") diff --git a/lineageweave/naruon_calendar_projection.py b/lineageweave/naruon_calendar_projection.py new file mode 100644 index 000000000..f49f422c8 --- /dev/null +++ b/lineageweave/naruon_calendar_projection.py @@ -0,0 +1,503 @@ +"""Strict consumer contract for Naruon-owned calendar event projections. + +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. +""" + +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})$" +) +_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): + """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 containing exactly the admitted 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_internal_whitespace: bool = True, + allow_url_shape: bool = True, +) -> str: + """Validate exact bounded text without silently normalizing identity.""" + + if not isinstance(value, str): + raise NaruonCalendarContractError(f"{field_name} must be a string") + 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 value): + raise NaruonCalendarContractError( + f"{field_name} contains control characters" + ) + if not allow_internal_whitespace and any( + character.isspace() for character in value + ): + raise NaruonCalendarContractError( + f"{field_name} must be an opaque token without whitespace" + ) + if not allow_url_shape and "://" in value: + raise NaruonCalendarContractError( + f"{field_name} must not contain a URL" + ) + return value + + +def _bounded_integer( + value: Any, + *, + field_name: str, + minimum: int, + maximum: int, +) -> int: + """Return one true integer inside an inclusive range.""" + + if isinstance(value, bool) or not isinstance(value, int): + 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}" + ) + 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( # 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( + "timeout must be greater than 0 and at most 30 seconds" + ) + return timeout + + +def _opaque_reference(value: Any, *, field_name: str) -> str: + """Validate a bounded opaque non-URL token.""" + + return _bounded_text( + value, + field_name=field_name, + maximum_length=256, + allow_internal_whitespace=False, + allow_url_shape=False, + ) + + +def _parse_rfc3339(value: Any, *, field_name: str) -> datetime: + """Parse one exact offset-aware RFC 3339 instant.""" + + text = _bounded_text( + value, + field_name=field_name, + maximum_length=64, + allow_internal_whitespace=True, + ) + if _RFC3339_PATTERN.fullmatch(text) is None: + raise NaruonCalendarContractError( + f"{field_name} must be RFC 3339" + ) + 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: + 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 code.""" + + code = _bounded_text( + value, + field_name=field_name, + maximum_length=64, + allow_internal_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 occurrence from a projection page.""" + + field_name = f"events[{index}]" + row = _strict_object( + value, + field_name=field_name, + required=_OCCURRENCE_FIELDS, + ) + if not isinstance(row["all_day"], bool): + raise NaruonCalendarContractError( + f"{field_name}.all_day must be a boolean" + ) + starts_text = _bounded_text( + row["starts_at"], + field_name=f"{field_name}.starts_at", + maximum_length=64, + allow_internal_whitespace=True, + ) + ends_text = _bounded_text( + row["ends_at"], + field_name=f"{field_name}.ends_at", + maximum_length=64, + allow_internal_whitespace=True, + ) + observed_text = _bounded_text( + row["observed_at"], + field_name=f"{field_name}.observed_at", + maximum_length=64, + 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") + _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" + ) + 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=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_internal_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=observed_text, + ) + + +def parse_naruon_calendar_page( + payload: Any, + *, + maximum_events: int = 200, +) -> NaruonCalendarPage: + """Validate and convert one Naruon calendar projection page.""" + + 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 None + else _bounded_text( + next_cursor_value, + field_name="calendar_page.next_cursor", + maximum_length=1024, + allow_internal_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 a 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_internal_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_internal_whitespace=False, + ) + 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, + ) + self._timeout = _bounded_timeout(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.""" + + start_text = _bounded_text( + window_start, + field_name="window_start", + maximum_length=64, + allow_internal_whitespace=False, + ) + 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": start_text, + "window_end": end_text, + "limit": str(self._maximum_events), + } + if cursor is not None: + fields["cursor"] = _bounded_text( + cursor, + field_name="cursor", + maximum_length=1024, + allow_internal_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, + expected_response_media_type=NARUON_CALENDAR_MEDIA_TYPE, + ) + return parse_naruon_calendar_page( + payload, + maximum_events=self._maximum_events, + ) diff --git a/lineageweave/post_structure.py b/lineageweave/post_structure.py index 3fb25e163..17dc5f025 100644 --- a/lineageweave/post_structure.py +++ b/lineageweave/post_structure.py @@ -158,8 +158,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 3b8963c1a..441b7d88d 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -974,8 +974,9 @@ 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( - chat_completion_content(details_body), + details_content, post_title=post_title, context_hints=context_hints, ) diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py index 76fb59cc0..9f4df72ed 100644 --- a/lineageweave/rankweave_client.py +++ b/lineageweave/rankweave_client.py @@ -312,11 +312,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: @@ -358,7 +358,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, channels=channels, weights=active_weights diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 2954e061b..5c53fc8cd 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -90,6 +90,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) @@ -121,7 +137,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" @@ -138,10 +157,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) @@ -156,7 +204,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: @@ -197,8 +247,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: diff --git a/tests/test_http_client_edges.py b/tests/test_http_client_edges.py index 2b395b37a..5edcebf24 100644 --- a/tests/test_http_client_edges.py +++ b/tests/test_http_client_edges.py @@ -2,7 +2,23 @@ import pytest -import lineageweave.http_client as http_client +from lineageweave import http_client +from lineageweave.llm_context import use_llm_metadata + + +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] @pytest.mark.parametrize("raw", [b"not-json", b"\xff"]) @@ -14,21 +30,240 @@ def test_json_helpers_reject_non_json_and_wrong_shapes( http_client.get_json("https://gateway.example/health", timeout=1) assert isinstance(error.value.__cause__, (UnicodeDecodeError, http_client.json.JSONDecodeError)) - 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( + "body", + [ + None, + {"error": "provider secret response"}, + {"choices": []}, + {"choices": ["not-an-object"]}, + {"choices": [{"message": "not-an-object"}]}, + {"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) + + +def test_chat_completion_content_returns_admitted_text() -> None: + """A well-formed provider envelope exposes only its text content.""" + assert ( + http_client.chat_completion_content( + {"choices": [{"message": {"content": "synthetic result"}}]} + ) + == "synthetic result" + ) + + +def test_response_media_type_treats_a_missing_header_as_unavailable() -> None: + """An omitted response type is distinct from an admitted media type.""" + + class HeaderlessResponse: + """Return no value for the requested response header.""" + + @staticmethod + def getheader(name: str) -> None: + assert name == "Content-Type" + + assert ( + http_client._response_media_type(HeaderlessResponse()) # type: ignore[arg-type] + == "" + ) + + +def test_https_request_rejects_a_connection_without_a_socket( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A connector that yields no TLS socket fails closed before a request.""" + closed = False + + class SocketlessConnection: + """Model an unsuccessful synthetic connection without provider data.""" + + sock = None + + def __init__(self, *_args: object, **_kwargs: object) -> None: + pass + + def connect(self) -> None: + pass + + def close(self) -> None: + nonlocal closed + closed = True + + monkeypatch.setattr(http_client.http.client, "HTTPConnection", SocketlessConnection) + + with pytest.raises(http_client.HttpClientError, match="no socket after connect"): + http_client._request( + "GET", + "https://gateway.example/health", + body=None, + headers={}, + timeout=1, + ) + + assert closed is True + + +def test_post_json_rejects_non_object_metadata_before_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Post provenance cannot be merged into a malformed metadata value.""" + requested = False + + def unexpected_request(*_args: object, **_kwargs: object) -> tuple[int, bytes]: + nonlocal requested + requested = True + return 200, b"{}" + + monkeypatch.setattr(http_client, "_request", unexpected_request) + with ( + use_llm_metadata({"lineageweave_post_id": "synthetic-post"}), + pytest.raises(ValueError, match="metadata must be an object"), + ): + http_client.post_json( + "https://gateway.example/v1/chat/completions", + {"metadata": "not-an-object"}, + headers={}, + timeout=1, + ) + + assert requested is False + + +def test_post_json_adds_context_metadata_when_payload_omits_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Post provenance is carried when the request has no metadata object.""" + captured_body = b"" + + def capture_request(*_args: object, **kwargs: object) -> tuple[int, bytes]: + nonlocal captured_body + captured_body = kwargs["body"] # type: ignore[assignment] + return 200, b"{}" + + monkeypatch.setattr(http_client, "_request", capture_request) + with use_llm_metadata({"lineageweave_post_id": "synthetic-post"}): + assert ( + http_client.post_json( + "https://gateway.example/v1/chat/completions", + {"messages": []}, + headers={}, + timeout=1, + ) + == {} + ) + + assert b'"lineageweave_post_id": "synthetic-post"' in captured_body + + +def test_request_preserves_the_url_query_in_the_http_target( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cursor parameters remain part of the bounded projection request.""" + requested_path = "" + + class SyntheticResponse: + """Serve one bounded JSON object without a network dependency.""" + + status = 200 + + @staticmethod + def getheader(name: str) -> str | None: + assert name == "Content-Length" + return "2" + + @staticmethod + def read(amount: int | None = None) -> bytes: + assert amount == 2 + return b"{}" + + class SyntheticConnection: + """Capture the exact request target for a synthetic HTTP request.""" + + def __init__(self, *_args: object, **_kwargs: object) -> None: + pass + + def request( + self, + _method: str, + path: str, + *, + body: bytes | None, + headers: dict[str, str], + ) -> None: + del body, headers + nonlocal requested_path + requested_path = path + + @staticmethod + def getresponse() -> SyntheticResponse: + return SyntheticResponse() + + @staticmethod + def close() -> None: + pass + + monkeypatch.setattr(http_client.http.client, "HTTPConnection", SyntheticConnection) + + assert http_client._request( + "GET", + "http://gateway.example/events?cursor=synthetic-token", + body=None, + headers={}, + timeout=1, + ) == (200, b"{}") + assert requested_path == "/events?cursor=synthetic-token" @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={}) @@ -41,17 +276,55 @@ 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"[]" if str(args[1]).endswith("/items") else 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 len(calls) == 2 + assert http_client.post_form( + "https://gateway.example", + {}, + timeout=1, + ) == {} + assert http_client.get_json_list( + "https://gateway.example/items", + timeout=1, + ) == [] + assert len(calls) == 3 + + +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] def test_request_hides_raw_provider_transport_errors(monkeypatch: pytest.MonkeyPatch) -> None: 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", + ) 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" diff --git a/tests/test_naruon_calendar_projection.py b/tests/test_naruon_calendar_projection.py new file mode 100644 index 000000000..b1a858cf1 --- /dev/null +++ b/tests/test_naruon_calendar_projection.py @@ -0,0 +1,359 @@ +"""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, + 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() + + 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 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" + 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", + "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, NaruonCalendarContractError)): + 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, 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, # type: ignore[arg-type] + ) + + +@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, # type: ignore[arg-type] + ) + + +@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) + 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: + 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, object] = {} + + def fake_get_json( + url: str, + *, + 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( + "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(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: + 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" + ) + assert schema["$defs"]["opaque_reference"]["pattern"] == ( + r"^(?!.*://)[^\s\u0000-\u001F\u007F]+$" + ) + + +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 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", + ) 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})) diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index f58932544..52c2fb507 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -193,6 +193,49 @@ async def incomplete(*_args, **_kwargs): assert any(args[1] == QUEUED and args[6] == "post_content_ingestion_incomplete" for args in updates) +def test_missing_source_body_is_not_reported_as_a_provider_failure(monkeypatch, caplog) -> None: + connection = _Connection(values=[2]) + pool = _Pool(connection) + + async def claim(*_args, **_kwargs): + return _row(RUNNING, 1) | {"post_body": " "} + + monkeypatch.setattr(post_content_worker, "_claim_job", claim) + monkeypatch.setattr( + post_content_worker, + "load_settings", + lambda: SimpleNamespace( + embedding_model="embedding-model", + orchestrator_base_url="", + orchestrator_api_key="", + ), + ) + client = SimpleNamespace(available=True) + + with caplog.at_level("WARNING", logger=post_content_worker._logger.name): + asyncio.run( + post_content_worker.process_post_content_job( + pool, + post_id="00000000-0000-0000-0000-000000000001", + source_body_digest="a" * 64, + vision_factory=lambda: client, + embedding_factory=lambda: client, + structure_factory=lambda: client, + ) + ) + + updates = [args for query, args in connection.executed if "set status_code" in query] + assert any( + args[1] == FAILED + and args[6] == "post_content_source_body_missing" + and args[7] == "source post has no body" + for args in updates + ) + assert any( + "source post has no body" in record.message for record in caplog.records + ), "empty-body skip must still emit a diagnostic log line" + + def test_transient_provider_error_is_requeued_before_attempt_limit(monkeypatch) -> None: connection = _Connection(values=[2]) pool = _Pool(connection) 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 c6f23af01..e1fb6f77b 100644 --- a/tests/test_rankweave_client.py +++ b/tests/test_rankweave_client.py @@ -127,11 +127,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: