The one damage you should expect
Every append writes one line and a newline, flushes, and fsyncs before it returns, and the next append cannot start until that one has. So the damage the write path itself leaves has one expected place: the end of the file. If the process dies or the machine loses power in the middle of an append, the journal can end with a fragment, part of a JSON object with no closing brace and no newline.
That expectation rests on an assumption. Lesson 1 showed that a plain fsync on macOS hands the bytes to the drive without promising when, or in what order, the drive stores them. A power cut that catches the drive's cache can in principle leave damage further back too. The engine does not try to tell that apart from any other corruption, and neither does this lesson.
That event never happened. append never returned, so record never applied it, the route never answered, and no client was told the plate had landed. A phone that sent it still holds the plate in its outbox and sends it again later with the same request id (lesson 4 is why that is safe). Dropping the fragment loses nothing anyone was promised.
How replay tells the two kinds apart
Journal.events reads the whole file, splits it into lines, and first finds last, the index of the last line that is not blank. Then it walks the lines in order. Blank lines are skipped. Every other line goes through json.loads. When one fails, the function asks a single question: is this the last non-blank line?
| What replay meets | Where | What happens |
|---|---|---|
| A line that does not parse | The last non-blank line | A warning names the line, the generator ends, and the rebuild uses everything before it |
| A line that does not parse | Anywhere earlier | JournalError with the file and line number, and the rebuild stops |
| A fragment cut inside a multi-byte character | The end of the file | UnicodeDecodeError from read_text, before any line is parsed, and the rebuild stops |
| A blank line | Anywhere | Skipped |
| Valid JSON with an unknown type | Anywhere | ValueError from the store, and the rebuild stops (lesson 3) |
When the rebuild stops, it stops inside the engine's startup, so the engine does not come up at all. That is loud on purpose.
Why stopping is the right answer
Damage in the middle of the journal is not what an interrupted append leaves, because every earlier line was fsynced before a later one began. It means something else went wrong: a failing disk, a drive cache that lost writes it had accepted, a hand edit, a bad copy. That deserves a person, and the error message tells the person exactly where to look.
Skipping the line instead would build a mirror that is quietly wrong. Suppose the lost line was a class.proposed. A later class.adopted for that class would update zero rows without complaint, and a later class.linked would tie a plate to a class that does not exist. Every screen would still render. Nothing would say the record had a hole in it. A rebuild that refuses to start is easier to live with than one that starts with a hole.
Where the forgiveness ends
Here is something I found while writing this lesson. The forgiveness lives only on the read side. events skips the fragment, but nothing ever removes it from the file. The engine starts, and the next append opens the file in append mode and writes its line straight after the fragment, on the same line, because the fragment never got its newline.
I ran that against the real backend/journal.py in a scratch directory, nowhere near Dad's data. One good event, then a fragment, then one more append. Replay returned only the first event. The new one had fused with the fragment into one unparseable line, and since that line was now last, replay forgave it too. One append later, the fused line was no longer last, and replay raised JournalError for line 2.
So the expected damage, followed by ordinary use, turns into the unexpected kind within two writes. Worse, the event lost on the way was a real one: append had fsynced it and returned, and the running engine had shown it, because apply put it in the mirror. The loss only surfaces at the next start. Nothing in the engine's test suite exercises the torn-line path, so nothing caught this.
A second gap sits in front of the handler. events first decodes the whole file with read_text(encoding="utf-8"), and only then looks at lines. The journal writes Korean as it is, because of ensure_ascii=False, and a Hangul syllable takes three bytes in UTF-8. A tear that falls inside one of those leaves an incomplete character at the end of the file, and read_text raises UnicodeDecodeError before the loop and its JSONDecodeError handler ever run. I checked that too, in the same scratch setup: one good event, then a fragment that stops one byte short of a Korean syllable. The case the engine means to forgive then stops startup exactly like corruption, with an error that names a byte offset and no line.
The repair belongs at open time, before the first append, and it works on bytes, not decoded text, so a tear inside a character is just another fragment. If the file does not end with a newline, look at the bytes after the last newline. If they decode and parse as one complete event, the tear fell between the closing brace and the newline, and replay already counts that event: add the missing newline and keep it. Otherwise cut the file back to the last newline and keep the cut bytes in a side file for a human to look at. Appending a newline to a real fragment is not enough. It would turn the fragment into a middle line, and the next start would refuse it.
What to take from it
The policy in events is right, and the comment in the code says why in two sentences. What the probe shows is that a tolerance on read needs a matching repair on write, or the tolerated state does not stay the tolerated state. When you build a log like this, test the case after the crash, the first write after the restart, and not only the crash itself.