fix(projects): let a membership's foreign key violation out, so the sweep retries

A project's memberships are foreign keys into both the project and the version
they name, so a chronicle page carrying a membership whose project is in a
*later* page cannot apply it yet. `ChronicleManager.applyPage` handles exactly
this: it catches the violation, counts the payload `failed`, and `sweep` re-runs
every page until failures stop falling.

Phase 3 broke that for projects by catching the violation inside the apply arm.
`applyInnerEvent` then returned normally, `applyPage` counted the payload
applied, the sweep saw nothing left to do, and the membership was dropped --
silently, permanently, on the device a chronicle exists for.

The catch was added so that `FrostSigningManager.applySignedEvent` would not
report a succeeded session as failed. That reasoning was already satisfied:
`applySignedEvent` wraps the call in its own try/catch. And no sibling arm
catches -- a translation chapter naming an absent chapter throws today -- so the
catch made projects the only kind a chronicle could not retry. It is gone.

Pages are 128 events or 64 KiB, and projects sort last in APPLY_ORDER, so a
boundary falling between a project and its memberships is the ordinary case for
any room with a chapter's worth of chunks in it.

`pages arriving out of order still converge` already modelled this -- one page
per payload, delivered backwards, comparing every row count on both devices --
and passed anyway, because `rowCounts` did not look at the project tables. It
does now, which is what makes that test the one that would have caught this.
`ProjectApplyJvmTest` asserts the throw rather than the swallow, and adds the
retry in miniature: refused while the project is missing, accepted once it is
there, same event both times.

1404 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 20:05:08 +02:00
parent aceef313ed
commit 2fb8d6921b
4 changed files with 109 additions and 51 deletions

View File

@@ -14,6 +14,7 @@ it belongs to:
| the `summarize` resolver is Phase 4 | the seam is Phase 4, the resolution is Phase 7c | two of its four call sites are composables and cannot await a database, so the names are pre-resolved in `FrostSigningViewModel` |
| three chronicle tests to add | three to add and four to fix | the existing suite compares a seeded room against `CHRONICLABLE_KINDS` itself, and one test asserts every chroniclable kind can also be *rebuilt* -- which projects deliberately cannot |
| around twenty new strings | eighteen, three of them already there | `projects`, `no_projects_exists_in_this_group` and `create_project` came with the stub |
| the apply arms catch the foreign key violation | they let it out | `ChronicleManager.sweep` retries a page only on a *counted* failure, so catching it loses a membership that crosses a page boundary — and `applySignedEvent`, the reason for catching, already has its own try/catch |
The one thing found while building that the plan did not anticipate: a project
reaches a chronicle only off the `GroupSignedEvent` record, because there is no
@@ -725,11 +726,19 @@ already writes "The group signed …" when a session completes, which is the lin
that covers it. Three new strings for a transcript the signing session is already
narrating would be three places for the same event to be described differently.
Both membership arms must tolerate the version not being here. The FK will refuse
the insert; catch it, log it, and return null rather than letting it escape —
`applySignedEvent` swallows throwables for a reason (the signature is valid
whether or not this device could write the row down) but a refusal that reaches it
is reported as a failed apply on a session that succeeded.
**Both membership arms let the foreign key violation out, and must.** The
temptation is to catch it — `applySignedEvent` swallows throwables so a succeeded
session is not reported as failed — and that is what was built first. It was wrong
twice over. `applySignedEvent` already has its own try/catch, so the reasoning was
satisfied without doing anything; and `ChronicleManager.applyPage` learns a
payload could not be applied **only** by catching the exception, counts it
`failed`, and `sweep` re-runs the pages until failures stop falling. That loop is
the entire mechanism for a membership whose project arrived in a later page.
Swallowing it counts the payload as applied, the sweep terminates, and the row is
lost silently on the one device with nothing to fall back on.
No sibling arm catches either — a translation chapter naming an absent chapter
throws today — so catching made this the only kind a chronicle could not retry.
**Tests** (`jvmTest`): a `ProjectApplyJvmTest` in the shape of
`TranslationChunkApplyJvmTest` — apply a project with three owners and assert