Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion backend/services/calendar_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ class CalendarTask:
due_date: Optional[datetime.datetime] = None


def _calendar_change_timestamp_utc(
value: datetime.datetime,
*,
field_name: str,
) -> datetime.datetime:
"""Return an RFC 5545 change-management timestamp normalized to UTC."""
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field_name} must be timezone-aware")
return value.astimezone(datetime.timezone.utc)


def generate_ics_from_task(task: CalendarTask) -> str:
"""
Generates a basic CalDAV-compatible .ics (iCalendar) string for a TicketTask (VTODO).
Expand All @@ -27,13 +38,23 @@ def generate_ics_from_task(task: CalendarTask) -> str:
elif task.status == "blocked":
ics_status = "NEEDS-ACTION"

created_at = _calendar_change_timestamp_utc(
task.created_at,
field_name="created_at",
)
updated_at = _calendar_change_timestamp_utc(
task.updated_at,
field_name="updated_at",
)

cal = Calendar()
cal.add("VERSION", "2.0")
cal.add("PRODID", "-//Naruon//AI Workspace//EN")

action_item = Todo()
action_item.add("UID", task.task_uid)
action_item.add("DTSTAMP", task.updated_at)
action_item.add("CREATED", created_at)
action_item.add("DTSTAMP", updated_at)
action_item.add("SUMMARY", task.title)
action_item.add("STATUS", ics_status)

Expand Down
56 changes: 55 additions & 1 deletion backend/tests/test_calendar_sync.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import datetime
from services.calendar_sync import generate_ics_from_task, CalendarTask

import pytest
from icalendar import Calendar

from services.calendar_sync import CalendarTask, generate_ics_from_task


def test_generate_ics_from_task():
Expand Down Expand Up @@ -29,9 +33,59 @@ def test_generate_ics_from_task():
assert "SUMMARY:Review Q2 Marketing Report" in ics_content
assert "STATUS:IN-PROCESS" in ics_content
assert "DUE:20260525T150000Z" in ics_content
parsed = Calendar.from_ical(ics_content)
vtodo = next(component for component in parsed.walk() if component.name == "VTODO")
assert vtodo.decoded("CREATED") == created_at
assert "END:VTODO" in ics_content


def test_generate_ics_from_task_serializes_created_and_dtstamp_in_utc():
task = CalendarTask(
task_uid="utc-1",
title="UTC timestamps",
status="in_progress",
created_at=datetime.datetime(
2026,
5,
23,
10,
0,
tzinfo=datetime.timezone(datetime.timedelta(hours=9)),
),
updated_at=datetime.datetime(
2026,
5,
23,
11,
0,
tzinfo=datetime.timezone(datetime.timedelta(hours=-5)),
),
)

ics_content = generate_ics_from_task(task)

assert "CREATED:20260523T010000Z" in ics_content
assert "DTSTAMP:20260523T160000Z" in ics_content


@pytest.mark.parametrize("field_name", ["created_at", "updated_at"])
def test_generate_ics_from_task_rejects_naive_change_management_time(
field_name: str,
):
aware = datetime.datetime(2026, 5, 23, 10, 0, tzinfo=datetime.timezone.utc)
task_values = {
"task_uid": "naive-1",
"title": "Naive timestamp",
"status": "in_progress",
"created_at": aware,
"updated_at": aware,
}
task_values[field_name] = datetime.datetime(2026, 5, 23, 10, 0)

with pytest.raises(ValueError, match=f"{field_name} must be timezone-aware"):
generate_ics_from_task(CalendarTask(**task_values))


def test_generate_ics_from_task_escapes_summary_text():
task = CalendarTask(
task_uid="escape-1",
Expand Down
50 changes: 50 additions & 0 deletions docs/doctoring/calendar-vtodo-change-timestamps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# VTODO change-management timestamps

## Problem

`CalendarTask.created_at` existed in the Naruon calendar writeback model but was not serialized into VTODO output. The generated component therefore exposed `DTSTAMP` only and lost the calendar-store creation timestamp.

A second standards gap became visible while restoring that field: RFC 5545 requires both `CREATED` and `DTSTAMP` change-management values to be represented in UTC. `generate_ics_from_task()` accepted arbitrary `datetime` values, so a non-UTC aware timestamp could be handed directly to the serializer and a naive timestamp could depend on process-local timezone behavior if normalized implicitly.

## Contract

For Naruon-generated VTODO components:

- `CREATED` is emitted from `CalendarTask.created_at`;
- `DTSTAMP` remains sourced from `CalendarTask.updated_at`;
- both timestamps are normalized to `datetime.timezone.utc` before serialization;
- naive or otherwise offset-less values fail closed with a field-specific `ValueError` rather than being interpreted in a machine-local timezone;
- `DUE` semantics are unchanged because RFC 5545 permits additional DATE-TIME forms for that property.

The current production caller in `backend/api/calendar.py` already constructs both change-management timestamps with `datetime.datetime.now(datetime.timezone.utc)`, so the stricter boundary preserves the live writeback path while making the serializer safe for future callers.

## Evidence lineage

- Original product RED: VTODO parsing raised `KeyError` for `CREATED` on predecessor `db74bb189817345fc9dbe1db038e5e787e5d9a45` before its one-line serializer repair.
- Canonical dependency-security adoption: `443d13e1446c8d7140e889a8bcbcf4c2b616f3bd` preserves the predecessor as first-parent provenance and adopts `#1623@17a7618eda2b212b691f08fa936e042b34258fc9` without copying dependency-owner source.
- UTC-boundary RED: `e74537faa3cf39e7f628dbc5be4d6adefdbe800e` requires non-UTC aware `CREATED`/`DTSTAMP` inputs to serialize as UTC and requires naive values to be rejected.
- Causal fix: `184446b5a2c5e797e62ba5aa6dd5ce65bbc691ba` restores `CREATED` and introduces a single UTC normalization boundary used by both change-management fields.

The RED commit is source-order evidence; it is not described as a terminal hosted failure unless an immutable workflow receipt for that exact head is available.

## Alternatives considered

Leaving timezone conversion to the iCalendar library was rejected because the domain contract would remain implicit and naive values could acquire host-local meaning. Treating naive timestamps as UTC was also rejected because that silently changes the meaning of an ambiguous input. Rejecting offset-less values makes the serialization boundary deterministic and keeps UTC requirements explicit.

## Acceptance

The final exact head must prove that:

1. parsing the emitted VTODO recovers `CREATED` from the task creation instant;
2. aware timestamps with non-zero offsets serialize as UTC `Z` values for both `CREATED` and `DTSTAMP`;
3. naive `created_at` and `updated_at` values are rejected;
4. the existing status, summary escaping, and optional `DUE` behavior remains green;
5. no frontend dependency/security source is duplicated from the canonical #1623 owner.

Hosted checks and independent review are head/base-specific and must be regenerated after the final stacked topology is established.

## Reference

Desruisseaux, B. (2009). *Internet Calendaring and Scheduling Core Object Specification (iCalendar)* (RFC 5545). Internet Engineering Task Force. https://doi.org/10.17487/RFC5545

RFC 5545 §3.8.7.1 defines `CREATED` for VTODO and requires UTC; §3.8.7.2 requires `DTSTAMP` on VTODO and requires UTC. The UTC requirement is the authority for the normalization and fail-closed boundary above.