The export was a one-way door. A user changing phone started the prediction
model from zero, which made "your history is yours" a smaller promise than it
sounded — and the archive says, in its own prose, "keep this file, a future
version of the app will be able to read it back". Nothing checked that was true.
`ExportReader` mirrors `ExportDocument.render` in the same module, over a
hand-written strict JSON reader. Strict is the point: it refuses duplicate keys,
trailing content, non-integer numbers and unsupported escapes, because a parser
that quietly accepts a trailing comma is a parser that will one day accept
somebody else's file and import it as a cycle history. A missing magic string is
`NotOurFile`; a version above this build's is `NewerFormat` rather than a
half-read; unknown *keys* are ignored, which is what lets the format grow; an
unknown `source` becomes `IMPORTED` rather than a guess about the user.
The write is `CycleRepository.importHistory`, not a loop over
`confirmPeriodStart`, because that would score the standing forecast against
history the app never predicted — and the backfill guard does not catch all of
it, since a file exported this morning carries this morning's period. So the
scoring is not dodged by accident of date; it is not reached. The engine runs
exactly once, at the end, over the whole history. Merge keeps what is on this
phone untouched (§14 — a file does not get to rewrite a record she made here),
and replace goes through the new `PeriodDatabase.replaceEverything`, which
empties the tables inside the same transaction as the writes: a failure between
the wipe and the inserts would leave her with neither her own history nor the
file's. Delete My Data still calls `deleteEverything` and still gets its VACUUM —
replacing a history is not erasing one.
The screen asks the one question the app must not answer for her, before the
picker rather than after, because the screen that asked is the screen a re-lock
destroys. Add is the filled button and needs no confirmation; replace is
confirmed by a dialog that names the deletion and points back at the safe
option. The result lands in the banner above the tabs, beside the export's, as a
live region — it arrives with no focus change.
The archive cannot touch the app lock. It carries `biometricUnlock` and the
importer does not apply it: there is no PIN in the file and there cannot be.
Also raises the lock ViewModel test's hang budget to three minutes. 60s passed
alone and failed when four modules ran in one invocation — three real PBKDF2
derivations at 210k iterations. The budget is for catching a stuck coroutine,
not a slow one.
Verified: `:core:export` round trip against the committed golden file (17);
`CycleRepositoryImportTest` — one recalculation for a four-record archive,
nothing scored, one standing forecast, and a failed replace leaving the history
intact (prove-guard: remove `withTransaction` from `replaceEverything`, exactly
one red); `DataImporterTest`, `ImportScreenTest`, `ImportControllerTest`,
`ImportCopyTest`. On PeriodQA: Settings → Restore → Add → Downloads →
records-2026-08-19.json gives "Restored. 4 periods and 2 spotting days added.",
Insights then shows three cycles and accuracy still unscored, and the same file
a second time gives "Everything in that file was already recorded here, so
nothing changed."
closes#58
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.
## The format, because it outlives the batch
One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.
Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.
Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.
## Plaintext, and that is the decision rather than the default
An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.
## Only the user's own data, as a compile error
:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.
Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.
## No second copy, ever
The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.
The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.
## Two new guards, both proved to fail
checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.
checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.
261 JVM tests, none skipped. Five guards green.
closes#35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>