app: the history row was naming the wrong document's privacy level
A row read "22 kB · full" directly beneath "uploaded to fmr", while the status
line above said the upload went as BALANCED. Both were true and they described
different documents: the row showed ArchivedRun.anonymization, which describes
the *archived* copy - deliberately unredacted, so always "full" - and the status
line described the *uploaded* copy.
Read together, that says the complete data was uploaded when a redacted copy was
sent. A privacy display that overstates what left the device is worse than none,
and telling the user what left the device is the one thing this screen is for.
The level a run was uploaded at is now recorded separately (uploaded_as) and the
row says "kept complete on this device" / "uploaded to fmr as balanced" - each
label naming the copy it belongs to.
Two more from the same screenshot:
- Every row showed no verdict. The archive read summary.verdict; the schema
calls it summary.overall. Silently null on every run, so the list's most
prominent element was blank while everything else looked fine. The test
fixture had the same wrong field name, which is why it passed.
- The status line rendered the server's raw JSON index entry into the UI.
Verified on device: a fresh run archives with verdict "yellow" and
uploaded_as "balanced" beside anonymization "full".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ac6c653115
commit
6bba420845
@@ -31,10 +31,23 @@ data class ArchivedRun(
|
||||
val verdict: String? = null,
|
||||
@SerialName("finding_count") val findingCount: Int = 0,
|
||||
@SerialName("size_bytes") val sizeBytes: Long = 0,
|
||||
/**
|
||||
* How the *archived* document is redacted. Always "full" in practice, because the archive
|
||||
* deliberately keeps the unredacted run - see the package doc. This is not what was uploaded.
|
||||
*/
|
||||
val anonymization: String = "full",
|
||||
/** Whether this run has been accepted by a server, so history can show what is backed up. */
|
||||
val uploaded: Boolean = false,
|
||||
@SerialName("uploaded_to") val uploadedTo: String? = null,
|
||||
/**
|
||||
* The level the run was *uploaded* at, which is a different document from the archived one.
|
||||
*
|
||||
* Kept separately because conflating the two is actively misleading: the history row showed
|
||||
* the archive's own level ("full") directly beneath "uploaded to fmr", which reads as "the
|
||||
* complete data was uploaded" when a redacted copy had been sent. A privacy display that
|
||||
* overstates what left the device is worse than none.
|
||||
*/
|
||||
@SerialName("uploaded_as") val uploadedAs: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -119,7 +132,7 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu
|
||||
fun deleteAll(): Int = list().count { delete(it.id) }
|
||||
|
||||
/** Records that a server accepted this run, so history can distinguish backed-up from local. */
|
||||
fun markUploaded(id: String, serverName: String) {
|
||||
fun markUploaded(id: String, serverName: String, uploadedAs: String? = null) {
|
||||
val f = File(dir, safe(id) + META_EXT)
|
||||
val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
|
||||
?: return
|
||||
@@ -127,7 +140,7 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu
|
||||
f,
|
||||
json.encodeToString(
|
||||
ArchivedRun.serializer(),
|
||||
meta.copy(uploaded = true, uploadedTo = serverName),
|
||||
meta.copy(uploaded = true, uploadedTo = serverName, uploadedAs = uploadedAs),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -181,7 +194,10 @@ class RunArchive(private val dir: File, private val now: () -> Long = System::cu
|
||||
id = id,
|
||||
savedAtEpochMs = now(),
|
||||
startedAt = run["started_at"]?.jsonPrimitive?.content,
|
||||
verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content,
|
||||
// The schema calls it `overall` (Summary.overall); reading `verdict` here silently
|
||||
// yielded null for every run, so the history list's most prominent element - the
|
||||
// coloured verdict - was blank on every row.
|
||||
verdict = doc["summary"]?.jsonObject?.get("overall")?.jsonPrimitive?.content,
|
||||
findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0,
|
||||
sizeBytes = size,
|
||||
anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full",
|
||||
|
||||
@@ -25,7 +25,7 @@ class RunArchiveTest {
|
||||
private fun doc(id: String, findings: Int = 1, pad: Int = 0): String {
|
||||
val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" }
|
||||
return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" +
|
||||
""""findings":[$f],"summary":{"verdict":"warn"},"pad":"${"x".repeat(pad)}"}"""
|
||||
""""findings":[$f],"summary":{"overall":"warn"},"pad":"${"x".repeat(pad)}"}"""
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -120,13 +120,40 @@ class RunArchiveTest {
|
||||
fun uploadStateIsRecorded() {
|
||||
val a = archive()
|
||||
a.save(doc("run-1"))
|
||||
a.markUploaded("run-1", "fmr")
|
||||
a.markUploaded("run-1", "fmr", "balanced")
|
||||
val meta = a.list().single()
|
||||
assertTrue(meta.uploaded)
|
||||
assertEquals("fmr", meta.uploadedTo)
|
||||
assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry")
|
||||
}
|
||||
|
||||
// The archive's own level and the level a run was uploaded at describe *different documents*.
|
||||
// Showing the archive's ("full", because the archive is deliberately unredacted) next to
|
||||
// "uploaded to fmr" reads as "the complete data was uploaded" when a redacted copy was sent —
|
||||
// a privacy display that overstates what left the device is worse than none.
|
||||
@Test
|
||||
fun theUploadedLevelIsRecordedSeparatelyFromTheArchivedOne() {
|
||||
val a = archive()
|
||||
// A real archived document carries no privacy stamp: the anonymizer never runs on the
|
||||
// archive. The shared doc() fixture has one, which is exactly the unrealism that let this
|
||||
// confusion through in the first place.
|
||||
a.save("""{"run":{"id":"run-1"},"findings":[],"summary":{"overall":"green"}}""")
|
||||
a.markUploaded("run-1", "fmr", "balanced")
|
||||
val meta = a.list().single()
|
||||
assertEquals("full", meta.anonymization, "the archived copy is unredacted, by design")
|
||||
assertEquals("balanced", meta.uploadedAs, "the uploaded copy was redacted, and must say so")
|
||||
}
|
||||
|
||||
// The verdict is read from `summary.overall` — the schema's actual field name. Reading
|
||||
// `summary.verdict` silently yielded null for every run, so the history list's most prominent
|
||||
// element was blank on every row while everything else looked fine.
|
||||
@Test
|
||||
fun theVerdictComesFromTheSchemasOverallField() {
|
||||
val a = archive()
|
||||
a.save("""{"run":{"id":"r1"},"findings":[],"summary":{"overall":"yellow"}}""")
|
||||
assertEquals("yellow", a.list().single().verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRemovesBothFiles() {
|
||||
val a = archive()
|
||||
|
||||
Reference in New Issue
Block a user