From 974cf8b4a5618a43b17584ab762af6630ee4acd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:01:46 -0700 Subject: [PATCH 1/3] feat(calendar): define Naruon projection contract --- ...4.1-naruon-calendar-projection-contract.md | 18 + docs/adr/0038-calendar-source-contract.md | 57 ++- ...123-naruon-calendar-projection-boundary.md | 151 ++++++++ .../naruon-calendar-projection-v1.schema.json | 122 +++++++ .../NARUON_CALENDAR_PROJECTION_REFERENCES.md | 20 + lineageweave/naruon_calendar_projection.py | 345 ++++++++++++++++++ tests/test_naruon_calendar_projection.py | 297 +++++++++++++++ 7 files changed, 990 insertions(+), 20 deletions(-) create mode 100644 CHANGELOG.d/2.14.1-naruon-calendar-projection-contract.md create mode 100644 docs/adr/0123-naruon-calendar-projection-boundary.md create mode 100644 docs/contracts/naruon-calendar-projection-v1.schema.json create mode 100644 docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md create mode 100644 lineageweave/naruon_calendar_projection.py create mode 100644 tests/test_naruon_calendar_projection.py diff --git a/CHANGELOG.d/2.14.1-naruon-calendar-projection-contract.md b/CHANGELOG.d/2.14.1-naruon-calendar-projection-contract.md new file mode 100644 index 000000000..919684f7b --- /dev/null +++ b/CHANGELOG.d/2.14.1-naruon-calendar-projection-contract.md @@ -0,0 +1,18 @@ +## Added + +- Added a strict, bounded v1 consumer contract for calendar occurrences already + authorized and policy-filtered by Naruon, including recurrence occurrence + identity, provider revision, timezone/all-day semantics, disclosure level, + and observed provenance. + +## Changed + +- Clarified that LineageWeave owns post-grounded commitments and issue/todo + records, while Naruon owns provider CalDAV synchronization and writeback. + The earlier custom JSON `/events` feed must not be described as CalDAV. + +## Security + +- The new consumer rejects unsafe base URLs, end-user/provider credential + shapes, unbounded pages and windows, naive timestamps, duplicate occurrences, + unknown fields/vocabularies, and URL-shaped opaque references. diff --git a/docs/adr/0038-calendar-source-contract.md b/docs/adr/0038-calendar-source-contract.md index b9e89d51b..da41602c9 100644 --- a/docs/adr/0038-calendar-source-contract.md +++ b/docs/adr/0038-calendar-source-contract.md @@ -1,37 +1,54 @@ -# ADR 0038: Separate CalDAV events from internal commitments +# ADR 0038: Separate external calendar events from internal commitments -- Status: Accepted +- Status: Superseded in part by ADR 0123 - Date: 2026-08-18 ## Context The buyer Calendar destination needs both external calendar events and 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. PR #251 defined the external calendar as an +independent consumer port, while the current application already stores +authorized commitments and issue tickets. -## Decision +The first implementation read a custom JSON `GET {CALDAV_BASE_URL}/events` +feed. Despite the module and setting names, that feed was not a CalDAV client or +CalDAV server contract. It did not implement RFC 4791 WebDAV discovery/REPORT, +RFC 5545 iCalendar recurrence and timezone semantics, RFC 6578 synchronization, +or provider revision and authorization behavior. -`GET /api/calendar` returns two independent collections: +## Original decision retained -- `events`: events read from `CALDAV_BASE_URL/events` through - `lineageweave.caldav_client`; malformed external rows are ignored. +The Buyer Calendar returns two independent collections: + +- `events`: externally observed calendar occurrences; and - `commitments`: the existing authorized internal commitment projection, filtered by the requesting account's `post_read` RBAC and post ABAC rules. -When CalDAV is unset or temporarily unavailable, `events` is empty and the -response includes a next action in `calendar_sources`; the internal -commitments remain available. The backend never invents an external event. +When the external calendar channel is unset or temporarily unavailable, +`events` is empty and the internal commitments remain available. The backend +never invents an external event. + +LineageWeave does not add a second calendar database, CalDAV server, provider +credential store, or writeback engine. + +## Superseding decision + +ADR 0123 replaces the custom `/events` transport and CalDAV naming with a +versioned, read-only Naruon calendar projection contract. Naruon is the authority +for customer-owned CalDAV 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/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/0123-naruon-calendar-projection-boundary.md b/docs/adr/0123-naruon-calendar-projection-boundary.md new file mode 100644 index 000000000..94d5d017c --- /dev/null +++ b/docs/adr/0123-naruon-calendar-projection-boundary.md @@ -0,0 +1,151 @@ +# ADR 0123: Consume calendar observations through Naruon, not provider CalDAV + +- Status: Proposed +- Date: 2026-08-21 +- Issue: #336 +- Stack parent: #261 +- Related authority: `ContextualWisdomLab/naruon#978`, `ContextualWisdomLab/naruon#998` + +## 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 contract 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 will consume a **read-only, versioned Naruon calendar projection**. +It will 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 +- exact parser/transport tests in `tests/test_naruon_calendar_projection.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. + +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, or fragment-bearing base URLs; +- missing or control-bearing service credentials; +- windows longer than 366 days; +- pages larger than 200 events; +- unknown fields, schema versions, status, disclosure, or truth vocabularies; +- naive timestamps, invalid intervals, and duplicate occurrence references; +- URL-shaped opaque references and cursors. + +The adapter follows no redirects through the current shared HTTP client. Errors +identify the configured host but never include the service credential. + +Until Naruon ships the matching read endpoint and service-audience contract, +LineageWeave runtime wiring remains fail-closed. Existing internal commitments +remain available even when the external event channel is unavailable. + +## Consequences + +### Positive + +- Product language no longer implies a CalDAV implementation 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. + +### Costs and limitations + +- The Buyer Calendar will not show external events until Naruon implements and + releases the corresponding read projection. +- The two repositories require consumer/provider contract tests before runtime + activation. +- This PR establishes the consumer and contract only; it does not claim provider + interoperability, CalDAV conformance, or a completed Naruon connector. + +## Merge and activation gate + +This ADR remains Proposed and the PR remains Draft until: + +1. #258 → #260 → #261 are integrated or this stack is restacked on their + protected descendants; +2. Naruon publishes the matching endpoint, media type, service audience, and + conformance fixtures; +3. LineageWeave wires configuration and the Buyer API without forwarding an + end-user token; +4. exact-head tests, security checks, and independent review pass. + +## References + +See `docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md`. 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..cabf57bc9 --- /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+$" + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!.*://)\\S+$" + }, + "rfc3339": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "calendar_occurrence": { + "type": "object", + "additionalProperties": false, + "required": [ + "event_reference", + "occurrence_reference", + "source_reference", + "provider_revision", + "display_text", + "starts_at", + "ends_at", + "all_day", + "time_zone", + "status_code", + "disclosure_code", + "truth_status_code", + "observed_at" + ], + "properties": { + "event_reference": { + "$ref": "#/$defs/opaque_reference" + }, + "occurrence_reference": { + "$ref": "#/$defs/opaque_reference" + }, + "source_reference": { + "$ref": "#/$defs/opaque_reference" + }, + "provider_revision": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?!.*://)[^\\u0000-\\u001F\\u007F]+$" + }, + "display_text": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[^\\u0000-\\u001F\\u007F]+$" + }, + "starts_at": { + "$ref": "#/$defs/rfc3339" + }, + "ends_at": { + "$ref": "#/$defs/rfc3339" + }, + "all_day": { + "type": "boolean" + }, + "time_zone": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^\\S+$" + }, + "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..9a642519c --- /dev/null +++ b/docs/doctoring/NARUON_CALENDAR_PROJECTION_REFERENCES.md @@ -0,0 +1,20 @@ +# Naruon calendar projection references + +## Product traceability + +| Source | Product decision | Evidence | +|---|---|---| +| RFC 4791 | Do not call the current JSON `/events` feed CalDAV; provider DAV behavior belongs to Naruon. | ADR 0123; issue #336 | +| RFC 6578 | Sync tokens and collection reconciliation are provider-authority concerns, not LineageWeave read-model fields. | ADR 0123; Naruon #978/#998 | +| RFC 5545 | Recurrence, occurrence identity, timezone, and all-day semantics must survive the Naruon projection. | Projection v1 schema and parser tests | +| PROV-O | External rows remain `observed`; LineageWeave commitments retain separate authoritative post provenance. | `truth_status_code`; ADR 0123 | + +## 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 + +World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ diff --git a/lineageweave/naruon_calendar_projection.py b/lineageweave/naruon_calendar_projection.py new file mode 100644 index 000000000..48ebe86ca --- /dev/null +++ b/lineageweave/naruon_calendar_projection.py @@ -0,0 +1,345 @@ +"""Strict consumer contract for Naruon-owned calendar event projections. + +LineageWeave owns post-grounded customer commitments and issue tickets. Naruon +owns customer calendar provider access, CalDAV synchronization, provider +revision handling, and policy filtering. This module consumes only a bounded, +already-authorized Naruon read projection; it is deliberately not a CalDAV +client and never receives provider credentials or an end-user bearer token. +""" + +from __future__ import annotations + +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) +_ALLOWED_STATUS_CODES = frozenset({"confirmed", "tentative", "desired", "cancelled"}) +_ALLOWED_DISCLOSURE_CODES = frozenset({"busy_only", "summary_visible"}) +_ALLOWED_TRUTH_STATUS_CODES = frozenset({"observed"}) + + +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]: + 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: {', '.join(sorted(missing))}" + ) + if extra: + raise NaruonCalendarContractError( + f"{field_name} has unexpected fields: {', '.join(sorted(extra))}" + ) + return value + + +def _bounded_text( + value: Any, + *, + field_name: str, + maximum_length: int, + allow_whitespace: bool = True, + allow_url_shape: bool = True, +) -> str: + if not isinstance(value, str): + raise NaruonCalendarContractError(f"{field_name} must be a string") + normalized = value.strip() + if not normalized or len(normalized) > maximum_length: + raise NaruonCalendarContractError( + f"{field_name} must contain 1..{maximum_length} characters" + ) + if any(ord(character) < 32 or ord(character) == 127 for character in normalized): + raise NaruonCalendarContractError(f"{field_name} contains control characters") + if not allow_whitespace and any(character.isspace() for character in normalized): + raise NaruonCalendarContractError(f"{field_name} must be an opaque token") + if not allow_url_shape and "://" in normalized: + raise NaruonCalendarContractError(f"{field_name} must not contain a URL") + return normalized + + +def _opaque_reference(value: Any, *, field_name: str) -> str: + return _bounded_text( + value, + field_name=field_name, + maximum_length=256, + allow_whitespace=False, + allow_url_shape=False, + ) + + +def _parse_rfc3339(value: Any, *, field_name: str) -> datetime: + text = _bounded_text(value, field_name=field_name, maximum_length=64) + normalized = f"{text[:-1]}+00:00" if text.endswith("Z") else text + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise NaruonCalendarContractError(f"{field_name} must be RFC 3339") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise NaruonCalendarContractError(f"{field_name} must include an offset") + return parsed + + +def _controlled_code( + value: Any, + *, + field_name: str, + allowed: frozenset[str], +) -> str: + code = _bounded_text( + value, + field_name=field_name, + maximum_length=64, + allow_whitespace=False, + ) + if code not in allowed: + raise NaruonCalendarContractError(f"{field_name} has an unsupported value") + return code + + +def _parse_occurrence(value: Any, *, index: int) -> NaruonCalendarOccurrence: + field_name = f"events[{index}]" + row = _strict_object( + value, + field_name=field_name, + required=frozenset( + { + "event_reference", + "occurrence_reference", + "source_reference", + "provider_revision", + "display_text", + "starts_at", + "ends_at", + "all_day", + "time_zone", + "status_code", + "disclosure_code", + "truth_status_code", + "observed_at", + } + ), + ) + if not isinstance(row["all_day"], bool): + raise NaruonCalendarContractError(f"{field_name}.all_day must be a boolean") + starts_at = _parse_rfc3339(row["starts_at"], field_name=f"{field_name}.starts_at") + ends_at = _parse_rfc3339(row["ends_at"], field_name=f"{field_name}.ends_at") + if ends_at <= starts_at: + raise NaruonCalendarContractError(f"{field_name}.ends_at must be after starts_at") + _parse_rfc3339(row["observed_at"], field_name=f"{field_name}.observed_at") + return NaruonCalendarOccurrence( + event_reference=_opaque_reference( + row["event_reference"], field_name=f"{field_name}.event_reference" + ), + occurrence_reference=_opaque_reference( + row["occurrence_reference"], field_name=f"{field_name}.occurrence_reference" + ), + source_reference=_opaque_reference( + row["source_reference"], field_name=f"{field_name}.source_reference" + ), + provider_revision=_bounded_text( + row["provider_revision"], + field_name=f"{field_name}.provider_revision", + maximum_length=256, + allow_url_shape=False, + ), + display_text=_bounded_text( + row["display_text"], + field_name=f"{field_name}.display_text", + maximum_length=512, + ), + starts_at=str(row["starts_at"]).strip(), + ends_at=str(row["ends_at"]).strip(), + all_day=row["all_day"], + time_zone=_bounded_text( + row["time_zone"], + field_name=f"{field_name}.time_zone", + maximum_length=128, + allow_whitespace=False, + ), + status_code=_controlled_code( + row["status_code"], + field_name=f"{field_name}.status_code", + allowed=_ALLOWED_STATUS_CODES, + ), + disclosure_code=_controlled_code( + row["disclosure_code"], + field_name=f"{field_name}.disclosure_code", + allowed=_ALLOWED_DISCLOSURE_CODES, + ), + truth_status_code=_controlled_code( + row["truth_status_code"], + field_name=f"{field_name}.truth_status_code", + allowed=_ALLOWED_TRUTH_STATUS_CODES, + ), + observed_at=str(row["observed_at"]).strip(), + ) + + +def parse_naruon_calendar_page( + payload: Any, + *, + maximum_events: int = 200, +) -> NaruonCalendarPage: + """Validate and convert one Naruon calendar projection page. + + The parser rejects unknown fields and vocabulary values so provider or + policy changes cannot silently broaden what LineageWeave exposes. + """ + if not 1 <= maximum_events <= 200: + raise ValueError("maximum_events must be between 1 and 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) > maximum_events: + raise NaruonCalendarContractError("calendar_page.events exceeds the admitted page size") + events = tuple(_parse_occurrence(row, index=index) for index, row in enumerate(rows)) + occurrence_references = [event.occurrence_reference for event in events] + if len(occurrence_references) != len(set(occurrence_references)): + raise NaruonCalendarContractError("calendar_page contains duplicate occurrence references") + next_cursor_value = root.get("next_cursor") + next_cursor = None + if next_cursor_value is not None: + next_cursor = _bounded_text( + next_cursor_value, + field_name="calendar_page.next_cursor", + maximum_length=1024, + allow_whitespace=False, + allow_url_shape=False, + ) + return NaruonCalendarPage( + schema_version=NARUON_CALENDAR_SCHEMA_VERSION, + projection_revision=projection_revision, + events=events, + next_cursor=next_cursor, + ) + + +class NaruonCalendarProjectionClient: + """Read a bounded Naruon calendar projection with one service credential.""" + + def __init__( + self, + base_url: str, + service_access_token: str, + *, + maximum_events: int = 200, + timeout: float = 10.0, + ) -> None: + parsed = urlparse(base_url.strip()) + 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, + ) + if not 1 <= maximum_events <= 200: + raise ValueError("maximum_events must be between 1 and 200") + if not 0 < timeout <= 30: + raise ValueError("timeout must be greater than 0 and at most 30 seconds") + self._events_url = f"{base_url.strip().rstrip('/')}{NARUON_CALENDAR_EVENTS_PATH}" + self._service_access_token = token + self._maximum_events = maximum_events + self._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.""" + starts_at = _parse_rfc3339(window_start, field_name="window_start") + ends_at = _parse_rfc3339(window_end, field_name="window_end") + if ends_at <= starts_at: + raise ValueError("window_end must be after window_start") + if ends_at - starts_at > _MAX_WINDOW: + raise ValueError("calendar window must not exceed 366 days") + fields = { + "window_start": window_start.strip(), + "window_end": window_end.strip(), + "limit": str(self._maximum_events), + } + if cursor is not None: + fields["cursor"] = _bounded_text( + cursor, + field_name="cursor", + maximum_length=1024, + allow_whitespace=False, + allow_url_shape=False, + ) + payload = get_json( + f"{self._events_url}?{urlencode(fields)}", + headers={ + "authorization": f"Bearer {self._service_access_token}", + "accept": NARUON_CALENDAR_MEDIA_TYPE, + }, + timeout=self._timeout, + ) + return parse_naruon_calendar_page(payload, maximum_events=self._maximum_events) diff --git a/tests/test_naruon_calendar_projection.py b/tests/test_naruon_calendar_projection.py new file mode 100644 index 000000000..9e8681f9d --- /dev/null +++ b/tests/test_naruon_calendar_projection.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +from copy import deepcopy +from urllib.parse import parse_qs, urlparse + +import pytest + +from lineageweave.naruon_calendar_projection import ( + NARUON_CALENDAR_MEDIA_TYPE, + NaruonCalendarContractError, + NaruonCalendarProjectionClient, + parse_naruon_calendar_page, +) + + +def _event(**overrides): + event = { + "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, **overrides): + page = { + "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_the_service_credential_and_parses_projection(monkeypatch) -> None: + received = {} + + def fake_get_json(url: str, *, headers: dict[str, str], timeout: float) -> dict: + received.update(url=url, headers=headers, timeout=timeout) + return _page() + + monkeypatch.setattr( + "lineageweave.naruon_calendar_projection.get_json", + fake_get_json, + ) + client = NaruonCalendarProjectionClient( + "https://naruon.example/tenant-projection/", + "service-secret", + maximum_events=25, + timeout=7, + ) + + page = client.list_events( + "2026-08-01T00:00:00Z", + "2026-09-01T00:00:00Z", + cursor="cursor_001", + ) + + parsed_url = urlparse(received["url"]) + assert parsed_url.scheme == "https" + assert parsed_url.netloc == "naruon.example" + assert parsed_url.path == "/tenant-projection/api/calendar/events" + assert parse_qs(parsed_url.query) == { + "window_start": ["2026-08-01T00:00:00Z"], + "window_end": ["2026-09-01T00:00:00Z"], + "limit": ["25"], + "cursor": ["cursor_001"], + } + assert received["headers"] == { + "authorization": "Bearer service-secret", + "accept": NARUON_CALENDAR_MEDIA_TYPE, + } + assert received["timeout"] == 7 + assert page.projection_revision == "projection_001" + assert page.next_cursor == "cursor_002" + assert page.events[0].occurrence_reference == "occ_001" + assert page.events[0].truth_status_code == "observed" + + +@pytest.mark.parametrize( + "base_url", + [ + "file:///tmp/calendar", + "https://user:secret@naruon.example", + "https://naruon.example?token=secret", + "https://naruon.example#events", + ], +) +def test_client_rejects_unsafe_base_urls(base_url: str) -> None: + with pytest.raises(ValueError): + NaruonCalendarProjectionClient(base_url, "service-secret") + + +@pytest.mark.parametrize("token", ["", " ", "secret\nsecond-line"]) +def test_client_rejects_missing_or_control_bearing_service_tokens(token: str) -> None: + with pytest.raises(NaruonCalendarContractError): + NaruonCalendarProjectionClient("https://naruon.example", token) + + +@pytest.mark.parametrize("maximum_events", [0, 201]) +def test_client_rejects_invalid_page_bounds(maximum_events: int) -> None: + with pytest.raises(ValueError, match="maximum_events"): + NaruonCalendarProjectionClient( + "https://naruon.example", + "service-secret", + maximum_events=maximum_events, + ) + + +@pytest.mark.parametrize("timeout", [0, 31]) +def test_client_rejects_invalid_timeouts(timeout: float) -> None: + with pytest.raises(ValueError, match="timeout"): + NaruonCalendarProjectionClient( + "https://naruon.example", + "service-secret", + timeout=timeout, + ) + + +@pytest.mark.parametrize( + ("window_start", "window_end", "message"), + [ + ("2026-08-01T00:00:00", "2026-08-02T00:00:00Z", "offset"), + ("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", "offset"), + ("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, + message: str, +) -> None: + with pytest.raises(NaruonCalendarContractError, match=message): + parse_naruon_calendar_page(_page(_event(**{field: value}))) + + +def test_parser_rejects_non_boolean_all_day() -> None: + with pytest.raises(NaruonCalendarContractError, match="boolean"): + parse_naruon_calendar_page(_page(_event(all_day="false"))) + + +def test_parser_rejects_duplicate_occurrences() -> None: + with pytest.raises(NaruonCalendarContractError, match="duplicate"): + parse_naruon_calendar_page(_page(_event(), deepcopy(_event()))) + + +def test_parser_rejects_unknown_fields() -> None: + with pytest.raises(NaruonCalendarContractError, match="unexpected"): + parse_naruon_calendar_page(_page(_event(provider_url="https://provider.example"))) + + +def test_parser_rejects_unsupported_schema_and_invalid_roots() -> None: + with pytest.raises(NaruonCalendarContractError, match="unsupported"): + parse_naruon_calendar_page(_page(schema_version="2.0")) + with pytest.raises(NaruonCalendarContractError, match="object"): + parse_naruon_calendar_page([]) + with pytest.raises(NaruonCalendarContractError, match="array"): + parse_naruon_calendar_page(_page(events={})) + + +def test_parser_rejects_over_limit_pages_and_invalid_parser_bounds() -> None: + events = [_event(occurrence_reference=f"occ_{index}") for index in range(3)] + with pytest.raises(NaruonCalendarContractError, match="page size"): + parse_naruon_calendar_page(_page(*events), maximum_events=2) + with pytest.raises(ValueError, match="maximum_events"): + parse_naruon_calendar_page(_page(), maximum_events=0) + + +def test_parser_allows_terminal_page_without_cursor() -> None: + page = _page() + page.pop("next_cursor") + + parsed = parse_naruon_calendar_page(page) + + assert parsed.next_cursor is None + + +def test_parser_rejects_missing_required_fields_and_non_string_text() -> None: + missing = _event() + missing.pop("source_reference") + with pytest.raises(NaruonCalendarContractError, match="missing required"): + parse_naruon_calendar_page(_page(missing)) + with pytest.raises(NaruonCalendarContractError, match="must be a string"): + parse_naruon_calendar_page(_page(_event(display_text=123))) + + +def test_parser_rejects_whitespace_in_opaque_references() -> None: + with pytest.raises(NaruonCalendarContractError, match="opaque token"): + parse_naruon_calendar_page(_page(_event(source_reference="source private"))) + + +def test_client_omits_cursor_when_not_requested(monkeypatch) -> None: + received = {} + + def fake_get_json(url: str, *, headers: dict[str, str], timeout: float) -> dict: + received["url"] = url + return _page() + + monkeypatch.setattr( + "lineageweave.naruon_calendar_projection.get_json", + fake_get_json, + ) + client = NaruonCalendarProjectionClient( + "https://naruon.example", + "service-secret", + ) + + client.list_events("2026-08-01T00:00:00Z", "2026-08-02T00:00:00Z") + + assert "cursor" not in parse_qs(urlparse(received["url"]).query) + + +def test_json_schema_matches_the_parser_contract() -> None: + import json + from dataclasses import fields + from pathlib import Path + + from lineageweave.naruon_calendar_projection import NaruonCalendarOccurrence + + 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" From 44517b30d76dc2ac66c496b9b3cf6436b7955cd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:28:12 +0900 Subject: [PATCH 2/3] fix: enforce strict calendar timestamps --- lineageweave/naruon_calendar_projection.py | 14 +++++++++++++- tests/test_naruon_calendar_projection.py | 6 ++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lineageweave/naruon_calendar_projection.py b/lineageweave/naruon_calendar_projection.py index 48ebe86ca..5d661ffa6 100644 --- a/lineageweave/naruon_calendar_projection.py +++ b/lineageweave/naruon_calendar_projection.py @@ -11,6 +11,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta +import re from typing import Any from urllib.parse import urlencode, urlparse @@ -25,6 +26,10 @@ _ALLOWED_STATUS_CODES = frozenset({"confirmed", "tentative", "desired", "cancelled"}) _ALLOWED_DISCLOSURE_CODES = frozenset({"busy_only", "summary_visible"}) _ALLOWED_TRUTH_STATUS_CODES = frozenset({"observed"}) +_RFC3339_PATTERN = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:" + r"[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})$" +) class NaruonCalendarContractError(ValueError): @@ -119,7 +124,14 @@ def _opaque_reference(value: Any, *, field_name: str) -> str: def _parse_rfc3339(value: Any, *, field_name: str) -> datetime: text = _bounded_text(value, field_name=field_name, maximum_length=64) - normalized = f"{text[:-1]}+00:00" if text.endswith("Z") else text + if _RFC3339_PATTERN.fullmatch(text) is None: + raise NaruonCalendarContractError(f"{field_name} must be RFC 3339") + normalized_text = text[:10] + "T" + text[11:] + normalized = ( + f"{normalized_text[:-1]}+00:00" + if normalized_text.endswith(("Z", "z")) + else normalized_text + ) try: parsed = datetime.fromisoformat(normalized) except ValueError as exc: diff --git a/tests/test_naruon_calendar_projection.py b/tests/test_naruon_calendar_projection.py index 9e8681f9d..7cfbe8fed 100644 --- a/tests/test_naruon_calendar_projection.py +++ b/tests/test_naruon_calendar_projection.py @@ -132,7 +132,7 @@ def test_client_rejects_invalid_timeouts(timeout: float) -> None: @pytest.mark.parametrize( ("window_start", "window_end", "message"), [ - ("2026-08-01T00:00:00", "2026-08-02T00:00:00Z", "offset"), + ("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"), ], @@ -179,7 +179,9 @@ def test_parser_preserves_busy_only_policy_filtered_text() -> None: @pytest.mark.parametrize( ("field", "value", "message"), [ - ("starts_at", "2026-08-24T09:00:00", "offset"), + ("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"), From 6f6f5e26db8e7322d4d8854a5e2b67a6d2feccb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 14:07:06 +0900 Subject: [PATCH 3/3] style: normalize calendar contract imports --- lineageweave/naruon_calendar_projection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lineageweave/naruon_calendar_projection.py b/lineageweave/naruon_calendar_projection.py index 5d661ffa6..57880d051 100644 --- a/lineageweave/naruon_calendar_projection.py +++ b/lineageweave/naruon_calendar_projection.py @@ -9,9 +9,9 @@ from __future__ import annotations +import re from dataclasses import dataclass from datetime import datetime, timedelta -import re from typing import Any from urllib.parse import urlencode, urlparse