From db74bb189817345fc9dbe1db038e5e787e5d9a45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:44:18 +0900 Subject: [PATCH 1/4] fix(calendar): export VTODO CREATED from task creation time generate_ics_from_task serialized only DTSTAMP (updated_at), so calendar clients could not distinguish task creation from modification. Emit iCalendar CREATED from CalendarTask.created_at per RFC 5545 and cover it with a parsed iCalendar regression. --- backend/services/calendar_sync.py | 1 + backend/tests/test_calendar_sync.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/backend/services/calendar_sync.py b/backend/services/calendar_sync.py index 7da9ba500..c2f95bcc0 100644 --- a/backend/services/calendar_sync.py +++ b/backend/services/calendar_sync.py @@ -33,6 +33,7 @@ def generate_ics_from_task(task: CalendarTask) -> str: action_item = Todo() action_item.add("UID", task.task_uid) + action_item.add("CREATED", task.created_at) action_item.add("DTSTAMP", task.updated_at) action_item.add("SUMMARY", task.title) action_item.add("STATUS", ics_status) diff --git a/backend/tests/test_calendar_sync.py b/backend/tests/test_calendar_sync.py index 19d5966a1..06d6c5744 100644 --- a/backend/tests/test_calendar_sync.py +++ b/backend/tests/test_calendar_sync.py @@ -1,4 +1,5 @@ import datetime +from icalendar import Calendar from services.calendar_sync import generate_ics_from_task, CalendarTask @@ -29,6 +30,9 @@ 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 From e74537faa3cf39e7f628dbc5be4d6adefdbe800e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 11:44:54 +0900 Subject: [PATCH 2/4] test(calendar): require RFC 5545 UTC change timestamps --- backend/tests/test_calendar_sync.py | 56 ++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_calendar_sync.py b/backend/tests/test_calendar_sync.py index 19d5966a1..d432b1562 100644 --- a/backend/tests/test_calendar_sync.py +++ b/backend/tests/test_calendar_sync.py @@ -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(): @@ -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", From 184446b5a2c5e797e62ba5aa6dd5ce65bbc691ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 11:45:08 +0900 Subject: [PATCH 3/4] fix(calendar): normalize change timestamps to RFC 5545 UTC --- backend/services/calendar_sync.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/services/calendar_sync.py b/backend/services/calendar_sync.py index 7da9ba500..81d75fc16 100644 --- a/backend/services/calendar_sync.py +++ b/backend/services/calendar_sync.py @@ -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). @@ -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) From f2f689c04b84b2eac4e36ff79c7343266885dc18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 11:45:45 +0900 Subject: [PATCH 4/4] docs(calendar): record RFC 5545 timestamp contract --- .../calendar-vtodo-change-timestamps.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/doctoring/calendar-vtodo-change-timestamps.md diff --git a/docs/doctoring/calendar-vtodo-change-timestamps.md b/docs/doctoring/calendar-vtodo-change-timestamps.md new file mode 100644 index 000000000..c5a889927 --- /dev/null +++ b/docs/doctoring/calendar-vtodo-change-timestamps.md @@ -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.