Zadanie 10: radicale-basecamp-sync
Treść zadania
Request Kuby: stworzyć specyfikację projektu synchronizacja kalendarzy Radicale → Basecamp (przez 5 iteracji audytu, skill spec-iteration/superpowers), opublikować do zatwierdzenia. Zakaz implementacji — tylko specyfikacja.
Pytania Kuby do rozstrzygnięcia w spec:
- Czy da się zsyncować wiele kalendarzy Radicale do 1 kalendarza Basecamp?
- Czy lepiej mieć wiele kalendarzy w Basecamp?
- Inne rozwiązanie? — zaproponować warianty z trade-offs i rekomendacją.
Specyfikacja
Cel
Widzieć eventy z kalendarzy Radicale (self-hosted, https://radicale.parhelium.com/) w Basecamp, z łatwą opcją podglądu wszystkich eventów w jednym miejscu.
Stan zbadany (fakty zweryfikowane 2026-09-03, nie założenia)
Radicale (3.7.8, auth HTTP Basic):
- 6 kalendarzy: „N" (77 eventów), „Rodzinny kalendarz (Natalia i Kuba + dzieci)" (534), „K - Praca" (12), „K" (6), „K - Sport" (74), „666166646" (0 — pomijamy).
- REPORT calendar-query działa (zweryfikowane realnym wywołaniem) — z getetag + calendar-data; filtr time-range działa.
- Eventy: UID, TZID=Europe/Warsaw, VTIMEZONE, VALARM, RRULE; występują eventy 0-czasowe (DTSTART==DTEND).
- Klient piszący: DAVx5 (telefony).
Basecamp (BC3 API + CLI, konto Convertere 6251746, bot [email protected]):
- JEDYNY projekt dostępny botowi: 48546146 „Convertere - operacyjnie". Bot NIE może tworzyć projektów (403) — projekt(y) musi założyć Kuba w UI i dodać bota.
- Schedule: każdy projekt ma DOKŁNIE JEDEN Schedule (CLI + realne wywołanie).
- Tworzenie wpisów Schedule działa przez API — realny test create OK (wpis testowy 10270899636 w projekcie Operacyjnie do usunięcia przez CEO).
- Update/trash wpisów: endpointy w docs BC3, niezweryfikowane realnie (akcja destrukcyjna odrzucona w sesji) — do potwierdzenia w PoC.
- Brak klucza idempotencji w API — dedup po stronie klienta.
- Rate limit: 429 + Retry-After; pierwszy zaobserwowany próg ~50 req/10 s per IP (dynamiczny).
Warianty architektury (odpowiedź na pytania Kuby)
| Kryterium | A1 (1 projekt BC) | A2 (2 projekty: praca/prywatne) | B (projekt per kalendarz) | C (feed zewn. za auth) |
|---|---|---|---|---|
| „Wszystko w jednym miejscu" | ✅ (1 Schedule) | ⚠️ 2 Schedule | ❌ rozproszone | ✅ (własny widok) |
| Separacja prywatne/służbowe | ❌ | ✅ | ✅ | ✅ |
| Koszt utrzymania | najniższy | niski | średni (4–5 projektów) | niski, ale trzeci system |
| Spełnia cel „w Basecamp" | ✅ | ✅ | ✅ | ❌ |
| Prywatność (RODO) | niska | średnia | średnia | wysoka (bez BC) |
| Rozszerzalność | zmiana configu | zmiana configu | nowy projekt + bot (człowiek) | zmiana configu |
Odpowiedzi: (1) TAK — da się wiele kalendarzy do 1 Schedule (zweryfikowane API); (2) wiele kalendarzy w BC = wiele projektów (Schedule 1/projekt) — rozprasza widok; (3) rekomendacja: A2 — dwa projekty („Kalendarze (praca)" + „Kalendarze (prywatne)"), routing target_project per źródło w configu; B odrzucone (rozproszenie, bot nie może tworzyć projektów), C komplementarne (nie zamiennik; wyłącznie za auth — publiczny feed prywatnych eventów wykluczony).
Kierunek synchronizacji
Decyzja Kuby 2026-09-03 (nadpisuje one-way): sync DWUKIERUNKOWY dla wpisów zarządzanych przez sync.
- Forward (Radicale → BC): podstawowy kierunek — pełny opis poniżej.
- Reverse (BC → Radicale): zmiana LUB usunięcie wpisu BC z prefiksem (
[N],[Rodzina],[K-Praca],[K],[K-Sport]), będącego własnością syncu → propagacja do Radicale (update VEVENT: SUMMARY bez prefiksu, DTSTART/DTEND; albo DELETE zasobu). Istniejące RRULE/EXDATE VEVENT są zachowywane. Konflikt (obie strony zmienione) → wygrywa Radicale (log WARN). - Pomijane: wpisy BC bez prefiksu (ręczne) — sync ich nie dotyka w żadną stronę; usunięcie prefiksu z tytułu = „odłączenie" wpisu (unclaimed, koniec synchronizacji w obie strony).
- Anty-pętla: każda własna operacja zapisu odnotowana w state (stabilny hash tytuł+daty); cykl działa tylko na różnice ≠ ostatnia własna operacja.
- Gwarancja przetrwania: Radicale down ⇒ cykl NO-OP (zero trashów/update'ów BC); trash BC wyłącznie przy poprawnej odpowiedzi Radicale i faktycznym braku eventu; po 48 h porażek cron self-disable + alert, wpisy BC nietknięte.
- Historyczna rekomendacja one-way (poniżej) została nadpisana powyższą decyzją: One-way: Radicale → Basecamp, read-only po stronie BC…
Mechanizm
- Skrypt Python na tym serwerze, cron co 15 min, lock (flock);
icalendar+requests+zoneinfo; state SQLite (/var/lib/radicale2basecamp/state.db, 0600) + codzienny backup (retencja 14 d) + procedura--reconcile/--adopt. - Config
/etc/radicale2basecamp/config.toml0600 poza repo — dane dostępowe wyłącznie tam (w tej spec publicznej haseł NIE ma — referencja: „dane dostępowe w konfiguracji CEO"). - Algorytm per cykl: REPORT calendar-query z time-range [−30 d; +180 d] (getetag) → diff etagów → calendar-multiget dla zmienionych → parsowanie ICS (RRULE + EXDATE + RECURRENCE-ID rozwijane lokalnie) → mapowanie na wpisy BC → kolejność operacji: trashes → creates → updates → zapis stanu + log.
- Tytuł wpisu BC:
[Prefiks] Tytuł(np.[N],[Rodzina],[K-Praca],[K],[K-Sport]); notify=false, participants=[]. - Timezone: TZID=Europe/Warsaw przez zoneinfo; UTC wprost; floating → zakładamy Europe/Warsaw (konfigurowalne). Eventy 0-czasowe: hipoteza (BC odrzuca ends==starts) do PoC; polityka jawna: extend 15 min z dopiskiem „[zero-duration]".
- Recurring: domyślnie osobne wpisy BC per wystąpienie; wyjątek — >60 wystąpień i prosty RRULE → natywny
recurrence_scheduleBC (1 wpis). Klucz: (calendar, UID, RECURRENCE-ID/occurrence_date). Zmiana RRULE → deterministyczny rebuild wystąpień mastera. - Idempotencja: lookup po stanie przed create; polityka wobec obcych wpisów BC: sync ich nie rusza (opcjonalna flaga
--claim). Guard masowego usunięcia: >20% trashy per kalendarz w cyklu → pause + alert (z wyjątkiem legalnym: rebuild jednego mastera). - Rolling window: wpisy poza oknem [−30;+180] trashowane w kolejnym cyklu.
- Limity: throttle ≤5 req/s, honorowanie Retry-After, backoff 5xx; timeout cyklu 10 min (kill bezpieczny — stan per operacja); pierwszy sync
--fullz timeoutem 20 min (burst 600–800 create ≈ 2–3 min).
Failure modes i monitoring (podsumowanie)
7 scenariuszy (Radicale down, BC down, wygaśnięcie tokenu BC ~70 h, DNS, dysk pełny, uszkodzenie state.db, timeout) — każda z zdefiniowanym zachowaniem i odzyskiwaniem; kill w trakcie cyklu bezpieczny. Alert po 3 nieudanych cyklach dwoma kanałami: komentarz BC + lokalny watchdog (niezależny od BC). Heartbeat file do monitoringu.
Bezpieczeństwo i prywatność (RODO)
- Sekrety: loginy/hasła wyłącznie w
/etc/radicale2basecamp/config.toml(0600, poza repo). W tej publicznej spec NIE MA danych dostępowych — wyłącznie identyfikatory techniczne w URL kalendarzy (hasła nieobecne). - Minimizacja: do BC trafia tylko tytuł + daty (bez DESCRIPTION/VALARM/uczestników/załączników; LOCATION opcjonalnie decyzją Kuby).
- Retencja i prawo do usunięcia: usunięcie w Radicale → sync trashuje w BC; kosz BC czyści trwale po 30 dniach; logi bez tytułów (INFO), DEBUG 0600 rotacja 14 d na tym samym serwerze.
- Administratorem danych kalendarza rodzinnego jest Kuba (wspólnie z Natalią) — decyzja o przekazaniu tytułów prywatnych eventów do Basecamp (USA) należy do administratora (ryzyko R1).
- Opcja wdrożeniowa: dedykowane konto
syncread-only w Radicale (rights per-collection).
Kryteria akceptacji wdrożenia (mierzalne)
- 100% eventów z 5 aktywnych kalendarzy (6. pominięty: 0 eventów) w oknie [−30;+180 d] w Schedule projektu docelowego wg configu, z poprawnym prefiksem (Radicale vs BC ±0).
- Edycja eventy w Radicale widoczna w BC ≤ 20 min.
- Usunięcie → trash w BC ≤ 20 min; zero duplikatów po 3 pełnych restartach (w tym kill -9 w trakcie cyklu).
- RRULE: poprawne wystąpienia; DST: event 19:15 CET = 18:15 UTC zimą, 19:15 CEST = 17:15 UTC latem; RECURRENCE-ID = osobny wpis; EXDATE = brak wpisu.
- Po 7 dniach: zero duplikatów, zero utraconych eventów, 100% cykli OK lub z poprawnym retry.
- Sekrety nieobecne w repo/spec/logach publicznych (grep).
Plan wdrożenia (fazy)
- Faza 0 (Kuba, ręcznie): załóż projekt(y) „Kalendarze (praca)"/„(prywatne)" w BC UI + dodaj bota + podaj buckets id. → warunek startu (bot nie tworzy projektów).
- Faza 1 (PoC, kalendarz „N"): sync read-only, weryfikacja strefy, prefiksów, realny test update/trash (niezweryfikowane w tej sesji).
- Faza 2: wszystkie 5 kalendarzy, state, dedup, idempotentny restart.
- Faza 3: recurring + edge cases (RRULE/EXDATE/RECURRENCE-ID, 0-czasowe, DST).
- Faza 4: monitoring, runbook re-auth BC (krok człowieka).
- Faza 5 (opcja): LOCATION w opisie; ewentualne zmiany routingu projektów.
Ryzyka (top)
R1 prywatne eventy w BC USA (RODO) — minimizacja + decyzja administratora; R2 wygaśnięcie tokenu BC ~70 h — runbook + 2-kanłowy alarm; R3 rate limit — throttle + Retry-After; R4 masowe usunięcie w źródle — guard 20% + pause; R5 DST/strefy — zoneinfo + testy; R6 bot nie tworzy projektów — Faza 0; R7 kolizja UID — klucz (calendar, UID).
Otwarte pytania (do decyzji Kuby)
- A1 czy A2 (rekomendacja: A2)?
- Akceptacja R1 (prywatne eventy w Basecamp)?
- LOCATION/DESCRIPTION przenoszone? (domyślnie NIE)
- Dedykowane konto
syncread-only w Radicale?
Status
ZATWIERDZONE przez Kubę 2026-09-03 — decyzja: wariant A1 (wszystkie kalendarze → 1 Schedule), projekt „Rodzinne" bucket 48748049, schedule 10266228493. Prefiksy w tytułach per kalendarz: [N], [Rodzina], [K-Praca], [K], [K-Sport]. R1 zaakceptowane (sync wszystkich, w tym prywatnych). LOCATION: domyślnie NIE. Implementacja w toku wg faz (Faza 0 zrealizowana — projekt utworzony przez Kubę).
Źródła (Pliki)
- Spec pełna:
/root/docs/superpowers/specs/2026-09-03-radicale-basecamp-sync-design.md - Audyty iteracji 1–5:
/root/docs/superpowers/specs/2026-09-03-radicale-audit-iter{1..5}.md - Board kanban: parhelium, karta t_52606ca9
Źródła (Linki URL)
- Radicale: https://radicale.parhelium.com/
- Basecamp BC3 API (schedule entries, rate limiting): https://github.com/basecamp/bc3-api
- Projekt Basecamp: 48546146 (Convertere - operacyjnie), konto 6251746