* fix(sync): freeze the conflict-review producers before the next release
The conflict-review feature (962c5bbeb1) is on master but in no release
tag, so a release cut would take it to the whole stable fleet while it is
still slated for removal. The Task 1 audit found it already ships to two
pre-release cohorts: Snap `edge` is public and built from every master
push (snapd auto-refreshes subscribers), and the Play `internal` track
auto-updates its testers. The data obligation therefore already exists —
the next tag would expand it, not create it.
Journal rows persist the discarded side of a conflict verbatim (entity
titles, arbitrary field values, raw action payloads). They are
device-local, absent from backups/exports, never uploaded, and expire at
14 days / 200 rows, so the recorded decision is: no export owed, and the
store, reader, UI and the SUP_CONFLICT_JOURNAL_CLEARED_BEFORE marker get
deleted together in the rollback.
Freeze both producers at the single production entry point into
autoResolveConflictsLWW:
- disableDisjointMerge: conflicts resolve by whole-entity LWW, which is
the behaviour of every released version to date, so stable gains no
merge behaviour it would later have to be migrated off.
- disableConflictJournal: no new rows are persisted. record() is the
journal's only write and both call sites are now gated, so nothing
produces rows behind the freeze.
Wired at the caller rather than as a global flag so ConflictResolution-
Service keeps the capability for its own tests and the freeze reverts by
deleting two lines. Covered by a spec that fails if either flag is
dropped, since that regression is otherwise silent.
Records the audit in docs/plans/2026-07-16-conflict-review-cohort-audit.md.
* fix(sync): don't offer conflict REVIEW when the journal is empty
The content-conflict banner (#8694) fires off the resolutions, not off the
journal, and its SPAP-15 REVIEW action was unconditional. With the producer
freeze the journal holds no rows, so REVIEW landed the user on the review
page's empty state — a dead end at the exact moment they are worried about a
discarded edit. v18.14.0 ships this banner WITHOUT the action (it has no
navigateToReview at all), so the next tag would have taken a permanently
broken affordance to the whole stable fleet — the opposite of what the freeze
is for.
Gate the action on the journal actually holding unreviewed rows. The count
signal reads fresh: the journal loop awaits record() (which refreshes it)
before the notification step. This also fixes a latent case that predates the
freeze — _journalResolution swallows record() failures by contract, so a
transient IndexedDB error already produced the same dead end. Mirrors
maybeShowSummaryBanner, which already stays silent on an empty journal.
Also cover the freeze's callee-side gates, which had none: the guard spec in
remote-ops-processing.service.spec.ts only asserts the production caller
PASSES the flags. Deleting `if (!options.disableConflictJournal)` in
autoResolveConflictsLWW left all 292 specs green while the fleet silently
resumed persisting the discarded side of every conflict. Both new gates are
falsification-verified: each mutation fails exactly its own spec.
* refactor(sync): simplify the conflict REVIEW action gate
Use a plain ternary to undefined instead of a conditional spread: the banner
component truthy-checks `@if (banner.action)` and exactOptionalPropertyTypes
is off, so `action: undefined` is identical to omitting the key — and reads
as the same thing. Trim the rationale comment to the load-bearing why.
No behaviour change; the gate's spec is unchanged and still passes.
* fix(sync): give the integration spec's journal mock its count signal
The content-conflict banner now reads `conflictJournal.unreviewedCount()` to
decide whether to offer REVIEW, but conflict-resolution-persistence.integration
.spec.ts mocks ConflictJournalService with `['record']` only, so resolution threw
`unreviewedCount is not a function` and took the #8997 project-recovery test down
with it.
Provide a real `signal(0)` via createSpyObj's properties arg rather than a spy:
unreviewedCount is a signal on the service, so this keeps the mock honest to the
type it stands in for.
Caught by CI, not locally: the earlier run covered only the four spec files the
change touched, and this mock lives in a fifth. Full suite now green, 12928 +
12914 across both TZ variants.
* test(sync): drop the redundant journal-on control test
'journals resolutions by default' duplicated coverage that already exists: the
#8956 multi-entity test asserts record() is called twice on the default path, so
it already fails if journaling breaks entirely — which was the only thing my
control guarded against (the freeze test passing vacuously).
Collapse the leftover one-test describe and its two single-use helpers back into
a flat spec.
Full suite green: 12927 + 12913 across both TZ variants.