Share a file into a chat, and save one back — with the label it carries

Both directions: pick a document from the vault inside a conversation, and
save a received one into the vault at the classification it arrived with.

🔴 **This is the answer to android#15, and the codebase had already given
it.** `Attachment.FILE` has carried this since before the lattice existed:
*the label is a MARKING, not a permission — the content key is in the same
envelope, so whoever can read the message can read the file.* That settles
the ★-property question. The key travels with the file by design, because
that is what makes sending work at all; there is no trusted computing base
to enforce against, since the person holds every key on a device they
control. A client-side refusal would be a suggestion, and **a suggestion
dressed as a control is worse than none** — it is the one people plan
around.

So neither client implements the ★-property, `LabelTest` keeps asserting
that absence, and PARITY.md now says plainly that Files is not mandatory
access control. What it IS: cryptographic read separation, plus a
classification that travels. The picker says so before the tap, in those
words.

Three things found on the way:

🔴 **A second copy of the vocabulary.** `classificationName` in
ChatScreens listed the same four names as `FileLevels`, agreeing by
coincidence with nothing making them agree. Rename a level for the Files
screen and the chat bubble keeps showing the old word for the same number,
in the same conversation, on the same phone. Collapsed to one.

🔴 **Attach and Camera did the same thing.** Both launched the gallery, and
there was no way to send a document at all. Attach now opens a small panel
— photo, or from Files — which keeps the kit's composer row at its four
controls.

🔴 **iOS's picker lists the root only**, so a file inside a folder cannot
be sent from a conversation, and nothing says so — it is simply absent,
which reads as "it isn't there". `LocalVault.allFiles()` walks the whole
tree. Filed as ios#68.

⚠️ The picker is the ONE place this vault hides a row it cannot read.
Everywhere else it insists on showing them, because hiding one leaves
somebody unable to see anything is there; a picker exists only for
choosing, and a row that can never be chosen is furniture.

⚠️ A received file is always saved as an encrypted copy, never a link —
there is no file on this phone to point at — and always at the label it
arrived with. Re-labelling somebody else's document on receipt would
launder its classification, and the number is the only thing that survived
the trip.

9 new tests. Each guard proved by breaking the code: writing the label for
every attachment kind fails the image test; dropping compartments fails
the two that name them; removing the fallback caption fails the
older-build test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 15:34:29 -04:00
co-authored by Claude Opus 5
parent fbf2c4c531
commit 6ec626cde2
9 changed files with 562 additions and 14 deletions
@@ -270,6 +270,18 @@ class Conversations(
height: Int = 0,
duration: Double = 0.0,
groupId: String? = null,
/** File only: the document's own name. */
filename: String = "",
/**
* File only: the label it was filed under.
*
* 🔴 **A MARKING, not a permission.** The content key rides in the same
* envelope, so whoever can read the message can read the file. This
* travels so the recipient can file it the same way — it must never be
* described, here or on screen, as something that stops them opening it.
*/
classification: Int = 0,
compartments: List<Int> = emptyList(),
): Sent {
val sealed = Attachment.seal(data)
val id = relay.uploadAttachment(sealed.blob)
@@ -277,6 +289,8 @@ class Conversations(
kind = kind, id = id, key = sealed.key, digest = sealed.digest,
bytes = sealed.blob.size, mime = mime,
width = width, height = height, duration = duration,
filename = filename, classification = classification,
compartments = compartments.sorted(),
)
return sendPlaintext(
toUserId,
@@ -301,12 +315,19 @@ class Conversations(
width: Int,
height: Int,
caption: String? = null,
kind: String = Attachment.IMAGE,
filename: String = "",
/** See the note on [sendAttachment]: a marking, not a permission. */
classification: Int = 0,
compartments: List<Int> = emptyList(),
): Sent {
val sealed = Attachment.seal(data)
val id = relay.uploadAttachment(sealed.blob)
val ref = Attachment.Ref(
kind = Attachment.IMAGE, id = id, key = sealed.key, digest = sealed.digest,
kind = kind, id = id, key = sealed.key, digest = sealed.digest,
bytes = sealed.blob.size, mime = mime, width = width, height = height,
filename = filename, classification = classification,
compartments = compartments.sorted(),
)
val body = Content.attachment(ref, caption, group.id,
groupName = vault.groupName(group.id))
@@ -344,6 +344,28 @@ class LocalVault(
found
}
/**
* Every file in the whole tree, for a picker that has no business showing
* folders.
*
* 🔴 **The whole tree, not the top of it.** iOS's picker lists `list(nil)`,
* so a document filed into a folder cannot be sent from a conversation at
* all — and nothing says so; the file is simply absent from the list, which
* reads as "it isn't there". Filed as ios#68.
*
* ⚠️ Unreadable and missing rows are dropped here rather than shown
* disabled. Everywhere else this class insists on showing a row you cannot
* open — because hiding it would leave somebody unable to see that anything
* is there. A picker is the exception: its only purpose is choosing
* something to send, and a row that can never be chosen is furniture.
*/
fun allFiles(): List<Entry> = lock.withLock {
nodes.filter { !it.isFolder }
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
.filter { it.name != null && !it.isMissing }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" })
}
fun contents(of: Node): ByteArray = lock.withLock { readContents(of) }
private fun readContents(node: Node): ByteArray {
@@ -468,6 +468,52 @@ class LocalVaultTest {
assertEquals(listOf("b.txt"), reopen().list(null).map { it.name })
}
// ---- The picker ------------------------------------------------------
/**
* 🔴 **The whole tree, not the top of it.** iOS's picker lists the root
* only, so a document filed into a folder cannot be sent from a
* conversation at all — and nothing says so: the file is simply absent,
* which reads as "it isn't there". Filed as ios#68.
*/
@Test
fun `everything sendable is offered, however deep it is filed`() {
val folder = vault.createFolder("Reports", open)
val inner = vault.createFolder("2026", open, parentId = folder.id)
vault.add("a".toByteArray(), "top.txt", open)
vault.add("b".toByteArray(), "buried.txt", secret, parentId = inner.id)
assertEquals(listOf("buried.txt", "top.txt"), vault.allFiles().map { it.name })
}
/** Folders are places, not files. Sending one means nothing. */
@Test
fun `folders are not offered`() {
vault.createFolder("Reports", open)
assertTrue(vault.allFiles().isEmpty())
}
/**
* ⚠️ **The one place this class hides a row it cannot read.** Everywhere
* else an unreadable row is shown, because hiding it would leave somebody
* unable to see that anything is there. A picker's only purpose is choosing
* something to send, and a row that can never be chosen is furniture.
*/
@Test
fun `rows that could never be sent are left out of the picker`() {
refs.files["doc://gone"] = "x".toByteArray()
vault.reference("doc://gone", "vanished.pdf", open)
vault.add("readable".toByteArray(), "fine.txt", open)
refs.files.remove("doc://gone")
assertEquals(listOf("fine.txt"), vault.allFiles().map { it.name },
"a file that has moved was offered as sendable")
val stranger = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() }
assertTrue(stranger.allFiles().isEmpty(),
"a row this device cannot decrypt was offered as sendable")
}
// ---- The tree --------------------------------------------------------
@Test