Commit Graph
10 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 ca6265e7a6 The watched mark is no longer ours alone
This engine has written `seen` into the shared shape all along, on the
grounds that the iPhone had no unread mark on a story. That turned out to
be wrong in a way worth recording: the iPhone HAS the mark and was not
writing it, locally or to a backup, so the ring around every contact
relit at each launch and a restore through it lost the lot.

The iPhone writes it now, so the comment claiming the field is ours alone
is false and is replaced, and `stories[].seen` moves out of the vector's
divergence list and into the canonical set.

No behaviour change here. The code was already right; only what it said
about the other engine was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:17:32 -04:00
Paul HittandClaude Opus 5 3607a85459 A federated thread travels under its address, not this app's key for it
This app keeps one `threads` map and namespaces it with prefixes: `group:`
for a group, `addr:` for somebody on another relay. `toShared` strips the
group one and never stripped the address one, so a backup written here
said `addr:ada@relay.example`, which is not an address. The iPhone writes
the bare one.

Neither side could read the other's federated conversations, and the
damaging direction is iPhone to Android: a bare address arriving here
lands in a map whose unprefixed keys are user threads.

Same mistake as the story id one commit ago, one field over, and the group
prefix beside it was already being handled correctly, which makes it a
slip rather than a decision. An internal storage detail does not belong in
a shared shape.

Coming back in, the prefix goes on unless the file already carries it, so
an older backup does not end up with two.

One test was pinning the bug: it asserted the shared shape contained the
prefixed key. It now asserts the bare address, and that the prefix is
absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:09:06 -04:00
Paul HittandClaude Opus 5 88397a8bda A story is identified by its key, never by the other device's row number
The two engines both had a notion of "this story, wherever it is read",
and kept it in different fields. This app put it in `id`. The iPhone
numbers its rows locally in `id` and kept the shared identity in `key`.
One name, two meanings, so a story travelling between the two phones came
back unrecognisable and the import filed it as a second copy of itself.

`key` is the identity now and `id` is written by neither engine. A local
handle has no business in a shared shape.

`id` is still read as a fallback for files written before this was
settled, and only when it is a STRING. A number there is the writing
device's row handle: stringifying it produced a confident, meaningless
identity, which is the original bug reached by another path. A story with
neither falls back to its post time, the same value the iPhone falls back
to, so the two still land together.

Two tests were pinning the old contract rather than testing it. One
asserted the internal id came from the shared `id` when both were present;
the other was called `a numeric story id from the iphone becomes a usable
name`, which is the bug stated as a requirement. Both rewritten, and the
new sibling covering the post-time fallback is what caught the numeric
case above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:47:35 -04:00
Paul HittandClaude Opus 5 1049c1d118 Pins survive a backup, and a pin keeps what this app cannot show
The vector's canonical set just widened to every field both engines carry,
and pins was the one this app failed: it never read or wrote them, so a
pinned message was lost crossing in either direction and nothing said so.

A pin is kept whole now, at and by included, though this app shows
neither. Dropping a field an engine cannot render turns a backup into a
lossy hop: restore an iPhone through an Android phone and the pins come
back stripped of who pinned them and when. The vault keeps the record
rather than just the mid, so setting a pin here no longer discards what
travelled with it.

Import merges into gaps, like the drafts beside it: a pin set on THIS
phone is the current one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:40:15 -04:00
Paul HittandClaude Opus 5 5fda2954c1 The story audience keeps its meaning when it crosses to an iPhone
This app stores the audience mode as its enum name, EXCEPT, and the iPhone
writes and reads it lower case. The export started carrying the field one
commit ago, so a backup moving between the two phones would have had the
mode dropped in silence.

Dropped is not neutral here. An audience that fails to restore is an
account back on everyone, which shows stories to the people somebody had
deliberately left out, and nothing on screen would have said so.

Converted in both directions where the ok/connected rename already lives,
and asserted in both directions, because a one-way test would pass on two
Android phones and still lose this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:12:40 -04:00
Paul HittandClaude Opus 5 f9463d8ba6 The export carries the choices somebody made, not only their messages
Following the SPEC 16.3 rule the same sweep just wrote down. The export
held threads, groups, calls, stories, the roster and the group keys, and
left behind the story audience, muted authors, the story archive, drafts,
polls in flight and the 8.9c presence tokens. Restoring on a new phone
lost every one of them silently, which looks exactly like a person who
never set them.

They travel under the names the iPhone already writes, so a backup moves
between the two phones rather than only between two Android ones. The
presence tokens travel as the two per-contact maps alone, never the
delivery record around them, which holds this account's own key and 16.1
excludes.

Import merges into gaps rather than overwriting: a draft typed on THIS
phone is the newer thought, and a presence token this device already
holds is the live one the relay knows the digest of, so an older copy
from a file would make this account invisible to that contact until they
issued another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:10:47 -04:00
Paul HittandClaude Opus 5 fd3a52b35c Story reactions survive a restart, and ride the backup
They lived in a map in memory. An author watched a tally empty itself
overnight and the reactor watched their own reaction disappear, and there
was nothing on screen to say either had happened. The iPhone was fixed
yesterday; this is the other half.

The vault keeps them now, loaded when the session opens and written on
every change, withdrawals included. The export carries them too, in the
same shape the iPhone reads, so a tally survives a move between the two
phones as well as a restart. Import merges per reactor rather than per
story: two devices can hold different halves of one story's tally, and
taking a story's map wholesale would drop the other half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:06:19 -04:00
Paul HittandClaude Opus 5 fb68878752 Check the backup payload against the published vector
Both apps were taught one backup shape and nothing checked that they
agreed. Each side's tests asserted against what the OTHER was believed
to write, transcribed by hand from reading its source, which is a test
of one person's reading rather than of interoperability.

`backup-payload-v1.json` in flamenet-e2e is now the authority, and the
bar it sets is exact: decode the canonical payload, encode it again, and
the result must match byte for byte once both are put in a canonical
order. The same vector is checked on the iPhone, so the two are
compared against one file rather than against each other's source.

One real fix came out of writing it. `messageFromShared` wrote
`author` unconditionally, so a one-to-one message that carried no author
came back claiming user zero sent it, and a round trip through this app
changed the file. Storage keeps an author on every message and reads a
missing one as 0, so it is now written only when the file carried one.

The env var is exported in BOTH ci jobs, the pinned one and the one that
runs at vectors HEAD, and added to the gate that checks every vector
file exists before the suite runs. A path that silently does not exist
puts the tests back where they were and the failure reads as a protocol
bug rather than a missing file. `.e2e-ref` moves to the commit that adds
the vector.

Full suite: 1279 tests, no failures. `ci/lint` accepts the pipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:43:33 -04:00
Paul HittandClaude Opus 5 7ab5c774cb A file in a chat keeps its name and its label
`Ref.toJson` is what the vault persists next to a message, and it never
wrote `filename`, `classification` or `compartments`. They reach the far
end correctly, as additive top-level keys on the envelope, so a received
document ARRIVED named. It was the local copy that lost them, on the
first read back out of storage. A file in a chat therefore had a name
until the app was restarted and never again, which reads as a damaged
file rather than as the app forgetting.

`fromJson` now defaults those three to null rather than to empty.
Passing nothing means "not a wire parse, read what was stored"; passing
a value, including an empty one, means the envelope said so and wins.
Empty defaults could not tell those two apart, and answering "" for both
is exactly what made a stored file nameless.

The same rule fixes `expires`. The history path hands in 0.0, and 0
means "does not expire", so every stored story read back as immortal:
its bytes were never dropped and it rendered forever. A caller that
names an expiry still means it; one that does not now gets what the
vault kept.

They are stored under the same names the shared backup format uses, so
`BackupPayload` carries them straight through. A document restored from
a backup arrived with no name and no label before this, which is worse
than not restoring it.

Four tests on the storage round trip, one on the envelope still winning,
one that an attachment which never had a name does not invent one, and
one carrying a document through a backup whole. Full suite: 1276 tests,
no failures. No new ktlint violations in Content.kt (3 at HEAD, 3 now).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:29:31 -04:00
Paul HittandClaude Opus 5 5a5e41bfa2 Android writes and reads the shared backup format
Section 16 pins the envelope and says nothing about what is sealed, so
each app sealed its own storage verbatim and stamped the file with the
platform that wrote it. Each then refused the other's, in as many words:
"import it on a device of the same kind". Somebody moving between an
Android phone and an iPhone lost every conversation they had.

The iPhone half landed first. This is the other half.

`BackupPayload` translates rather than dumps, which is the entire point.
This app stores a message under the keys its wire format uses (`secs`,
`fwd`, `rx`), keeps one-to-one and group threads in one map under
prefixed ids, hangs the attachment kind beside `att` rather than inside
it, abbreviates the sizes, and holds expiries in unix seconds. The
shared shape spells all of that out and counts in milliseconds.
Exporting storage as-is is what made the two formats incompatible in the
first place, so a copy would have reproduced the bug with more steps.

Federated threads are the one thing that does not fit. This app keys
them by address and the iPhone's history is keyed by numeric user id,
with a decoder that skips anything else, so an address left in `threads`
would be dropped in silence. They travel under `addrThreads` instead: an
iPhone ignores the key, an Android phone gets them back, and nothing
disappears without somebody having decided it should.

Import tells the two formats apart by SHAPE, not by file name, because a
name is whatever the person saving it chose. Section 16 announces itself
with `alg` and `ct`; the older export announces itself with `magic`. A
wrong passphrase on a section 16 file reports a wrong passphrase and
does NOT fall through to the legacy reader, which would have answered
"that file is not a Flamenet history export" for a file that is one and
sent somebody hunting for corruption instead of a typo.

Nothing writes the old format any more, and everything still reads it.

Seventeen tests: the shared side against the key names the iPhone
writes, the round trip back into this app's own storage shape, the unit
conversions in both directions, and a payload transcribed from the
iPhone's encoder. That last one is the weak point and the test says so:
it proves this app reads the shape the iPhone is BELIEVED to write. Only
a vector both apps are built against proves they agree, and
`backup-v1.json` pins the envelope alone, which is exactly how the two
shapes drifted apart.

Full suite: 1271 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:11:35 -04:00