sync(chunk 1): VTODO mapper with an unknown-property round-trip

A hand-rolled content-line model instead of ical4j: a raw (name, params,
value) tree is what the round-trip requirement wants, and a typed model
normalises away exactly what has to survive. lib-recur already does RRULE
and java.time is native at minSdk 29, so the 2.2 MB of zone data and the
registry shims buy nothing. Deviates from SYNC.md's library table — see
SYNC-PLAN.md decision 4.

- domain/ical: parser, serialiser, value codecs. No Android, no data types,
  so the floret-kit extraction stays a file move.
- data/tasks/ical/VTodoMapper: VTODO <-> TaskEntity. Claims a property only
  when it can reproduce it exactly; everything else round-trips verbatim
  through TaskEntity.unknown_properties, which already existed at v1 — no
  migration needed, and BEGIN/END lines carry the nesting the plan thought
  needed a second table.
- 19 fixtures as the specification, with the canonical comparison harness
  from SYNC.md: no property lost, modulo the enumerated allowlist.
- ICalendarWriter now delegates folding and escaping rather than carrying
  its own copy.

VALARM ownership settled: neither side writes the other's alarms. VALARMs
round-trip in the residue, local reminders stay in task_alarms. The
setAlarm collision was the provider's; the two stores are now disjoint.
This commit is contained in:
2026-09-04 16:45:03 +02:00
parent fd6c302c17
commit a30114efbb
32 changed files with 2010 additions and 48 deletions
+37 -1
View File
@@ -24,7 +24,7 @@ and says so rather than quietly picking the other branch.
| 1 | dav4jvm + cert4android distribution, and the Java-21 wall | **Vendor a known-good tree**, recompiled at our Java 17 target | `FAIL_ON_PROJECT_REPOS` stays; no Ktor/guava/xpp3 tail; MPL-2.0 headers retained and the attribution screen becomes mandatory, not optional. Freezes the API churn that shipped two breaking majors nineteen days apart |
| 2 | Conflict policy on 412 | **Server wins, local edit discarded** | DAVx5's policy. Terminates, always. The discarded edit is *surfaced* — a sync report the user can read — because silence is what makes this policy feel like data loss. The draft's "preserve as a duplicate" stays withdrawn: RFC 4791 §4.1 makes it unuploadable |
| 3 | External mode's future | **Kept, unchanged** | The mapper and the UI stay dual-capable. Retiring it is a separate decision that does not block sync, and it ships and works today |
| 4 | iCalendar library | **ical4j 4.3.0**, with the shims `SYNC.md` § *Libraries* enumerates | BSD-3 (attribution screen again). Costs the 2.2 MB of duplicated zone data, the mandatory `ical4j.properties` + `MapTimeZoneCache` + registry shim, and R8 keep rules. biweekly's missing tz database is the disqualifier for CalDAV round-tripping |
| 4 | iCalendar library | **None — a hand-rolled content-line model.** ⚠️ Overturned during chunk 1; `SYNC.md` § *Libraries* assumed a typed library was the only option | A raw `(name, params, value)` content-line tree is what the unknown-property requirement actually wants: a typed model normalises away exactly what must survive. `ICalendarWriter` already folds in 204 lines, `lib-recur` is already a direct `:app` dependency and does `RRULE`, and `java.time` is native at minSdk 29 — so ical4j's 2.2 MB of duplicated zone data, its production-exhausting `ZoneRulesProvider`, its mandatory properties/cache/registry shims and its release-only R8 failure buy nothing. VTIMEZONE bodies are already on the round-trip allowlist, so they round-trip opaquely. **Cost accepted: we own the lexer.** biweekly was rejected on the same ground as before |
---
@@ -134,6 +134,42 @@ local reminder edit destroys every server-side alarm on that task.
**Done when:** the corpus round-trips, the migration test passes both directions,
and no fixture loses an unknown property.
### Settled while building it
- ⚠️ **No schema migration was needed.** `TaskEntity.unknown_properties` already
exists at v1, reserved for exactly this and written by nothing. The residue
fills it. It holds leftover properties followed by whole sub-component blocks —
`BEGIN`/`END` lines are content lines like any other, so nesting and unknown
sub-components fall out of the same representation, which is what the plan
thought needed a second table.
- **`VALARM` ownership: neither side writes the other's alarms.** `VALARM`s
round-trip in the residue and are never authored; local reminders live in
`task_alarms` and are never serialised. The collision `SYNC.md` describes was
`AndroidTasksDataSource.setAlarm` bulk-deleting *provider property rows* — in
the own-store world the two stores are disjoint and neither can destroy the
other. Merging them is a later decision; the point is that it is now a
decision, not an accident.
- **The mapper claims a property only when it can reproduce it exactly.**
Everything else stays in the residue: a value it cannot parse or that is out of
range (`PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`),
a time it can read but not reproduce (floating, or a `TZID` this device's tzdb
lacks), and a `DTSTART`/`DUE` pair that disagrees on value type or timezone —
one `is_all_day` flag and one `timezone` column cannot author both, and
flattening the odd one out destroys it. Clamping or defaulting any of these
would be a silent rewrite of somebody's data. `SUPPRESSED_BY_RESIDUE` makes
"present in the residue" mean "do not author this".
- ⚠️ **Suppression is conditional, or editing breaks.** A residue property
suppresses its column only while the two still agree. If the user edits a field
whose stamp was unreproducible, the stale residue copy is evicted and the
column is authored — otherwise changing the due date of an imported task would
silently do nothing on the server. `RELATED-TO` is the deliberate exception: a
parent we have not fetched is not the same as no parent, so an unresolvable
link is kept rather than destroyed.
- **One normalisation is allowed beyond the stated allowlist**, and it is an
equivalence rather than a concession: an absent `STATUS` and
`STATUS:NEEDS-ACTION` mean the same thing for a to-do. Any other `STATUS`
compares exactly, so losing a completion still fails the corpus.
---
## Chunk 2 — auth, discovery, account