asterism branch specs/duplicate-reconciliation commits 18 unpushed files 119 touched lines +23,400 / -1,600 tests 1168 core, green

Pre-push review: duplicate-reconciliation (M4c)

18 unpushed commits implementing M4c — duplicate Entry/Work/rule sets resolve silently where nothing reader-authored is at stake, divergent sets reach the reader, and backup export projects groups instead of refusing on them. Reviewed across four parallel agents; all findings fixed and re-measured.

At a glance

  • Two performance regressions this milestone introduced were found and fixed before pushing. A background pass that ran on every launch, import and reader action went 0.286–0.298 s → 1.82–2.00 ms; Recent publication went 1.098–1.116 s → 0.677–0.697 s, back inside its pre-M4c band.
  • A live cross-surface bug: Entry detail and Recent disagreed about whether a record was torn, because the normalisation was a defaulted parameter and one call site omitted it. A record could open read-only with a "copies differ" notice while Recent showed it normally.
  • A data-loss path was closed during implementation, not after: phase 2 made delete fan out to every row before the disclosure alert existed. It was caught in review and closed at the API before the UI landed.
  • Two budgets that shipped red in M4b are green again when re-measured here — capture projection (76.3–79.1 ms vs 100 ms) and diagnosis refresh (0.294–0.298 s vs 0.4 s). T-2053's red cells retire.
  • Five review rounds, every one found real defects — including a permanent export dead end that told the reader to wait for a sync that would never come, and a confirm button that silently did nothing.

Verdict

Ready to push

All four review agents' blocking findings were fixed and verified. make test-core (1168), build-ios, test-quick and test-ui all pass with no new compiler warnings, and the performance suite was re-run three times after the fixes.

One budget remains red and is recorded, not tuned: Req 10.1's settling pass measures 7.264–7.365 s against a 2 s budget. It is asserted inside withKnownIssue with a regression ceiling outside it, matching how library-integrity-tolerance and relational-references ship theirs. The honest caveat: batching the per-set commits recovered only ~1.6 s of the original ~9 s, so the remaining cost is unattributed rather than explained.

Review findings

15 raised · 14 fixed · 1 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When the app syncs across devices, it can occasionally end up with two copies of the same thing — the same chapter saved twice, or the same work created on two phones. Before this change the app just showed you both, and refused to make a backup at all if it found any.

Now it sorts them out. If the two copies agree — or only one of them has anything you actually wrote — the app quietly merges them in the background and deletes the spare. If they genuinely disagree, because you wrote a different note on each device, it never guesses: it shows you both and asks which to keep.

Why it matters

Two things you could not do before now work. Backups no longer refuse over duplicates. And saving a chapter that matched a duplicate used to fail with "this chapter was already saved" while saving nothing at all — that dead end is gone.

Key concepts

  • Silent resolution — merging copies when nothing you wrote is at stake.
  • Divergent — copies that genuinely disagree, which only you can resolve.
  • Two-pass deletion — the app never deletes on first sight. It looks, waits, and only deletes if nothing changed in between, so an edit still arriving from another device wins over a delete.

Architecture

Six layers, built in order: GroupOrdering (three deterministic orderings — which row represents a set, which variant leads, which row survives); DuplicateScan (bucketing plus union–find over application UUIDs, classifying each set silent / divergent / deferred); an EntryGroup/WorkGroup fetch seam replacing the old single-row helpers; fan-out writes so every write addresses all rows of a logical record; the reconciler with its settling ledger; and the reader surfaces.

Patterns

Delete the old helper so the compiler enumerates the callers. fetchEntry/fetchWork were removed outright rather than deprecated, which turned "find every caller" into a build error list. This caught 14 production sites — but notably not three that never called them, which shipped a defect for exactly that reason.

Convergence versus collapse. Rows sharing an application UUID are one record materialised twice; they are made identical in place and never split. Rows with distinct UUIDs collapse to a survivor. Conflating the two is the central hazard.

Trade-offs

Deletion commits one transaction per set so a rollback discards one set's work rather than a batch's. That choice is what makes the settling pass slow, and the measurement put the requirement and the design choice in direct tension for the first time.

The ordering is the load-bearing part

No ordering may consult PersistentIdentifier — it is store-local, so two devices would disagree. Everything sorts on synced content plus application UUID. This constraint produced the subtlest defect in the branch: the representative ordering compares version before the active flag, so deduplicating a rule group for export could drop the row carrying the site's only active rule. For title rules that meant a permanent export refusal citing "records still arriving" on a library at its fixed point; for URL rules, a silent demotion — nothing validates isCurrent on the way out. Both existing tests seeded the benign flag direction.

Self-referential write targets

Phase 1 pointed single-row writes at .representative, whose key includes the authored tuple. On an evidence-tied group an edit moved the representative, so successive edits landed on different rows and manufactured the torn state the milestone exists to remove. Fixed with a mutation-free write target, then deleted entirely when fan-out landed.

Deletion safety

Write-before-delete (Req 2.1), a settling fingerprint read before the pass that records it, and commit-time re-verification in a fresh context. The fingerprint deliberately moves the race window rather than closing it: a change confined to derived fields, or to authored content that ends up equal to the outcome, is invisible to it — all outside Req 2.6's protection.

Where the measurements landed

The fixture measures ~48 µs per row of SwiftData materialisation, so the only lever that matters is traversal count. A note save was costing 12–20 whole-table traversals. Gating the full tier and reordering recentPresentation onto value-based scan entry points removed most of it.

Important changes — detailed

GroupOrdering: three orderings with no store-local identity

GroupOrdering.swift

Why it matters. Every deterministic decision in the milestone rests on these. A tie broken by PersistentIdentifier would make two devices disagree about which row survives.

What to look at. GroupOrdering.swift — representative / variant / survivor

Takeaway. When a decision must agree across devices, ban store-local identifiers from the comparator at the type level rather than by convention — and assert the order algebra (strict weak ordering) with a shared harness, since intransitivity is the failure mode you cannot see by inspection.
Rationale. Q36 and the constraint at IdentityResolution.swift:91-96. Identical rows must tie rather than be separated by a local identifier.

The archive dedup must keep a rule group's active/current custody

SiteUnionProjection.swift

Why it matters. Without it, backup export could refuse permanently with a message telling the reader to wait for a sync that will never arrive — or silently export a taught site whose current URL rule had been demoted to history.

What to look at. RuleMembership.reduced — pick the marked row, not the least

Takeaway. A dedup that picks a 'representative' silently inherits whatever that ordering prioritises. If downstream code reads a flag off the survivor, the flag must be part of the selection, not an afterthought.
Rationale. Decision 24. The representative ordering compares version before the active flag, so the marked row losing is ordinary rather than exotic. Both pre-existing tests seeded the benign direction.

Delete fans out to every row — closed at the API before the UI existed

LibraryRepository.swift

Why it matters. Phase 2 made delete address every row of a group while the disclosure alert was still a later task, so one tap destroyed copies the reader had never seen. Strictly worse than the single-row delete it replaced.

What to look at. deleteEntry(disclosedVariants:) — nil means no disclosure was made

Takeaway. When a destructive operation's guard ships in a later phase than the operation itself, close the hole at the API in the same commit. An optional whose nil case means 'refuse' is cheaper than a comment saying the UI will handle it.
Rationale. Req 2.8. The model had been supplying the answer to its own disclosure check — reading the variant set off the record and passing it straight back.

Gate the full reconcile tier (T-2092)

LibraryRepository.swift

Why it matters. Every full-tier pass was walking four tables. A no-op reconcile went from 0.186-0.200 ms to 0.286-0.298 s — ~1450x — on a path that runs at launch, after import, and after every reader action.

What to look at. duplicatePhaseRuns(tier: .full)

Takeaway. A regression ceiling with a comment naming the regression it expects is worth far more than a round number. This one predicted 'a pass that starts faulting the 5,000 Entries it currently never touches' and caught exactly that, on the first run after it landed.
Rationale. Decision 30, superseding 28. The staleness objection holds only for the launch pass; every other caller already refreshes immediately before invoking it.

One normalisation, no defaulted parameter

LibraryRepository+Groups.swift

Why it matters. canonicalWorkIDs defaulted to empty, so omitting it gave a wrong answer instead of a compile error. Entry detail omitted it while Recent passed it — the same record read as torn on one surface and whole on the other.

What to look at. canonicalWorkIDs — defaults removed from all four entry points

Takeaway. A defaulted parameter that changes an answer is a silent-wrong-result generator. If every caller must make a choice, do not give them a default that looks like a decision.
Rationale. Req 3.2 requires the same authored content on every surface. The builder was centralised to prevent drift, but the default kept omission cheap.

recentPresentation stops walking the store three times

LibraryRepository+RecentPresentation.swift

Why it matters. Recent publication had regressed 0.686-0.713 s to 1.098-1.116 s. The scan walked both tables, then the function fetched both tables again.

What to look at. Value-based DuplicateScan entry points

Takeaway. Before optimising, check whether the value-based entry point you need already exists — these were written for the backup projection with a doc comment saying 'must not walk the store a second time', and the newer caller did not use them.
Rationale. Decision 31. The reorder stays inside one observation, so there is no staleness trade-off at all.

Key decisions

"Unresolved Work set" means divergent, not merely un-collapsed.

A silently resolvable Work set already names its survivor by the rule the collapse will use, so an Entry set behind it has a defined assignment target. The earlier reading made sets defer behind blockers that clear in the same pass, costing three passes across two sessions where Q62 forbids latching.

Consequence recorded rather than hidden: it made Req 8.4's third message arm unreachable, which was then verified unreachable by any route and deleted from the requirements.

Rows sharing an application UUID converge; they are never split.

No within-group survivor rule can be made deterministic across devices, so identity groups are made identical in place and deleted only whole, as a set's losing member. Distinct-UUID sets collapse to a survivor.

The normalised work assignment is an equality key and must never be written.

It is used to decide whether two rows agree. Writing it back would silently repoint a reader's manual assignment to a Work they had not chosen — forbidden by Req 2.6. Made structurally unwritable rather than documented.

Rule versions align across owning Site rows, never within one.

Aligning within one Site row violates the validator's Site-unique-version rule and quarantines the hostname, taking teaching off the capture path. The originally recorded rationale for this was factually wrong — it claimed a state validated that in fact does not — and was rewritten during review rather than left standing.

Req 10.1's 2 s budget is breached and recorded, not tuned.

The settling pass measures 7.264–7.365 s. Asserted inside withKnownIssue so the requirement is still asserted and visibly forgiven, with a regression ceiling outside the block so a further slide still fails.

The cost model is admitted to be incomplete. Batching the per-set commits recovered ~1.6 s of ~9 s; the rest is unattributed, and two candidate explanations are recorded explicitly as hypotheses.

The export dedup does not share isConvergedGroup with the validator.

Gating dedup on convergence would emit archives the reference validator rejects for exactly the unsettled groups. The agreement is pinned by a test running validator and export over one library instead.

This reasoning was challenged in review and partly upheld: the rejection is right, but the conclusion that a test substitutes for a shared predicate is not — a predicate constrains the state space, one library constrains one point, and the point chosen was the benign one. The weaker invariant that actually needed stating became Decision 24.

Review findings

SeverityAreaFindingResolution
majorLibraryRepository+Groups.swift — canonicalWorkIDsThe normalisation was a parameter defaulting to empty. Entry detail omitted it while Recent passed it, so a group split across two members of an unresolved Work set opened read-only with a 'copies differ' notice on one surface and rendered normally on the other — the Req 3.2 disagreement the centralised builder was written to prevent.Defaults deleted from all four entry points; ~25 call sites audited. A new normalising helper guards on cheap scalar reads first, so a library where no group disagrees never fetches a Work.
majorLibraryRepository+ReparseCapture.swift — applyCaptureAssignmentThe capture path wrote one Work row where every sibling path fans out, creating rows that disagree on urlIdentity — which is the Work duplicate-set bucket key, so unrelated Work sets would bridge in the union-find. The same defect Q80 fixed for composed teaching, missed here because the path never called the deleted helpers.Fanned out over the group's rows, with a test. Task 8.1's derived-write checklist corrected — it had enumerated the paths and omitted this one.
majorLibraryRepository+Capture.swift — buildCaptureBasisFound by the new fan-out test: the basis listed Work rows, so a split Work group made captureWorkMatch return .ambiguous and a capture against a twice-arrived Work recorded provenance with no Work at all.Fixed; costs ~10 ms of capture projection, recorded. Capture projection remains inside its 100 ms budget.
majorSiteUnionProjection.swift — rule dedupDeduplicating a converged rule group picked the representative, whose ordering compares version before the active flag. If the marked row was not the lowest-versioned it was dropped: title rules then refused export permanently citing 'records still arriving' on a library at its fixed point, and URL rules silently exported a taught site with its current rule demoted to history.The marked row is chosen where the group has one, so active/current custody is invariant under dedup. Reproduced on both arms before fixing.
majorLibraryRepository.swift — full-tier gateduplicatePhaseRuns(tier: .full) was unconditionally true, so every full-tier pass walked four tables. A no-op reconcile regressed ~1450x, on the launch / import / reader-action path.Gated on candidate count and ledger state, with the launch pass left unconditional. Measured 0.286-0.298 s to 1.82-2.00 ms. The withKnownIssue wrapper and its interim floor deleted; the original 10 ms ceiling is a live assertion again.
majorLibraryRepository+Redirect.swift — Work bucket keyThe Work duplicate relation was spelled twice and the spellings disagreed on blank handling, so the redirect resolved a different candidate set than the reconciler used to pick the survivor. A write addressed to a collapsed Work could report recordNotFound for a record sitting in the store.Both call DuplicateScan.workBucketKey. One clause of the finding was judged wrong on inspection and left alone; the test pins the case that actually breaks.
majorAppLibraryModel.swift — Req 1.3 re-armAn aborted deleting commit re-armed a latch that nothing read, so a set that aborted stayed unresolved for the session. Reader-action and import triggers were also silently dropped whenever a pass was in flight — losing the import trigger specifically, which is the one that reliably manufactures same-UUID duplicates.Every pass re-reads the latch at its tail, bounded against livelock; a pending flag queues rather than drops. The latch is handed back rather than consumed-then-discarded.
majorEntryDetailModel / EntryDetailView — disclosure alertTwo defects on the Req 2.8 delete path: the model supplied the answer to its own disclosure check, and SwiftUI clears an alert's isPresented binding before the action runs, so the confirm button silently did nothing. Only the UI journey test reached the second — every unit test called the method directly, where the binding never runs.Disclosure travels as a parameter via presenting:, so the value committed is the value the alert displayed. The API refuses an undisclosed torn delete outright.
majorrecentPresentation / works() — traversal countA single note save cost 12-20 whole-table traversals (~1.5-2.5 s of blocking work at the fixture's measured 48 us/row). recentPresentation walked each table three times; works() snapshotted every Entry then discarded ~95%.recentPresentation reordered onto the value-based scan entry points that already existed; works() filters before mapping. Recent publication 1.098-1.116 s to 0.677-0.697 s.
minorDuplicateReconciler.swift — canonicalWorkIDsA fourth verbatim copy of the builder whose doc comment says it exists so the map cannot drift. It feeds the settling fingerprint and the pre-deletion re-verification, so drift means deleting against a normalisation the scan disagrees with. commitDeletions also defaulted the map to empty, which would silently skip every Entry deletion.Derived once; both the duplicate and the silent-no-op default removed.
minorGroupOrdering / RuleDefinitionComparatorTwo predicates for 'same rule definition' disagreeing on ignored-anchor order, exact-scalar versus canonical Unicode equality, and trims. Two rows with permuted anchors were one rule to the teaching commit and two definitions to isConvergedGroup.isConvergedGroup now asks RuleDefinitionComparator. Sorting anchors in canonicalDefinition was deliberately NOT done — it would tie two permuted rows in the representative ordering, and a tie there is a non-deterministic representative.
minorSyncMonitor.swift — awaitQuiescenceThe documented hard cap checked its deadline only after awaiting a whole debounce cycle, so a debounce task that never completed blocked forever while holding the duplicate pass slot.Enforced at each quiet-period boundary.
minorMaintenanceViewModels.swift — conflict listLibraryDiagnosticsModel took pendingConflicts by value at construction and the view captured the model with State(initialValue:), so a conflict recorded or cleared while the reader was on Check Library never appeared or disappeared.Passed as an escaping accessor; PendingConflict lifted out of AppLibraryModel.
minorimplementation.md / asterism-design.md / OVERVIEW.mdThe measurement retired both of T-2053's red budgets and three documents still carried the old red numbers — including a line added by task 23 in this same push and contradicted two commits later. The implementation notes also stated both baselines remained above their budgets, which was factually wrong for capture projection.All three corrected; the improvement is now reported with its likely cause rather than waved through in one sentence.
minorTest coverage gapsReq 3.2's work-detail dedup for split Entry groups is untested (both candidate tests use genuinely distinct Entries and pass with the dedup removed), and Req 1.5's Work-before-Entry phase ordering is untested, which the code itself notes.Not written. Recorded as known gaps rather than silently skipped.

Per-file diffs

Click to expand.

specs/duplicate-reconciliation/decision_log.md Added +2227 / -0
diff --git a/specs/duplicate-reconciliation/decision_log.md b/specs/duplicate-reconciliation/decision_log.mdindex a62aaf3..5bf163a 100644--- a/specs/duplicate-reconciliation/decision_log.md+++ b/specs/duplicate-reconciliation/decision_log.md@@ -69,6 +69,68 @@ | Q63 | 2026-08-01 | Rule convergence writes the full definition surface (incl. trims) via a new internal `applyDefinition`, rewrites losing-version citations via the `rewriteCitations` machinery, and demotes rather than activates active flags; canonical serialisations are fixed-field-order (patterns) and sorted-keys JSON (URL rules) | Without the version rewrite, citations naming the losing version dangle (Req 6.2); activating all rows would recreate the two-active `.siteTuple` state; `setImmutableDefinition` omits trims, so "converged" groups could still derive different chapter titles | | Q64 | 2026-08-01 | Nil-key guards: empty `conservativeIdentityKey` never joins a key bucket; Works bucket by parsed title only when `lastParsedTitle` is non-blank | Reader-created Works have no parsed title and `createWork` never sets one — without the guard every manual Work on a hostname congeals into one spurious divergent set | | Q65 | 2026-08-01 | Performance assertions follow the house band discipline: three-run bands, median-to-median comparison reported, generous hard ceilings asserted | The repo records 0.74–1.28 s on unchanged code; a tight 1.10× assertion would flake, and `make test-performance-chunks` already models report-don't-assert for exactly this reason |+| Q66 | 2026-08-01 | Req 1.6's "work assignments span a Work set" reads the Entry's **physical** `work` pointer, whatever its provenance — a derived assignment counts as much as a manual one | The deferral is about the survivor's assignment *target* being defined, and a derived assignment needs a target just as much. (Authored-content equality is the opposite case: only a manual assignment is authored, per the Definitions, so the two read different fields on purpose) |+| Q67 | 2026-08-01 | The assignment normalisation is an **equality key**, never a write value: `EntryAuthoredContent` keeps the physical `workAssignment` beside a private normalised key that only `==` and the ordering read | Design review found the normalised value stored as the content's assignment and carried into `DuplicateMember.variants`, which is what outcome content is built from. `canonicalWorkIDs` is populated for every Work set, divergent ones included, where the canonical member is the survivor *rule's* candidate and not the reader's Merge choice — writing it would repoint a manual assignment against Req 2.6. Keeping it private makes that write unreachable rather than merely unintended, and outcome content consequently never writes an assignment at all: the survivor keeps its own, and a Work collapse re-points it under Req 5.2 |+| Q68 | 2026-08-01 | Absence sorts last in the canonical rule encoding, so **the more-specified definition represents**: between two rows of one rule group differing only in whether a field is set, the row that sets it is the convergence target — the row carrying trims, a canonical URL, or the active flag wins | The rule representative doubles as Req 6.1's convergence selector, and Req 6.2 requires a cited rule to replay values the surviving definition actually derives. Converging on the row that drops a trim would make every citation replay a chapter title the group never produced; Decision 7 accepts replay loss for the *losing* definition, not for the more complete one. The same absent-last rule already governs `canonicalURLString` in the Entry tuple, so one statement covers both |+| Q69 | 2026-08-01 | `entriesByID`/`worksByID`, `commitReShareUpdate`'s row resolution, and `composedOutcomeChangesState`'s comparison map all route through the write-target order — one rule decides which row of an identity group is written, everywhere | Phase 1 left these on `RecordResolutionOrder` while the converted sites moved to the group seam, so two winner rules addressed one group and a split group differing in capture evidence could take a curation edit on one row and a bulk teach on another. Q59's "the compiler enumerates the callers" enforcement does not reach them: they never called the deleted helpers. `composedOutcomeChangesState` was a third rule again (a first-one-wins `Dictionary` build), deciding whether the apply path runs at all |+| Q70 | 2026-08-01 | `writeTarget` and the whole write-target ordering are deleted at their named removal point (Decision 9), together with `entriesByID`/`worksByID`, `ResolvedRecords`, and `resolveByID` | Phase 2 leaves no single-row write path: `updateEntry`, `updateWork`, `moveEntry`, `deleteEntry`, `commitReShareUpdate`, `commitReparse`, the import upsert, composed teaching, Articles and the Contracts applier all address every row. An accessor with nothing left to choose is a second concept a reviewer has to learn for no behaviour |+| Q71 | 2026-08-01 | `deleteEntry` carries an `EntryEditBasis` beside its disclosed variants | The design's redirect paragraph gives deletion an arm ("no identity match at all → `deleteEntry` reports success"), which is unreachable without an identity to resolve by; and a delete redirected onto a survivor must clear Q43's match rule for the same reason an edit must, or it removes content the reader never saw |+| Q72 | 2026-08-01 | `VariantID` is a SHA-256 digest over the variant's canonical order components | The disclosure a reader confirms is re-verified at commit against store state (Req 2.9), so the name has to be derived from synced content and identical on both sides — `Hashable` is seeded per process and the raw canonical string would carry the reader's prose into any log line that printed an ID |+| Q73 | 2026-08-01 | A torn group refuses a basis builder through a new `LibraryRepositoryError.unresolvedDuplicate(type:id:)` | The surfaces already distinguish quarantine from corruption from deletion, and each reads as a different thing to the reader; a torn group is none of them — it is workload with a route, and the case is what lets the surface say so |+| Q74 | 2026-08-01 | `moveEntry` and `deleteEntry` stamp `modifiedAt` on **every** row of each Work group they touch | The Work rows are one logical record too. A per-row stamp leaves the group's rows differing in `modifiedAt`, which is the last slot of the representative ordering — so a write that was never about the Work at all would move which row represents it |+| Q75 | 2026-08-01 (reworded 2026-08-02) | Test fixtures that seed Works directly set `lastParsedTitle` and parsed provenance, so a seeded pair reads as the *parsed* Work the fixture means | Fixture hygiene, not a production hazard — the original wording implied one and sent a reviewer hunting for a bug that is not there. `titleProvenance` defaults to `.manual`, so a hand-seeded Work with no `lastParsedTitle` reads as carrying an authored title (Q34); that classification is **correct** for a reader-created Work, since the reader typed that title, and `createWork` produces exactly it (`Models.swift:148`). Non-bare alone is not tornness: tornness needs two rows to **disagree**, and a reader-created Work materialised twice by CloudKit gives two rows with the same `displayTitle` — one variant, `isTorn == false`, converging silently. Created independently on two devices it gets distinct UUIDs and, per Q64, buckets by neither URL identity nor parsed title, so it forms no set at all. The fixtures needed fixing because they seeded rows that *differed*, which no production path produces from `createWork`. Phase 3 is not blocked |+| Q76 | 2026-08-02 | The import upsert's Decision 8 `modifiedAt` guard is judged against the **group's** modification time, outside the row loop | It is a record-level decision, and Req 2.7 says an import applies to every row of a non-torn group or to none. Judged per row, an archive whose timestamp fell between two rows' took on the older row and left the newer one alone — one variant before the import, two after: the app tearing a group it had just presented as one record |+| Q77 | 2026-08-02 | `moveEntry(.existing:)` treats the move as a no-op only when **every** row already points at the destination, is not intentionally unattached, and already carries `.manual` provenance | The old guard compared `Set(rows.compactMap { $0.work?.id })` against `[workID]`, and `compactMap` drops the rows pointing nowhere: a group with one row assigned and one unattached matched, returned `.committed`, and wrote nothing — leaving the twin unattached with no `.manual` provenance, because that block sits after the early return. The `.unattached` arm already had the `allSatisfy` shape |+| Q78 | 2026-08-02 | `WorkEditBasis` carries `titleProvenance`, and its Q43 match includes the manual title | `updateWork` edits `displayTitle`, and a manually set title differing from the last parsed one is reader-authored (Q34), so the title is one of "the basis's edited fields". Left out, a survivor whose manual title differed matched on notes/tags/type alone — commonly all empty on both sides — the redirect fired, and the draft title was written over it on every row: exactly the Req 2.6 silent overwrite Q43 exists to prevent. Deriving the basis's authored title needs the provenance, which the snapshot already carries |+| Q79 | 2026-08-02 | `deleteEntry`'s `disclosedVariants` is optional; `nil` means no disclosure was made, and a torn group refuses it with `.torn` | Req 2.8 requires the reader to be *told* differing copies exist before the group goes. A caller that reads the variants off the record and hands them straight back has disclosed nothing to anybody — it is the model answering its own check. `nil` makes "I have no alert" expressible, so the surface task 18 has not built yet cannot delete unseen content by accident. `EntryDetailModel` passes `nil` until that alert lands |+| Q80 | 2026-08-02 | Composed teaching's `applyAssignment` fans the **Work's own** fields over every row of the destination group; only the `entry.work` pointer names one row | A to-one relationship has no other shape, and every row of the group is the same Work (Req 5.5) — but `urlIdentity` is the Work duplicate-set bucket key (§2.4), so rows holding different values would connect unrelated Works into one set for `DuplicateScan`'s union–find, and `lastParsedTitle` is the bucket key where no URL identity is taught. Rows differing in `modifiedAt` are what Q74 forbids outright |+| Q81 | 2026-08-02 | The lookup-to-commit race guard (Q27) refuses only when the re-derived disposition is `.edit` | The guard fires on any Entry sharing the key, which includes the very duplicate set that sent the sheet to `.new` — so saving a note against an unresolved set returned `.raced` and the extension showed "This chapter was already saved", refusing the reader's note. That is the Req 7.1 dead end this milestone removes, and it was reachable through the shipped share extension. A disposition that re-resolves to `.new` is not a race: it is the state the lookup had already classified, and the new row joins the set at the next pass (Req 7.2) |+| Q82 | 2026-08-02 | Six known asymmetries are recorded at their sites rather than changed in this pass | Each fails safe and none loses content: `EntryAssignmentBasis.matches` compares a physical pointer against a manual-only value (a false refusal, never a write); the redirect scans every set member in survivor order where Q43 says "the survivor" (a wider *acceptance*, with the refusal still naming the member a collapse would keep); `try?` reads a store failure as "not there" (the identity re-fetch that follows fails again, so it ends in a refusal either way); `intentionallyUnattached` is authored with no provenance gate, because the Articles path sets the same flag with `.none` (Decision 10) and a gate would read one row as bare and its twin as authored; `EntrySnapshot.conservativeIdentityKey` defaults to `""` for pre-existing fixtures, which Q64's empty-key guard turns into "gone" rather than a wrong match; and Merge remains reachable-but-refused for split groups, which task 17 owns |+| Q83 | 2026-08-02 | Rule convergence demotes a duplicate active/current flag **within each owning Site row**, never across them | Q63 asks convergence not to manufacture the two-active `.siteTuple` state, and moving the group's flag onto its representative would strand a *second* taught row holding this rule's only active copy — `.taught` with no active title rule is illegal in every mode. Demoting per site honours "never activate" and cannot leave a site untaught. In the ordinary single-Site case the Site phase has already left one active, so it is a no-op |+| Q84 | 2026-08-02 | Outcome content is **copied off the carrier row**, not rebuilt from the `AuthoredContent` tuple | Decision 10's rule applied to the write half. A manual chapter title travels with its provenance and a Work's genre tags with the reader's ordering; rebuilding from the tuple would stamp `.manual` where the store held `.none` and re-sort the tags, which is what two shipped tests caught on the read half |+| Q85 | 2026-08-02 | Writing `intentionallyUnattached` as outcome content also clears the row's `work` pointer | Every write path that sets the flag nils the pointer (`moveEntry(.unattached)`); a row that is intentionally unattached and still points somewhere is a state nothing else produces. Provenance is deliberately left alone — Decision 10's lesson is not to invent one |+| Q86 | 2026-08-02 | The deletion phase runs in **one** fresh `withLockedContext` with a transaction per set — and one table read for the whole batch, not one per set | Q61's property is that the verification reads store state rather than the deriving context's cache, and one fresh context delivers exactly that. Per-set locking would cost 300 lock acquisitions and 300 context constructions inside Req 10.1's 2 s budget for no additional isolation, and a fetch inside the loop would scan the table once per set — 300 scans of 5,000 rows. The *transaction* is per set, which is what makes a rollback discard one set's work; the *read* is not |+| Q87 | 2026-08-02 | Commit-time verification re-derives the fingerprint over the **planned members only**; a brand-new member arriving between derivation and deletion is not detected | Detecting it would mean re-deriving membership, which is a second whole-table walk per pass. It costs nothing: the arrival is never one of the rows being deleted, so no authored content is lost — the survivor rule simply picks the newcomer on the next pass and collapses the current survivor into it. Squarely inside the race Decision 5 accepts |+| Q88 | 2026-08-02 (corrected 2026-08-02) | `DuplicateScan` walks each table twice — scalar columns first, then relationships and authored content for candidate rows only — and `LibraryToleranceScan` carries the arrival gate. **What the first walk costs a duplicate-free library is two scalar column reads and two dictionary inserts per row, and nothing after it**: only a UUID whose bucket holds a second UUID, or that names more than one row, is passed to the union–find | The phase-1 review found `EntryRow.init` faulting `Entry.work` for every Entry in the library, which is the exact cost `LibraryDiagnostics.scan` documents itself refusing to pay, and Q58's claim of "one property fault, not a second walk" unmet. Splitting the walk fixes the fault. The original claim of "two columns per row and nothing else" was still wrong: `candidateComponents` added every UUID in the table to the `UnionFind`, and `components()` sorts its members by `uuidString` — a fresh `String` per comparison — and groups them all *before* the duplicate filter runs, so a duplicate-free 5,000-Entry library paid a full-table O(n log n) sort per full-tier pass. Narrowing the input to candidates is what makes the sentence true |+| Q89 | 2026-08-02 | `DuplicateReconciliationOutcome` distinguishes `wroteNothing` from `isEmpty` | Req 2.4's fixed point is about writes; the launch pass's refresh gate is about anything a screen is built from, which includes reader workload. A library holding an unresolved divergent set reports that set on every pass forever, so one predicate could not serve both — and the property suite looping on the wrong one would never terminate |+| Q90 | 2026-08-02 (corrected 2026-08-02) | The reader-action trigger (Req 1.2) fires from the Entry and Work detail mutation closures, so it over-fires on ordinary curation edits | Those closures are where a reader deletion commits, and they do not report which kind of mutation it was. Missing a deletion leaves the set unresolved until an unrelated trigger. What the over-firing costs is a **full-tier pass**, which is the Site work-list derivation plus `DuplicateScan`'s scalar walk over four tables (Q88) plus the tolerance refresh the closure already runs — cheap on a settled library, but not the "two scalar column walks" this row first claimed, which described one table of the scan and none of the rest. Task 21 measures it (Req 10.2). Task 18's resolution surfaces call `scheduleDuplicateReconcile()` directly and need no widening of this |+| Q91 | 2026-08-02 | A rule identity group whose rows sit on **one** Site row stays `.siteTuple` after convergence, and this milestone does not change that | `V4LibraryValidator` requires a Site's title-pattern membership to hold distinct UUIDs (`:479`), so the state is diagnosed before the pass and after it. Convergence repairs the definitions, not the diagnosis. Req 6.2's "every taught Site SHALL still hold a usable active rule set" is therefore **not** achievable by convergence alone for that shape — a `.siteTuple` quarantines the hostname. The fix is in the validator's membership check, and it now has an owner: **task 20** (`35c1dva`, "Validator relaxation for converged rule groups"), stream 2. It is *not* task 14: that task retires `.duplicateIdentity`, a `LibraryToleranceScan` diagnosis with its own producer and clearing path, and retiring it will not clear a `.siteTuple` produced by `V4LibraryValidator.validate(site:)` — task 14's checklist says nothing about the validator. Task 20 carries the two clauses the relaxation cannot leave ambiguous (which row counts for `activePatternCount == 1`, and how the current-greatest-version clause reads) as decisions of its own |+| Q92 | 2026-08-02 | The Req 1.3 latch is re-read at the **end** of every duplicate pass, and a chain of self-scheduled follow-ups is bounded at three | Req 1.3's "a deleting commit aborted under 2.9 SHALL re-arm the follow-up" had no consumer: the latch was read only after an arrival, after the launch pass, and when scheduling a follow-up. In a session with no further arrival and no relaunch — the ordinary case, since the abort happened *because* something raced — nothing read it again and the set stayed unresolved for the session. The bound is what keeps a set that keeps aborting from rescheduling itself for the life of the session; three is the longest honest run (defer, follow-up, one abort, its re-arm), and past it the sets are left tolerated for the next ordinary trigger, which is the state Q19 already accepts for a short session |+| Q93 | 2026-08-02 | A reader-action or import trigger arriving while a pass is in flight sets a pending flag rather than being dropped, and the pass runs it when it ends | `scheduleDuplicateReconcile()` returned silently when the slot was taken, and the in-flight pass may have derived its scan before the reader's write landed — so Req 1.2's "a pass after a reader action that changes a set commits" was not met. Import shares the entry point and is the trigger that reliably manufactures same-UUID sets (M4b Q18), so it is where the loss mattered most. One flag rather than a queue: two requests want the same thing, which is one more pass over the store as it now stands |+| Q94 | 2026-08-02 | The reader-action and import trigger does **not** await sync quiescence; only the Req 1.3 follow-up does | Q62's 30 s cap is justified for a follow-up, which exists because a set changed between two passes and has to be given a chance to stop changing. A reader's deletion has already committed, and making its pass wait up to half a minute behind an unrelated hydration is not what Req 1.2 asks for. Both paths share one task slot and one runner; the wait is a parameter of the runner |+| Q95 | 2026-08-02 | An aborted deleting commit (Req 2.9) keeps its ledger entry; only a committed one forgets | `commitCollapses` forgot the key before the committed/aborted branch, so an aborted plan became a *first* observation again — the next pass would defer instead of deleting, and a collapse took three passes rather than one, for a set whose only fault was that something raced it. Keeping the entry is safe because the fingerprint on record is the one the pass left: a set that has since changed still fails the compare |+| Q96 | 2026-08-02 | `carrierRow` returning nil while the set *has* a variant skips the set without recording a fingerprint | It was an unguarded silent no-write: the survivor received no content, the fingerprint was recorded anyway, and two passes later the losing rows would be deleted against a survivor that never got the note (Req 2.1). No reachable case was constructed — the variant is derived from these very rows — but the guard is one line and the alternative failure is silent loss. An all-bare set has no variant and never reaches it |+| Q97 | 2026-08-02 | **Open, needs a design answer before code.** Work convergence writes no bucket key: `apply(_ carrier: Work, to:)` never touches `lastParsedTitle` or `urlIdentity`, and writes `displayTitle` only where the title is authored (Q34). A Work identity group whose rows *arrived* holding different bucket keys therefore stays that way, and because both keys anchor the same UUID in the union–find, the group permanently bridges two otherwise unrelated Work sets into one | Recorded rather than fixed. Converging them is not obviously right: `urlIdentity` and `lastParsedTitle` are derived from a rule and a parse, so writing one row's onto another's is a derived-field write (Q44 says those fan out over any split group) — but the value a *converged group* should hold is the question, and the representative's is a guess where the rows genuinely came from two different taught states. The alternative, bucketing a split group by the union of its rows' keys and accepting the bridge, is what happens today by accident. Neither loses authored content, so it fails safe either way |+| Q98 | 2026-08-02 | The arrival tier is only cheap for a library with **no split groups at all**: `duplicatePhaseRuns` opens on `lastDuplicateCandidateCount > 0` or a non-empty ledger, and one converged lone group keeps both non-zero for the session | Recorded, not changed. The gate's purpose is to keep an arrival from charging a whole-library walk where there is nothing to do, and a library holding a split group *does* have something to do — until Decision 4 says it never collapses, at which point the pass is a walk that writes nothing every time. Narrowing it would mean the gate distinguishing "candidates" from "candidates that could still change", which is most of the classification the pass exists to do. Task 22 measures the settled-library pass (Req 10.1's second pass is exactly it) and that number is what says whether this matters |+| Q99 | 2026-08-02 | The resolution contract finds its set by **membership overlap**, not by key equality | Membership *is* the `DuplicateSetKey`, which is right for the settling ledger and wrong here: a bare copy arriving between the sheet opening and the reader confirming mints a new key, so an equality lookup reported "this set no longer exists" and refused the confirmation — which Req 4.6 forbids in as many words. Matching on overlap lets the *variant* compare, the real staleness test, do its job. Nil where the members now span two sets: a component that split is not one decision any more, and re-presenting half of it would guess which half the reader meant |+| Q100 | 2026-08-02 | Task 18's `SettingsBackupModel` torn-groups message arm is **deferred to task 21** | `TornGroupsPayload` and `BackupV4ExportError.tornGroups` are task 21's to define and the only thing that produces one. Adding the type and the message branch here would be a value with no producer, in a file the stream-1 owner is about to touch — and a message arm nothing can reach is the dead requirement Decision 14 just deleted, re-added by hand. Everything else on task 18's checklist landed; the Settings route to Check Library already exists, so Req 8.4's "where to resolve them" has a target |+| Q101 | 2026-08-02 | The delete-disclosure alert passes its variants to the action as a **parameter** (`presenting:`), never by re-reading the model | SwiftUI clears an alert's `isPresented` binding *before* running the button's action, so a `confirmDisclosedDelete()` that re-read `deleteDisclosure` found it already nil and deleted nothing — silently, with the reader having tapped "Delete all copies". Caught by the UI journey test and by nothing else: every unit test called the method directly, where the binding never runs. Taking the value the alert was built from is also the stronger statement of Req 2.8 — what the commit re-verifies is exactly what was rendered |+| Q102 | 2026-08-02 | `recordCounts()` counts logical records, and Sites keep counting rows | The reader is told "your library holds N entries" beside what an archive holds, and the archive holds one record per application UUID (Req 8.2) — a row count would report a number the backup could never match, for a state the app presents as one record everywhere else. Sites are the exception because a hostname's Site rows are the CloudKit Mirroring spec's business (its Decision 4) and are diagnosed rather than presented as one |+| Q103 | 2026-08-02 | `SettingsSyncModel.duplicateIdentityRecordCount` becomes `duplicateReviewCount`, reading the workload | It counted *rows sharing an identifier*, off the retired diagnosis. That number can never reach zero for a converged group — Decision 4 never deletes the second row — so the health line reported a settled library as unsettled forever, which is the same unsatisfiability Q57 found on the Library Check screen. What is left of the question is the honest one: is anything waiting for you |+| Q104 | 2026-08-02 | `works()` reads the whole Entry table rather than a `work == nil` predicate | "Unattached" is a property of the logical record, which is the carrier row's assignment — so a predicate fetch hands back a *fragment* of any split group whose rows disagree, and a group built from a fragment reports the wrong timestamps and the wrong variants. The whole-table read costs the unattached rows' twins on top of what the Work snapshots already fault |+| Q105 | 2026-08-02 | A preserved edit conflict (Req 2.10) gets **no UI journey test**; it is covered by unit tests | The state needs a collapse to land between a screen's load and its save. Every seeded scenario writes its shape before the app opens, and nothing in a UI test can commit a collapse into the store while a detail screen holds a draft — so the journey would have to fake the very race it exists to prove. Task 19's checklist named one; the entry is here so the plan stops claiming coverage that cannot be built. What the unit tests do cover: the draft survives, the conflict reaches `AppLibraryModel`, it counts against the banner, it lists on Check Library with a route, and it clears when a write lands (Decision 18) |+| Q106 | 2026-08-02 | The Definitions' assignment normalisation is derived **once**, in `DuplicateScan.canonicalWorkIDs`, with two feeds: a scan's Work sets, and Work rows already in hand | It was written out three times (the scan, the Recent builder, the resolution commit) and omitted on two read surfaces (`works()`, the Work snapshot's Entries), so a group whose rows point at two Works of one Work set read torn on one screen and whole on another — against Req 3.2's "same authored content everywhere". The rows-in-hand feed exists because `works()` already fetches every Work for its own projection and a second store walk on a read path is not free; a test pins the two feeds to the same answer |+| Q107 | 2026-08-02 | `V4LibraryValidator`'s `currentRules.count <= 1` (`:554`) keeps counting **rows** where Decision 15's sibling clause counts **groups** | Recorded, not changed. It is the stricter reading — two current rows of one converged group fail it where a group count would pass — so it fails safe, and no shipped path produces that shape: `demoteWithinSites` keeps at most one current row per Site row. Left as it is rather than aligned, because changing a clause that is only reachable through a state nothing produces is a change with no test that can distinguish it. The cost is that the file now states its invariant two ways, which the comment beside the line says |+| Q108 | 2026-08-02 | The Works pill routes at `setKey.memberIDs.first`, which is the lowest UUID of the set and not the Work the reader tapped | Recorded, not changed. `memberIDs` is sorted by UUID string (`DuplicateSetKey`), so the Merge sheet opens on a member the reader may not have tapped — harmless for Merge, which is a choice of *both* sides and offers the other member as its destination, and every member of the set carries the same pill. Fixing it means the pill carrying its own record id beside the set key, which is a second identifier on a surface whose whole point is that the set is one decision |+| Q109 | 2026-08-02 | `projectDuplicateResolution` builds a `.work` contract for a divergent Work set with **no** torn member, which Req 5.3 sends to Merge; recorded rather than refused | Only `ContentView.route(toResolve:)` forks on the published route today, by key equality against a workload that may be a moment old, while the repository matches by membership overlap (Q99) — so a stale route can land such a set on the sheet. It fails safe: the sheet applies `WorkVariantUnion` (Q51), which is Merge's own content logic, appends every non-chosen variant's notes unconditionally (Req 5.4) and unions the tags, so nothing the reader wrote is lost either way. What differs is who picks the surviving row — the reader on Merge, Req 3.1's rule on the sheet. Refusing in the repository would also invalidate the shipped Work-path resolution tests, which seed exactly this shape; making them seed a torn member is task 21-adjacent work with no defect behind it |+| Q110 | 2026-08-02 | `commitResolution` validates the whole graph but reads only `diagnoses[hostname]` | Deliberate, and the wider read would be the bug: an unrelated hostname already quarantined before the resolution began would then refuse every resolution in the library, which is the dead end this milestone removes. A set's members share one hostname by construction — Entry sets bucket by (hostname, conservative key) and Work sets by site plus identity — and the only records a resolution touches beyond them are the Entries of a collapsing Work, which move to the survivor of the same set. The validation still *runs* over the whole graph, so a resolution that corrupted the store would throw rather than pass |+| Q111 | 2026-08-02 | `TornGroupsPayload.blockingWorkSet` is named only when **every** torn group waits behind the **same** Work set | promoted to Decision 25 |+| Q112 | 2026-08-02 | The archived Entry takes `workPatternID`/`workPatternVersion` from the **carrier** row and its other six citations from the representative | The pair *is* the work-assignment provenance, decomposed into two wire fields beside the `FieldProvenance` the snapshot carries. Read off the representative while the provenance came from the carrier, a group whose rows disagreed would archive a record naming a pattern its assignment never came from — a shape the store never held. The other six are derived or immutable evidence, which Req 2.7 fans across every row, so representative and carrier agree about them in any settled group |+| Q113 | 2026-08-02 | `requireRepresentableValues` keeps running over **rows**, not projected groups | A stored value the 4/4 format cannot represent is a fact about the row holding it, and a losing row is still in the library after the export — so checking only the projected halves would let a group export while one of its rows held a value a later collapse would carry into the survivor. It is also the cheaper direction: the check is the one that names the record and the value, and naming a row is more use than naming a group |+| Q114 | 2026-08-02 | The refusal's count is over torn **groups** of both types together, not per record type | Req 8.4 asks how many torn groups block the export, and the reader's question is how much is waiting rather than how it splits between Entries and Works. Check Library lists them individually with their routes, so the breakdown exists where it can be acted on |+| Q115 | 2026-08-02 | `SiteUnionProjection`'s `additional` count for `assignVersions` is taken **after** the rule-group dedup | That count is one of the conditions that force a hostname-wide renumbering ("a merge happened"). A nil-site rule row deduped away against a sited row of its own group is not a merge — nothing joined — and renumbering on it would rewrite every citation on the hostname for a rule the archive was already going to hold once |+| Q116 | 2026-08-02 | The export now builds a logical record for **every** Entry and Work rather than only for duplicated ones | Recorded rather than optimised. `BackupGroupProjection` buckets the whole table and builds an `AuthoredVariant` — and therefore a `VariantID` digest (Q72) — per non-bare row where `DuplicateScan` narrows to candidates first (Q88). Export is not on any recorded budget: it already faults `Entry.work` and encodes the whole library to JSON, which dominates a digest per row by orders of magnitude. Narrowing it would mean a candidate pass over both tables to answer a question the projection needs the groups for anyway |+| Q117 | 2026-08-02 | The archived `workID` is the **carrier's**, so a derived assignment only some rows hold can archive as unattached while the Work still lists the Entry. Recorded, not changed | `authoredContent` counts `work?.id` only where the provenance is `.manual` (Decision 10), so rows disagreeing about a *derived* assignment are bare-and-bare rather than torn, and an all-bare group's carrier is its representative. Q112 answered this for `workPatternID` — carrier, because it is the assignment's provenance — and the assignment itself follows the same row for the same reason. The visible cost is a file whose Work lists an Entry that names no Work (`entryIDs` unions every row, Req 5.5): it decodes, since nothing cross-checks the two, and a re-import reads `record.workID` alone, so the derived assignment does not survive. Not repaired here because the alternative — filtering a Work's `entryIDs` by the projected Entry's assignment — couples the two mappers and narrows a shipped Req 5.5 guarantee for a state Req 2.7 settles by fanning derived writes across every row. Pinned by a test so the shape is stated rather than discovered |+| Q118 | 2026-08-02 | The reconciler and the export compute **different** version numberings for one hostname, and that is not a defect | `assignVersions` renumbers 1..n over its input *list*, and the two callers hand it different lists: `.rows` for the reconciler, one row per identity group for the archive. Both are internally consistent — every citation is rewritten through the same map that produced the numbering — the export never writes, and the reconciler's numbering converges after one pass. What it does cost is a sentence: Decision 23's "the archive carries the definition the store is settling towards" is true of definitions and **not** of versions, where the archive can hold a number the store never will. Two related facts, observed while answering Decision 24's reachability question and written down because neither file mentions the other: the union's rewrite map is keyed by rule **id** (`uniquingKeysWith: { _, rhs in rhs }`, and the kept rule is appended last), so a Site-phase pass writes one version to *every* row of a group — it aligns exactly what `DuplicateReconciler.alignVersions` refuses to align within a Site row (Decision 13). Both are right for their own invariant, and the Site phase only visits duplicate, colliding, and imported hostnames, so it is not a general repair |+| Q119 | 2026-08-02 | The validator's converged-group clause is per **Site row** while the export's dedup is per **hostname**. Recorded, not changed | `V4LibraryValidator.validate(site:)` asks whether *this row's* tuple is internally consistent (`=== site` throughout, deliberately), so for a group spanning two Site rows of one hostname its membership clause is vacuous — each row holds the id once. The export dedups over the hostname's combined list and still picks one definition, which is the archive's only option since the file keys rules by UUID. The two answers cannot conflict, because the export is strictly the coarser grouping; what is lost is the losing definition's replay, which Decision 7 accepts. Unrecorded until now, and worth stating because "the validator and the export agree" is otherwise read as agreement about the same partition |+| Q120 | 2026-08-02 | A rule identity group split across two **hostnames** is unreachable, on one named premise: every citer of a rule sits on the rule's own hostname | Decision 23 asserted it; this is the argument. A duplicated *sited* row carries its Site relationship and rule re-parenting is hostname-local (`SiteReconciler` groups by hostname), so two sited rows of one rule share a hostname. A *nil-site* row is placed by `citerHostnames` at its first citer's hostname in id order, which is the sited twin's hostname exactly when no record on another hostname cites the rule — the same premise that file already relies on ("a rule cited from two hostnames, which nothing legitimately produces"). A rule nothing cites at all is refused by name instead. If the premise were ever broken the export would degrade from a named refusal to `encodingFailed`: the payload would hold one rule UUID twice, `BackupV4ReferenceValidator` would throw "duplicate TitlePattern ID", and the verify-decode would wrap it as a codec failure |+| Q121 | 2026-08-02 | `SettingsBackupModel.diagnosticCategory` has no `BackupV4ExportError` arm, so every export refusal logs `unknown:`. Recorded, not changed | Pre-existing — the switch covers `BackupCodecError`, `BackupValidationError` and `LibraryRepositoryError`, and export errors were never among them. It is a log-line category, never shown to the reader, and the reader-facing message is exact (`exportMessage`). Worth an arm the next time the file is opened for a reason of its own |+| Q122 | 2026-08-02 | The singular refusal message says "1 record … cannot hold them all" where `BackupV4ExportError.description` says "both" for the same count. Cosmetic, recorded | Two sentences with one job, written in two places for two audiences: the model's is the reader's, the error's is a log line. Neither is wrong about the count; "them all" over one record is merely inelegant. Changing copy that a UI journey asserts on, in the commit before performance measurement, buys less than it risks |+| Q123 | 2026-08-02 | `SettingsView`'s Check Library button and `SettingsBackupModel.routesToCheckLibrary` cannot disagree in production | The reviewer's shape — the refusal saying "Open Check Library" beside no button — needs `routesToCheckLibrary == true` with `diagnosticsModel == nil`. Both models come from the same `AppLibraryModel` repository: `settingsBackupModel()` returns nil without `backupRepository` and `libraryDiagnosticsModel()` returns nil without `repository`, and the two are set and cleared in the same two statements. So a Settings screen that exists at all has both, and one that has neither shows no Check Library row either. No test is added: the condition is unreachable, and a unit test cannot observe a SwiftUI `if` |+| Q124 | 2026-08-02 | The `.duplicateSets` fixture seeds its 10 rule identity groups **already converged** — one definition per group, no active row among them | An unconverged group is a `.siteTuple` failure until a pass repairs it (task 20, Q91), so seeding one would quarantine the fixture's hostname and change what every other measurement over that fixture measures. Req 10.1 measures the *second* pass, by which time a group seeded either way has converged, so the seeded state costs the measurement nothing |+| Q125 | 2026-08-02 | Req 10.1's samples come from re-seeded **generations** over one store, not from a fresh 5,000-Entry store per sample; each generation is a fresh UUID namespace | A fresh store per sample is ~20 s of seeding for ~1 s of measurement, three runs deep. The generation has to be fresh because `DuplicateSetKey` is the member UUIDs: re-seeding identical rows would hand the next pass a key the ledger had already fingerprinted at that exact shape, so it would delete on *first* observation and the pass after it would be measuring an empty library. Every row a generation ever seeded is deleted before the next one is written, so the library's row count returns to the seeded shape rather than growing per sample |+| Q126 | 2026-08-02 | Both passes are timed and both are asserted against Req 10.1's 2 s; the requirement's number is the second | Req 2.3 forbids a first observation from deleting, but it does not forbid it from *writing*: the survivor's outcome content and every Entry of a losing Work move on pass 1, because Req 2.1 needs them committed before a deletion can exist. Pass 1 is the write-heavy half and pass 2 the deletion-heavy one, so quoting only the second would let a pass meet 2 s by having deferred half its work to the pass before it |+| Q127 | 2026-08-02 | `M4ToleratedScalePerformanceTests.captureRuleApplication` names its three states instead of iterating `allCases` | Req 5.4 is `library-integrity-tolerance`'s, and its "every state from 1.1" means *that* spec's three tolerated states. `.duplicateSets` is this spec's Req 10.1 fixture shape, not a tolerated state; measuring it there would assert one spec's budget over another spec's fixture and add a fourth 5,000-Entry seed to a half-hour target |  ## Decision 1: Bare-side auto-collapse relaxes "identical" for Entries and Works @@ -301,3 +363,2168 @@ Same-UUID rule rows descend from one taught rule materialised twice; holding two - Forensic replay of fields derived by the losing definition shows the winner's output; accepted, recorded here.  ---++## Decision 8: "Unresolved Work set" in Req 1.6 means divergent, not merely un-collapsed++**Date**: 2026-08-01+**Status**: accepted — its flagged Req 8.4 consequence resolved by Decision 14++### Context++Req 1.6 defers an Entry set whose members' work assignments span an *unresolved*+Work set. Two readings of "unresolved" were available and the first+implementation took the wider one: any Work set that had not yet collapsed,+whatever its classification. That defers behind a **silently resolvable** Work+set as readily as behind a divergent one — and a silently resolvable Work set is+one whose survivor is already named, deterministically, by the same rule its+collapse will use.++The cost of the wide reading is not academic. Q62 forbids the follow-up latch+from firing on a Req 1.6 blockage, on the stated ground that such a blockage+waits on the reader and re-arming would loop forever. Under the wide reading+that ground is false for exactly the blockages that could clear on their own,+and the Entry set waits for whatever unrelated pass comes next.++### Decision++An Entry set defers under Req 1.6 only when two or more of the Works its rows+point at belong to one **divergent** Work set — a set with no determined+survivor. A silently resolvable Work set, collapsed or not, blocks nothing: its+survivor is computable now, so the assignment target is defined.++### Rationale++Q26 records the reason the deferral exists: "the collapsed Entry's assignment+value undefined while the Work set had no survivor to point at". A silently+resolvable Work set *has* a survivor to point at — `GroupOrdering.survivor`+names it from synced content, identically on every device, whether or not the+collapse has committed yet. There is nothing for the Entry set to learn by+waiting.++It also makes the rest of the model coherent. Q67 keeps the normalised+assignment out of every write precisely because the canonical member of a+*divergent* Work set is a guess at the reader's Merge choice; behind a silently+resolvable set the canonical member is not a guess, and Req 2.6 explicitly+blesses re-pointing an assignment to the surviving Work of a collapse. The+deferral and the write ban now cover the same case from two sides.++### Alternatives Considered++- **Defer behind any un-collapsed Work set (the wide reading, as first+  implemented)**: Simplest predicate, one condition - Rejected: it defers for a+  target that is already determined, costs three passes across two sessions in+  the common all-bare case, and contradicts Q62's stated reason for never+  re-arming on a Req 1.6 blockage.+- **Never defer; resolve the Entry set and let the Work collapse re-point+  afterwards**: Fewest passes - Rejected: behind a divergent Work set the+  survivor's assignment genuinely has no defined value until the reader merges,+  which is the state Q26 found and the requirement was written for.++### Consequences++**Positive:**+- A deferred Entry set is always waiting on the reader, which is what Q32's+  "route the affordance at the blocking Work set" and Q62's "never latch"+  assume.+- Silently resolvable Work sets and the Entry sets over them settle in the same+  pass sequence instead of one trailing the other by a whole pass.++**Negative:**+- Req 8.4's third message arm — "WHEN the blocking Work set is still settling,+  direct the reader to retry, naming no target" — becomes unreachable through+  the Req 1.6 route, since every blocking Work set now awaits the reader and+  arm 2 names it. The arm is not wrong, it is empty; whether to delete it or to+  re-point it at a different transient (a torn group observed before any pass+  has classified it) is a requirements question for the design owner, flagged+  rather than decided here. **Answered by Decision 14**: the arm is deleted,+  because no settling state can block an export at all — a torn group is+  divergent by definition and so always ends at the reader.++### Impact++`DuplicateScan.blockingWorkSet` and the map feeding it; the Req 1.6 tests in+`DuplicateScanTests`; the Req 8.4 message arms when export lands (task 19).++---++## Decision 9: A separate write-target accessor, as scaffolding with a named removal point++**Date**: 2026-08-01+**Status**: accepted++### Context++Phase 1 replaced `fetchEntry`/`fetchWork` with the group seam and pointed every+converted caller — reads and writes alike — at `EntryGroup.representative`. The+representative ordering runs immutable capture evidence, then the authored+tuple, then the timestamps. For a group whose rows tie on evidence — the+double-import case this milestone exists for — only the authored tuple and the+timestamps are left to decide, and both move the instant a write lands. So an+edit through the representative hands the role to the twin, and the *next* edit+lands on the twin: two edits, two rows, a torn group manufactured by the app.++The deleted helpers did not have this problem. They keyed on `firstCapturedAt`+and ended on `PersistentIdentifier`, so repeated edits hit the same row. The+conversion was described as representative-preserving; behaviourally it was not.++### Decision++Reads keep `representative`. Single-row writes go through a separate+`writeTarget`, ordered on mutation-free capture evidence, then **carries the+group's authored content before bare**, then the content, then the application+UUID. Every single-row write path — `updateEntry`, `updateWork`, `moveEntry`,+`deleteEntry`, `commitReparse`, `commitReShareUpdate`, and the+`entriesByID`/`worksByID` bulk maps (Q69) — resolves through it and nothing+else. It is scaffolding: when fan-out lands (tasks 4–8) every write addresses+every row, the accessor has nothing left to choose, and it is deleted with the+single-row paths that need it.++### Rationale++Evidence alone is not enough. It leaves the rows of a double-import group tied,+so the pick falls to fetch order — and in the mixed bare/authored group that+Decision 1 makes the common shape, a fetch-order pick can put the edit on the+bare row while the twin keeps the old note, producing the second variant the+accessor exists to prevent. Ranking the carrying row first makes an edit+*replace* the variant the reader was shown (Q41) rather than add one, and pins+the target: the first write settles which row carries, and no later write can+move it. Rows still tie while they are all bare, which is exactly when they are+interchangeable (Q36).++`conservativeIdentityKey` is deliberately not in the key. It reads as capture+evidence and is not: re-parse and composed teaching both backfill it, and a key+a write path can change is a target that can move under a write.++### Alternatives Considered++- **Point the writes at `representative` and accept the movement (phase 1 as+  committed)**: No new concept - Rejected: it manufactures torn groups, which is+  the state the milestone exists to remove.+- **Key the write target on capture evidence alone**: Strictly no+  content-derived step, the cleanest statement of "cannot move under a write" -+  Rejected: it leaves evidence-identical rows tied, and the resulting+  fetch-order pick tears a mixed bare/authored group on its first edit. The+  bareness step costs one flag and removes that whole class.+- **Restore the `PersistentIdentifier` tiebreak the old helpers used**: Stable+  within a device, and it worked - Rejected: `IdentityResolution.swift:91-96`+  forbids it as a write basis, and it would be a step backwards from a key that+  is at least derived from synced content.++### Consequences++**Positive:**+- Successive edits through any path land on one row; a non-torn split group+  stays non-torn under ordinary curation.+- One winner rule now decides every write to an identity group (Q69), where+  phase 1 briefly had three.++**Negative:**+- Two accessors on one group type, and a reviewer must know which is which;+  mitigated by the doc comments naming the removal point.+- Not a cross-device rule and cannot be one: rows of an identity group carry no+  synced discriminator (Decision 4), so two devices editing one group can still+  write different rows. Only fan-out closes that, which is what tasks 4–8 do.+- Where two rows carry the *same* authored content, they tie and the first edit+  through a single-row path still re-diverges the group — the state Q17 records+  and fan-out fixes. This accessor bounds the damage, it does not remove it.++### Impact++`GroupOrdering.writeTarget*`, `EntryGroup`/`WorkGroup.writeTarget`, every+single-row write path listed above, `LibraryRepository.entriesByID`/`worksByID`,+and `composedOutcomeChangesState`. All of it is deleted or subsumed by the+fan-out work in tasks 4–8.++---++## Decision 10: A group's authored content is read off the row that carries it++**Date**: 2026-08-01+**Status**: accepted++### Context++Q41 says a split group presents the group's authored variant and never a+possibly-bare representative row's content, and phase 2 had to turn that into+`EntrySnapshot`/`WorkSnapshot` values for the read paths and the sheet bases.+The obvious construction is to take the representative's evidence and overwrite+the authored fields from `AuthoredContent` — the value the variant machinery+already computes.++It does not survive contact with the schema. The authored fields do not travel+alone. A manual chapter title has a `FieldProvenance` beside it; a work+assignment has a provenance *and* a relationship to the row that actually points+at the Work; `intentionallyUnattached` is set by the Articles path with `.none`+provenance and by the reader with `.manual`, and the authored tuple cannot tell+the two apart; `genreTags` is normalised for order-insensitive comparison inside+`WorkAuthoredContent` and is a reader-ordered list on the row. Rebuilding a+snapshot from the tuple therefore has to invent the missing halves, and the first+implementation did: it stamped `.manual` on every unattachment and handed back+tags in sorted order, which two shipped tests caught.++### Decision++`EntryGroup` and `WorkGroup` carry a `carrier` alongside `representative`: the+first row, in representative order, whose authored content equals the content the+group presents. The projected snapshot takes its capture evidence from the+representative and every authored field — with its provenance, its relationship,+and its stored ordering — from the carrier. A group of one, and an all-bare+group, have carrier and representative be the same row.++### Rationale++The carrier is where the content the reader is being shown actually lives, so+reading it off that row is the only construction that cannot disagree with the+store. It also keeps the concepts honest: the representative supplies evidence+because rows tied on evidence supply the same evidence whichever is picked, and+the carrier supplies content because exactly one row holds the presented variant.++### Alternatives Considered++- **Rebuild the snapshot from `AuthoredContent`**: The obvious construction, and+  no new concept - Rejected: the tuple is a comparison key, not a record. It has+  no provenance, no relationship, and a normalised tag order, so the rebuild has+  to fabricate all three — and a fabricated `.manual` provenance is a claim that+  the reader unattached something they did not.+- **Present the representative's content and accept the Q41 loss**: Simplest of+  all - Rejected outright: that is the loss class the milestone exists to close.+- **Keep both and let each caller pick**: Maximum flexibility - Rejected: it is+  the same "which row do I read" question the seam removed, handed back to every+  caller one at a time.++### Consequences++**Positive:**+- A projected snapshot is always a snapshot of *some* row's authored state, with+  its provenance intact; nothing about it is synthesised.+- The two accessors state their jobs in their names, and the doc comments can say+  why there are two without hedging.++**Negative:**+- Two row accessors on one group type again, which Decision 9 listed as a cost of+  `writeTarget`. The difference is that this pair is not scaffolding: both are+  read roles, neither is a write target, and there is no removal point for either.+- The carrier is chosen by content equality, so an all-bare group's carrier is its+  representative by fallback rather than by rule. That is the case where every row+  carries the same nothing, so the choice cannot be observed (Q36).++### Impact++`EntryGroup`/`WorkGroup`, `LibraryRepository.snapshot(_ group:)` for both types,+and everything downstream of them: `entry(id:)`, `work(id:)`,+`entryTeachingDetail`, the composed-teaching basis, `buildMergeWorkBasis`,+`buildWorkURLBasis`, and the re-parse basis.++---++## Decision 11: A torn group refuses composed teaching for its whole hostname++**Date**: 2026-08-02+**Status**: accepted++### Context++`buildComposedTeachingBasis` builds one basis entry per logical record over+every Entry and Work on a hostname, and refuses the whole surface when any of+those groups is torn — `LibraryRepositoryError.unresolvedDuplicate` naming the+least such group (`+ComposedTeaching.swift:441,462`). One chapter that arrived+twice with different notes therefore takes teaching away from every other+chapter on the site.++The reason first given for this was Req 2.8: a torn group has no single value+for the basis to carry. That is true of the *authored* fields, and composed+teaching does not write those — it writes derived ones, which Q44 explicitly+fans out over any split group, torn or not. On that reasoning the refusal would+be wrong, and the obvious repair would be to project the torn group from its+leading variant (Q41's presentation rule) and teach the hostname anyway.++Review found the refusal is right, but for a different reason, and one that+lives in the commit rather than in the basis. `applyEntryIdentityAndChapter`+re-checks provenance **per row** before it touches a chapter title+(`:637`), so a manual chapter on one row of a group survives a projection built+from another row's. `applyAssignment` does not: the assignment's protection is+decided once, at projection time, and the commit writes `entry.work`+unconditionally for every row of the group (`:660-668`). A projection computed+from the leading variant would therefore overwrite a twin's *manual* work+assignment — the reader-authored field the leading variant does not carry, and a+Req 2.6 silent overwrite.++### Decision++`buildComposedTeachingBasis` refuses the hostname when any Entry or Work group+on it is torn, and the surface names the resolution as the route (Q73).++### Rationale++The refusal is what stops a leading-variant projection reaching+`applyAssignment`'s unconditional per-row `entry.work` write. It is+conservative — it costs teaching on a whole hostname for one torn chapter — but+the alternative as the code stands loses a reader's manual assignment, and+losing authored content is the one thing this milestone may not do.++It is also temporary in the way the milestone intends: a torn group is workload+with a route, not damage. The reader resolves it and the hostname is teachable+again, which is precisely the difference between this refusal and the+`duplicateEntryID` throw it replaced — that one had no route at all.++### Alternatives Considered++- **Project the torn group from its leading variant and teach the hostname+  anyway**: Consistent with Q44 (derived writes fan out over torn groups) and+  with Q41 (the leading variant is what a torn group presents) - Rejected on the+  code as it stands, because `applyAssignment` decides protection once at+  projection time and writes per row unconditionally, so the projection would+  overwrite a twin's manual assignment. **Re-openable**: give `applyAssignment`+  the per-row `workAssignmentProvenance == .manual` guard that+  `applyEntryIdentityAndChapter` already has for chapter titles, and this+  alternative becomes the better answer — the refusal would then be protecting+  nothing.+- **Exclude the torn group from the basis and teach the rest of the hostname**:+  Narrowest possible refusal - Rejected: the projection's Work-creation and+  reuse decisions are computed over the hostname's *whole* Entry set, so an+  Entry silently missing from the basis changes which prospective Works are+  created and which existing ones are reused for every other Entry. A quieter+  wrong answer is worse than a loud refusal.++### Consequences++**Positive:**+- No composed-teaching commit can overwrite a manual work assignment on a row+  the projection never saw.+- The refusal carries a route, so it is workload rather than the dead end the+  `duplicateEntryID` throw was.++**Negative:**+- One torn chapter blocks teaching for its entire hostname until the reader+  resolves it. Disproportionate, and recorded as such — the re-openable+  alternative above is how it gets narrowed.+- An asymmetry a reader of the code will notice: `applyComposedOutcome` still+  contains torn-group handling (it iterates `group.rows` whatever the group's+  state) that `buildComposedTeachingBasis` now makes unreachable — except for a+  group that tears *between* the projection and the commit, which is exactly the+  window Q55 says the commit must re-derive for. The dead-looking code is the+  Q55 backstop, not a leftover.++### Impact++`buildComposedTeachingBasis`, `applyAssignment`, and the composed-teaching+refusal tests in `GroupProjectionBasisTests`.++---++## Decision 12: The settling fingerprint records the end of a pass, not its start++**Date**: 2026-08-02+**Status**: accepted++### Context++Req 2.3 lets a deletion happen only for a set unchanged since an earlier pass of+the same session, and the design's fingerprint covers "members, authored+variants, and member timestamps". The obvious implementation records the+fingerprint the derivation produced, then writes, then compares the next pass's+derivation against it.++It does not settle. Req 2.1 makes the pass write the outcome content *before* it+may delete anything, and Req 3.1 raises the survivor's `lastSharedAt` to the+set's latest — which the Entry fingerprint names as its latest timestamp+(`entryFingerprint`, `latest: \.lastSharedAt`). So a first pass that writes+anything at all guarantees the second pass sees a different fingerprint, defers+again, and hands the deletion to a third pass — for the asymmetric bare/authored+set Decision 1 makes the common shape, every single time. The same holds on the+Work side through `modifiedAt`, which *is* the Work fingerprint's latest+timestamp; Q56's `modifiedAt` raise on an Entry survivor moves no fingerprint,+because the Entry fingerprint does not name that field.++### Decision++The ledger records each set's fingerprint as it stands **after** that pass's own+writes, computed from the live rows once the outcome content and the timestamps+have been applied.++### Rationale++Reconciliation's own hand is not a change to the set. What Req 2.3 is protecting+against is an edit or an arrival that the acting device has not seen — and+recording the post-write state still catches every one of those, because any+change to a set's membership, to its authored content, or to the timestamps the+fingerprint names moves it away from what this pass left. Not *anything* touching+the set: a write to a field the fingerprint does not name — an Entry's+`modifiedAt`, a derived chapter title — is invisible to it by construction. That+is deliberate, and it is why the second alternative below is rejected: the+fingerprint has to name the timestamps a re-share moves, and nothing more.++It also keeps Req 1.3's promise honest. A pass defers, the follow-up runs, the+set is unchanged, it collapses: two passes, which is what "an earlier pass of the+same app session" reads as. Three passes for every asymmetric set would have made+Q19's launch-and-quit session cost noticeably worse for no protection.++### Alternatives Considered++- **Record the pre-write fingerprint**: the literal reading, and no extra+  bookkeeping - Rejected: the pass's own writes guarantee a mismatch, so no+  asymmetric set settles in fewer than three passes and a two-pass session never+  collapses one at all.+- **Leave the timestamps out of the fingerprint**: also makes the pass's writes+  invisible - Rejected: a re-share of a doomed row bumps only `lastSharedAt`, and+  that is precisely the arrival the settling rule exists to notice.++### Consequences++**Positive:**+- An asymmetric set collapses on the second pass, like an already-converged one.+- The ledger is written in the same place the deletion plan is, after the+  chunk's save — so a chunk whose save failed leaves neither, and a later pass+  cannot delete against content that never committed.++**Negative:**+- "Unchanged since the earlier pass" now means "unchanged since what the earlier+  pass left", which a reader of Req 2.3 has to be told. Recorded here.++### Impact++`DuplicateSettlingLedger`, `DuplicateReconciler.Chunk`, and the settling tests in+`DuplicateReconcilerTests`.++---++## Decision 13: Version alignment writes only where the owning Site row stays valid++**Date**: 2026-08-02 (context and first alternative corrected 2026-08-02, see below)+**Status**: accepted++### Context++The design has rule convergence align every row of an identity group to the+representative's `version`, and rewrite every citation naming a losing version+(Q63). Q63's stated reason is that citations naming the losing version would+otherwise dangle.++`V4LibraryValidator` requires each Site row's title-pattern and URL-rule versions+to be positive and unique **across all of that Site row's patterns** (`:507-517`) or+URL rules (`:528-538`), and for URL rules additionally that the current one holds the+greatest retained version (`:558-567`). Every new rule version gets a new UUID+(`+ComposedTeaching.swift:120`), so a Site row routinely holds several unrelated+rules at several versions. A write that lands a group's row on a version another+rule on the same Site row already holds turns a validating hostname into a+`.siteTuple` — which `setQuarantine` turns into a quarantine that takes teaching+off the capture path. A pass that "converges" must not do that.++Two rows of *one* rule UUID on one Site row are a different matter, and the+original wording of this section got them wrong. It claimed such a pair+"validate" at versions 3 and 7 and stop validating once pulled together. They do+not validate either way: the membership clause one line earlier (`:479`) requires+a Site row's pattern ids to be distinct and throws first. Q91 records the same+state, and `DuplicateReconcilerTests.convergenceDemotesRatherThanActivates` seeds+exactly that shape and asserts the diagnosis.++The premise for the alignment is also weaker than it looks. Rule groups are never+deleted (Decision 4, Q39), so a citation naming a losing version resolves against+the row that still holds it, and after convergence that row holds the surviving+*definition* anyway. The dangling case is the export projection, where the group+becomes one archive record — and the design already emits `rewrites[ruleUUID] =+representative.version` there, in the exporter.++### Decision++A group's row takes the representative's version only where the write leaves its+owning Site row still validating: the target version is held by no other rule on+that Site row, and — for URL rules — the current rule still holds the greatest+retained version afterwards. Everything else keeps the version it has. An+ownerless row belongs to no Site tuple and always aligns. The citation rewrite is+emitted only when the whole group ends on one version, which is the only state in+which a cited version has nowhere left to resolve.++Two rows of the group on one Site row can therefore never both take the target:+the guard reads live values, so the second sees the first sitting on it. "Never+within one Site row" falls out of the rule rather than being a second rule.++### Rationale++The invariant the write must not break is stated over a Site row's whole rule+membership, so the guard has to be too. Consulting only the group's own rows —+which is what the first implementation did — permits the reachable case that+motivates this decision: hostname H with Site rows S1 and S2, S1 holding rule A+at version 3 plus one row of rule B's group at 5, S2 holding B's other row at 3+and representing it. The group has one row on S1, so a per-group count says "safe+to align", the write lands B's S1 row on version 3, and S1 now holds two patterns+at version 3.++Cross-row rule custody within a hostname stays this spec's Non-Goal and the+shipped Site reconciler's job — `SiteUnionProjection` already assigns one version+per rule UUID when it runs. What convergence adds is the case the Site phase+declines (`distinguishable` is false precisely for rows sharing rule UUIDs), and+it adds it only where doing so is safe.++### Alternatives Considered++- **Align unconditionally, as the design says**: one rule, no per-row+  bookkeeping - Rejected: it manufactures a version collision on a Site row that+  was validating, and a quarantine is a worse state than the un-converged+  versions it replaces.+- **Guard on the group's own rows per Site row** (this decision as first+  implemented): cheaper, one dictionary tally, and it does stop the+  two-rows-of-one-group-on-one-Site-row case - Rejected: it guards the wrong set.+  The validator's uniqueness is over every pattern the Site row holds, and rule+  rows of *other* UUIDs are the common case, so the tally reports "safe" for the+  collision that actually happens.+- **Do not align versions at all, and drop the citation rewrite**: Req 6.2 holds+  either way, since every row now carries the surviving definition - Rejected:+  it leaves the cross-Site case with a group that is converged in definition and+  split in version, which the export projection then has to reconcile without+  the store ever having agreed.++### Consequences++**Positive:**+- Convergence never turns a validating hostname into a quarantined one, for+  either of the two clauses the validator states about versions.+- The citation rewrite fires exactly when a citation would otherwise dangle,+  rather than on every group.++**Negative:**+- A group split within one Site row stays split in version until the Site phase+  touches that hostname. Harmless — every row holds the same definition, so+  replay reproduces the same values whichever version a citation names.+- The guard reads `Site.patternValues` / `Site.urlRuleValues` per row written,+  which faults a relationship the alignment did not otherwise need. Rule groups+  are rare and small, and the alternative is a wrong answer.++### Impact++`DuplicateReconciler.alignVersions`, `patternVersionIsFree`,+`urlRuleVersionIsFree`, `convergePatternGroup`, `convergeURLRuleGroup`, and the+alignment and citation tests in `DuplicateReconcilerTests`.++---++## Decision 14: Req 8.4's third message arm is deleted, not re-pointed++**Date**: 2026-08-02+**Status**: accepted — supersedes Q52++### Context++Req 8.4 had three arms: count-and-route, name the blocking Work set, and — when+that Work set is "still settling" — direct the reader to retry, naming no target.+Decision 8 narrowed Req 1.6's deferral to divergent Work sets only, which made+every blocking Work set one that awaits the reader, so arm 2 always names it and+arm 3 became unreachable through that route. Decision 8 flagged the question and+left it: delete the arm, or re-point it at some other transient.++Task 11's settling ledger was the obvious candidate for that other transient —+the arm literally says "settling". It does not fit. Export refuses for duplicate+identity if and only if the store holds a torn group (Req 8.1), and a torn group+holds two or more authored variants by definition, which makes its set divergent,+which means it always ends at the reader (Req 4.7: divergent sets never resolve+automatically). A torn group can never be the losing member of a silent collapse+either, because a set containing a torn member is divergent and does not collapse+silently. So no settling state can be the thing blocking an export, and there is+no transient for the arm to name.++### Decision++Req 8.4's third arm is deleted. The refusal has two arms: the count and the route,+and the blocking Work set when the torn group's resolution is deferred behind one.+`TornGroupsPayload` carries no `settling` flag.++### Rationale++An arm that cannot be reached is not a safety net, it is a claim a reader of the+requirement will try to satisfy and a test will have to fake. Deleting it says+the true thing: every export refusal for duplicate identity has an actionable+target, because every torn group does.++Q52 reached the weaker version of this conclusion — it rewrote the arm from+"export will succeed shortly" to "retry after reconciliation" on the grounds that+a torn group always ends at the reader. That is the same observation, and it is+enough to remove the arm rather than reword it.++### Alternatives Considered++- **Re-point the arm at a torn group observed before any pass has classified it**+  (Decision 8's suggestion): a genuine transient - Rejected: the group is torn in+  store state, the export predicate is a store-state predicate (Q14), and the+  reader has to resolve it whether or not a pass has run. Arm 1 already names the+  count and the route, which is the actionable thing to say.+- **Keep the arm and never exercise it**: no requirements churn - Rejected: it is+  the dead requirement Decision 8 flagged, and leaving it means task 20 ships a+  payload field and a message branch for a state that cannot occur.++### Consequences++**Positive:**+- Every duplicate-identity export refusal names something the reader can act on.+- Task 20's `TornGroupsPayload` loses a field and a message branch.++**Negative:**+- If a future change makes a torn group silently resolvable — nothing in the+  requirements points that way — the arm would have to come back. Recorded so the+  reasoning is available rather than rediscovered.++### Impact++Req 8.4, `TornGroupsPayload` and `SettingsBackupModel.privacySafeMessage`+(task 20), and Decision 8's flagged consequence, which is now answered.++---++## Decision 15: `activePatternCount` counts rule groups, not rule rows++**Date**: 2026-08-02+**Status**: accepted++### Context++Task 20 relaxes `V4LibraryValidator`'s membership clause so a Site row may hold+several rows of one rule UUID, provided they are a *converged group* — one+definition, one set of trims, at most one active flag. That leaves the taught+tuple's own clause needing an answer it never had to give before:+`activePatternCount == 1` (`:547`) counted `patterns.count(where: \.isActive)`,+over rows, at a point where a Site row could not hold two rows of one rule at+all.++Under the relaxation the two readings coincide on every state that reaches the+clause: the converged-group predicate already caps a group at one active row, so+a group contributes 0 or 1 either way. The decision is therefore about what the+invariant *says*, not about which shapes pass today.++### Decision++`activePatternCount` is the number of **identity groups holding an active row**:+`Set(patterns.filter(\.isActive).map(\.id)).count`.++### Rationale++Decision 5 of `specs/library-integrity-tolerance` states the invariant as "a+taught non-articles Site always holds exactly one active title *rule*". A+converged group is one rule however many rows carry it — that is the whole+premise of the relaxation one clause earlier — so counting rows would restate+the invariant as a claim about storage in the same routine that has just stopped+making one.++It also fails in the safer direction if the converged-group predicate is ever+loosened. Counting rows would silently start admitting a two-active group the+moment the predicate stopped forbidding it; counting groups would not.++### Alternatives Considered++- **Count rows, unchanged**: no edit at all, and it gives the same answer for+  every state the relaxation admits - Rejected: it leaves the taught tuple+  asserting something about rows in a routine whose membership clause is now+  about rules, and it is the reading that breaks first if the converged+  predicate is relaxed.+- **Count active rows but exempt rows whose group already contributed**:+  behaviourally identical to counting groups, spelled as a special case -+  Rejected: it is the same rule with an extra clause to read.++### Consequences++**Positive:**+- The tuple table and the membership clause now talk about the same thing: rules.+- A future loosening of the converged predicate cannot smuggle a two-active+  state past this clause.++**Negative:**+- The line no longer reads as a plain count of the `patterns` array, so a+  reader has to notice the `Set`. The comment beside it says why.++### Impact++`V4LibraryValidator.validate(site:)` and the taught arm of its tuple table.++---++## Decision 16: The current URL rule's version is its group's greatest++**Date**: 2026-08-02+**Status**: accepted++### Context++`V4LibraryValidator` requires a Site row's current URL rule to hold the greatest+retained version (`:558-567`). Rule convergence keeps the current flag on the+first row in representative order within each owning Site row+(`demoteWithinSites`), and that order runs `createdAt`, then `version`+**ascending**, then the flag (`GroupOrdering.swift:651-669`). A converged group+therefore routinely ends with its current row *below* a retained twin of the+same rule — the earlier-created row keeps the flag, and the later-created one+carries the higher version.++Decision 13 makes this reachable rather than hypothetical: version alignment is+refused wherever the write would break a Site row's own uniqueness, so a group+is deliberately left spanning versions. Read over rows, the clause then fails and+quarantines a hostname whose only fault is that one rule arrived twice.++### Decision++The clause compares **rules**, and a rule's version is the greatest its identity+group holds: the current rule satisfies it when no row on the Site carries a+version greater than the current rule's group maximum.++### Rationale++"Greatest retained version" encodes "the current rule is the newest teaching".+Within one group the rows are the same taught rule materialised twice, and the+spread between their versions is a sync artefact plus Decision 13's refusal to+paper over it — it says nothing about which teaching is newer. Comparing group+maxima asks the question the clause was written to ask, and keeps every answer+it was written to give: an *unrelated* rule retained above the current one still+fails, which is the state a reader would recognise as an older rule left current.++### Alternatives Considered++- **Exempt the clause whenever the current rule's group is split**: one line,+  and it clears the same shape - Rejected: it also exempts a genuinely stale+  current rule sitting under an unrelated newer one, for no reason but that some+  group happens to be split. The clause would stop meaning anything on exactly+  the hostnames most likely to be damaged.+- **Make convergence move the current flag to the greatest-versioned row**: the+  store would then satisfy the clause as written - Rejected: it contradicts+  Q63/Q83 outright. Convergence demotes and never activates, precisely so it+  cannot manufacture a state the Site phase has just repaired; promoting a row+  to current is activating one.+- **Align every group onto one version so the question cannot arise**: the+  simplest store - Rejected by Decision 13 already: the write that would do it+  is the one that lands a group's row on a version an unrelated rule holds, which+  turns a validating hostname into a quarantined one.++### Consequences++**Positive:**+- A converged URL-rule group stops quarantining its hostname, which is the whole+  of Req 6.2 for that shape.+- The clause keeps its meaning for the case it was written for.++**Negative:**+- The comparison is no longer a single `max()` over the array, and a reader has+  to know that a rule can span rows to see why. Recorded here and beside the+  code.+- Two rows of one rule at different versions persist indefinitely, so a citation+  naming either resolves. Harmless — every row holds the same definition, so+  replay reproduces the same values (Decision 13's own consequence).++### Impact++`V4LibraryValidator.validate(site:)`'s current-rule clause,+`GroupOrdering.isConvergedGroup`, and the alignment guard `urlRuleVersionIsFree`+which continues to prevent this state being *created* by a write.++---++## Decision 17: A refused confirmation clears the selection++**Date**: 2026-08-02+**Status**: accepted++### Context++Req 4.2 says the resolution sheet preselects the leading variant, and+`DuplicateResolutionModel` applied that to every contract it adopted — the one+the sheet opened with and the `.refreshed` one a stale confirmation returns+(Req 4.6). After a refusal the sheet therefore came back with a variant already+chosen, the refusal notice above it, and the Confirm button enabled.++The reader who taps Confirm twice — because the first tap appeared to do+nothing, which from the outside is exactly what a refusal looks like — commits+the leading variant of a set they have not read. The variants that produced the+refusal are, by definition, not the ones they were shown.++### Decision++`adopt(_:preselecting:)` preselects on the first presentation and **clears** the+selection on a refresh. `canConfirm` is false until the reader picks, and the+refusal notice stands beside an unconfirmable sheet.++### Rationale++The whole of Requirement 4 is that the reader chooses. A preselection is a+convenience on a screen they have just been given; carried through a refusal it+becomes an answer supplied on their behalf to a question that has changed. The+cost is one tap, on the rarest path this feature has.++Req 4.2's "the leading variant is preselected" is about presenting the sheet.+Req 4.6 asks for the current variants to be re-presented and says nothing about+preselection, so the two readings do not conflict — but the deviation from the+literal sentence is why this is a decision rather than a line of code.++### Alternatives Considered++- **Keep the preselection (as shipped)**: matches Req 4.2's sentence literally,+  and one tap fewer - Rejected: it lets a double-tap confirm content the reader+  never saw, which is the loss this milestone exists to prevent, in the one+  surface built to prevent it.+- **Keep the preselection but disable Confirm for a moment**: a timed guard+  against the double-tap specifically - Rejected: clock-driven UI behaviour the+  codebase avoids, and it treats the symptom (fast fingers) rather than the+  claim (this is still your choice).++### Consequences++**Positive:**+- No confirmation can name a variant the reader did not pick on the contract in+  front of them.+- The refusal notice now has a visible consequence, so it reads as a state+  rather than as a message.++**Negative:**+- A reader whose refusal was caused by a *bare* arrival is asked to re-pick for+  a set whose decision has not changed. Rare — Req 4.6 already declines to+  refuse for bare and agreeing arrivals, so a refusal means the variants moved.++### Impact++`DuplicateResolutionModel.adopt`, its `.refreshed` arm, and the refresh tests in+`DuplicateSurfaceTests`.++---++## Decision 18: A preserved conflict is counted and listed, never presented++**Date**: 2026-08-02+**Status**: accepted — supersedes the design's "presents the resolution sheet immediately"++### Context++The design's Req 2.10 paragraph said `AppLibraryModel` "holds pending conflicts+session-scoped (Q47) and presents the resolution sheet immediately". The shipped+code does not: `recordConflict` stores the conflict, the banner counts it, Check+Library lists it, and no surface discharges it. Code and design contradicted each+other, and the reviewer was right to refuse to leave that standing.++Worse than the contradiction: nothing cleared a conflict at all. `pendingConflicts`+emptied only on `close()`. Check Library told the reader to open the record and+save their edit again once the copies were resolved; they did, it committed —+and the banner and the row stayed for the rest of the session. A reader-facing+count that cannot reach zero is exactly the unsatisfiability Q57 spent this phase+removing from `.duplicateIdentity` (Reqs 9.4/9.6), re-introduced on the conflict+half of Req 9.1.++### Decision++The conflict is **not** presented. The refusing screen keeps the draft and shows+the reason; the app model counts it into the banner and lists it on Check Library+with a route. It clears when a write against the record commits — or against the+**survivor** a `.survivorDiverged` redirect named — and a `.torn` or+`.disclosureStale` conflict also clears when no published workload item covers+its record any more.++### Rationale++There is no sheet to present. The resolution sheet resolves a *set*; a preserved+conflict is one refused write, and for `.survivorDiverged` its record no longer+exists. Throwing a modal over a screen the reader is mid-edit on would also take+away the draft they are looking at, which is the one copy of the edit there is+(Q47).++The clearing rule follows the shape of each conflict rather than one rule for+all three. `.torn` and `.disclosureStale` are claims about the record — "this+holds copies that differ" — so they stop being true when the copies do, whoever+resolved them, which is what Req 9.4's "without an app restart" asks for.+`.survivorDiverged` is a claim about an edit the reader still has on screen, and+no set will ever mention its record again, so sweeping it would drop the only+notice of the draft the moment it was recorded. It clears on a write instead —+including a write to the survivor, which is where Check Library now points,+because re-saving the vanished record could only refuse a second time.++### Alternatives Considered++- **Implement the design as written and present the sheet**: no doc change -+  Rejected: there is no contract to present for a conflict, and a modal over a+  live draft risks the draft.+- **Sweep every conflict whose record is in no published set** (the reviewer's+  second suggestion): one rule, no per-kind branching - Rejected: it drops a+  `.survivorDiverged` conflict at the very next refresh, since its record is+  gone by construction. The banner would clear while the reader's edit was still+  unapplied.+- **Clear only on an explicit reader dismissal**: most honest about the draft -+  Rejected as the *only* mechanism: a peer device resolving the copies would+  leave a row saying something untrue until the reader dismissed it. Kept as+  `clearConflict(_:)` for a surface that wants to offer it.++### Consequences++**Positive:**+- The banner count can reach zero by the route Check Library names, which is the+  whole of Req 9.4 for this half of Req 9.1.+- Check Library's resolution sentence is now true for both shapes, because it is+  chosen per shape.++**Negative:**+- A `.survivorDiverged` conflict with a reader who abandons the draft and never+  writes either record persists for the session. Session-scoped by Q47, so a+  relaunch clears it; and a preserved edit outliving its usefulness is the safe+  direction.+- Three clearing paths rather than one, and a reader of `AppLibraryModel` has to+  see why. The comments beside each say so.++### Impact++`AppLibraryModel.clearConflicts(resolvedBy:)`, `dropSettledConflicts`, the+mutation closures of `entryDetailModel`/`workDetailModel`/`moveToModel`,+`WriteConflict.survivorID`, `LibraryDiagnosticsModel`'s conflict row, and the+design's Req 2.10 paragraph.++---++## Decision 19: The duplicate banner's filter names what Recent cannot show++**Date**: 2026-08-02+**Status**: accepted++### Context++Req 9.1 says tapping the banner routes to the affected records. The banner counts+`reviewCount` — Entry sets *and* Work sets — plus preserved conflicts, and its+filter keeps Recent rows carrying a `duplicateRoute`, which is populated only+from `workload.routes(for: .entry)`.++So for the shipped `divergentWorkSet` fixture the reader sees "1 duplicate needs+your decision", taps, and gets a blank list under a banner reading "Showing 1 to+resolve". The same for a conflict on a record in no set. The Works tab carries+its own pill, which is how the state escaped review: the UI journey went straight+to that tab and never came back through Recent.++### Decision++The filtered state renders an **Elsewhere** section beside the rows, one line per+counted item Recent holds no row for: a Work set with its route (Merge, or the+sheet where a member is torn) and one line for the preserved conflicts routing to+Check Library. `RecentDuplicatePlan` derives it, and its `accountedCount` — rows+plus elsewhere lines — is asserted equal to the banner's count.++### Rationale++The alternative fixes were both worse. Counting only what the filter can show+would take Work sets out of Req 9.1's count, and a divergent Work set is exactly+a duplicate awaiting a reader decision. Making Recent list Work rows would put+Works in the Entry list, where nothing else about them lives.++Naming them keeps one banner for one question — "what is waiting for you" — and+gives each item the route it actually has. The plan being a value rather than+view code is what lets the agreement be a test rather than a promise: the+disagreement it exists to prevent is arithmetic, and arithmetic is testable.++### Alternatives Considered++- **Make the count the count of things the filter can show**: smallest change,+  and the banner stops lying immediately - Rejected: Req 9.1 counts every set+  awaiting the reader, and a divergent Work set is one. The reader would be told+  nothing was waiting while Merge waited.+- **Show an empty-state when the filter finds nothing**: covers the blank screen+  - Rejected: it only covers the *all* case. One Entry set plus one Work set+  shows a row and silently drops the other, which is the same defect with a+  smaller footprint.+- **Route the banner at Check Library instead of a filter**: every item is listed+  there already - Rejected: it gives up the inline route Req 9.2 built for the+  common case, and sends a reader with one duplicate Entry to a diagnostics+  screen.++### Consequences++**Positive:**+- Every counted item has somewhere to go from the screen the count is on.+- The agreement is pinned by a unit test over a value, so a new workload route+  cannot quietly become uncountable.++**Negative:**+- A second row idiom in the Recent list, visible only while filtering. Its+  identifier (`duplicate-elsewhere-row`) and header say what it is.+- The conflicts line is one row for N conflicts, so the section's line count is+  not the banner's count. `itemCount` carries the arithmetic instead, which a+  reader of the type has to notice.++### Impact++`RecentDuplicatePlan`, `RecentView`'s filtered branch and its empty-branch+condition, `ContentView`'s `conflictCount` wiring, and the banner/filter tests in+`DuplicateSurfaceTests` and `DuplicateResolutionUITests`.++---++## Decision 20: The duplicate-blocked export refusal gets honest copy now, its payload later++**Date**: 2026-08-02+**Status**: accepted++### Context++Req 8.4 says the export refusal states how many torn groups block it and where to+resolve them. `SettingsBackupModel.privacySafeMessage` has arms for+`BackupCodecError` and `BackupValidationError` and nothing for+`BackupV4ExportError`, so a duplicate-blocked export fell to the default:+"Backup export failed. Please try again." No count, no route, and an instruction+to retry something that cannot succeed until the reader acts.++That predates this milestone. What phase 4 added is a widened window:+`SettingsSyncModel.duplicateReviewCount` stopped counting converged groups+(Q103, correctly — that number could never reach zero), so a library holding a+benign split group now reads *healthy* in Settings, says "Nothing unresolved" in+Check Library, and still refuses backup export with that generic sentence.++Task 21 owns the real fix: `BackupGroupProjection` makes a non-torn group export+as one record, and `TornGroupsPayload` carries the count and the blocking Work+set for the two message arms Decision 14 left. Q100 is right that a message arm+for an error nothing throws is the dead requirement Decision 14 just deleted.++### Decision++The interim is copy, not machinery. `privacySafeMessage` gains a+`BackupV4ExportError` arm whose `duplicateRecordIdentity` case says that a record+exists more than once, that a backup cannot hold both copies, and that Check+Library — reachable from the same Settings screen — is where to look. The health+line is left alone.++### Rationale++`duplicateRecordIdentity` is thrown *today*, by the export path as it stands, so+this arm has a producer and is not the dead-requirement shape Q100 warns about.+It says the true thing available today; the count and the route target arrive+with the payload that can supply them, and task 21's checklist now names+replacing this sentence.++Widening the health line instead was the alternative, and it is the wrong half to+touch: Q103 removed that count precisely because a converged group is benign and+reporting it forever was the unsatisfiable state. Re-adding a "not healthy" for+the same rows would undo a fix this phase made on purpose — and the honest+statement is not "your library is unhealthy" but "this export cannot represent+that record yet", which belongs where the export refuses.++### Alternatives Considered++- **Make the health line report a split group again**: closes the window from the+  other side, one line - Rejected: it re-introduces exactly what Q103 removed,+  and it reports a state the app resolves on its own as something the reader must+  act on (Req 9.5).+- **Ship the `tornGroups` payload now**: the real fix - Rejected: it is task 21's+  in a file stream 1 is about to rewrite, and the projection that makes a+  non-torn group exportable has to land with it or the message would name a+  refusal the reader cannot clear.+- **Leave it until task 21**: no interim churn - Rejected: the generic+  retry-forever sentence is a dead end reachable today, on the safety net, and+  this milestone exists to remove dead ends.++### Consequences++**Positive:**+- A reader whose export refuses is told what is wrong and where to look, today.+- The other `BackupV4ExportError` cases get their own honest sentences at the+  same time, instead of one shared "try again".++**Negative:**+- The sentence names no count, which Req 8.4 asks for. Recorded as interim, with+  task 21 owning the replacement.+- Two places will have said something about duplicates blocking export until+  task 21 lands; the arm is small and its replacement is on that task's list.++### Impact++`SettingsBackupModel.privacySafeMessage`/`exportMessage`, and tasks 18.2 and 21.1+in the task list.++---++## Decision 21: The duplicate-identity export refusal is replaced, not kept beside the torn one++**Date**: 2026-08-02+**Status**: accepted — supersedes Decision 20's interim copy++### Context++`BackupV4Exporter` refused an export for **any** repeated application UUID+(`requireUniqueIdentities`, `BackupV4ExportError.duplicateRecordIdentity`). Req+8.1 makes the refusal exactly a torn group: rows sharing a UUID that agree about+everything the reader wrote are one record, and Req 8.2 has them project to one.++Decision 20 shipped an interim message for that error, because+`TornGroupsPayload` did not exist yet and a message arm for an error nothing+throws is the dead requirement Decision 14 had just deleted (Q100). With+`BackupGroupProjection` landed, `duplicateRecordIdentity` is the case nothing+throws.++### Decision++`duplicateRecordIdentity` is deleted from `BackupV4ExportError` and replaced by+`tornGroups(TornGroupsPayload)`. `SettingsBackupModel`'s interim sentence is+replaced by the two payload-driven arms, and `SettingsView` gains the routing+button beside Retry that the payload gives a target.++### Rationale++An error case with no producer is worse than an unhandled state: it is a+`switch` arm every future reader has to reason about, and a message the app can+never show. Keeping both would also leave two sentences saying something about+duplicates blocking export, which is exactly what Decision 20 recorded as the+cost of shipping the interim — with task 21 owning the removal.++Deleting it is safe in a way it would not be for a decoding path: the case is+produced by the exporter alone and consumed by the Settings message and the+export tests. Nothing persists it, nothing decodes it, and no archive carries it.++### Alternatives Considered++- **Keep `duplicateRecordIdentity` unthrown, as defence**: no test could+  distinguish the two, and the arm reads as a live state - Rejected: it is the+  unreachable-arm shape Decision 14 deleted from the requirement itself, and it+  would leave the model with two competing sentences about the same refusal.+- **Keep it and throw it for repeated rule UUIDs**: rules were the other half of+  `requireUniqueIdentities` - Rejected: rule rows carry nothing reader-authored+  and are never torn (Q39), so a rule group is projectable by construction (Req+  8.2) and refusing over one is the dead end this milestone removes.+- **Add `tornGroups` and leave `requireUniqueIdentities` in front of it**: the+  smallest diff - Rejected: it would refuse before the projection ran, so no+  agreeing group could ever export and Req 8.1's "if and only if" would be false.++### Consequences++**Positive:**+- The refusal is exactly the state a reader has to act on, and it says how many+  and where (Req 8.4).+- One sentence in the app about duplicates blocking a backup, not two.++**Negative:**+- A public enum case is gone; anything outside this repo switching over+  `BackupV4ExportError` exhaustively would stop compiling. There is nothing+  outside this repo.++### Impact++`BackupV4ExportError`, `LibraryRepository.projectV4Payload`,+`SettingsBackupModel.exportMessage`, `SettingsView.backupRow`, and the export+suites in `BackupExportDegradedRefusalTests` and `BackupGroupProjectionTests`.++---++## Decision 22: The archive's projected record is the group, not a second struct++**Date**: 2026-08-02 (amended 2026-08-02)+**Status**: accepted — supersedes the design's `BackupProjectedEntry` /+`BackupProjectedWork`++### Context++The design has export build "explicit intermediate values" —+`BackupProjectedEntry` and `BackupProjectedWork`, each holding every field the+wire mappers read, "built from the representative row with authored content and+member timestamps overridden".++That sentence describes `EntryGroup`/`WorkGroup` and+`LibraryRepository.snapshot(_ group:)` exactly. The group seam already carries+the representative, the carrier, the variants and the member timestamps, and the+snapshot builder already performs the representative-evidence /+carrier-content / member-timestamp composition for every read surface in the+app.++### Decision++`mapV4EntryRecord` and `mapV4WorkRecord` take an `EntryGroup` / `WorkGroup`, and+**both** read their record through `snapshot(_ group:)`. Each keeps only the few+columns the snapshot has no place for, off the representative: the Entry+mapper's citation pairs, the Work mapper's URL-identity triple. No+`BackupProjectedEntry` or `BackupProjectedWork` type is introduced.++**Amendment (2026-08-02).** As first shipped, only the Entry mapper routed+through the snapshot; `mapV4WorkRecord` hand-picked eleven fields+representative-versus-carrier. It agreed with `snapshot(_ group:)` field for+field, which is precisely the "second spelling with no test that could tell them+apart until they disagreed" this decision rejected — the alternative was live in+the code that cited the decision. The Work mapper now routes too.++### Rationale++The projection's whole content is "what one record is when its rows disagree+about which row holds what", and that answer is stated once, in the group seam.+A parallel struct would restate roughly thirty fields and, worse, restate the+composition rule — so a file could archive a record the screens present+differently, which is the class of drift Q106 was written for on the read side.++The single-row path falls out rather than being written: a group of one row has+`representative == carrier` and member timestamps equal to the row's, so the+mappers produce exactly what they produced before.++### Alternatives Considered++- **The design's two structs**: explicit, and independent of the repository's+  seam - Rejected: a second spelling of the composition with no test that could+  tell the two apart until they disagreed, and thirty fields of transcription+  for it.+- **Map from rows and pick fields at the call site**: no new type at all -+  Rejected: it puts the representative/carrier choice in the exporter, where the+  next field added to `Entry` would get it wrong silently.++### Consequences++**Positive:**+- The archive and the screens compose a logical record through one function.+- The mappers shrank rather than grew: the group supplies the timestamps and the+  authored half that the old versions read field by field.++**Negative:**+- `BackupV4Exporter` now depends on the repository's group seam, so the wire+  mappers are no longer a pure function of loose rows. They were never called+  with loose rows outside the exporter.+- The Work mapper's route builds an `EntrySnapshot` per Entry of every Work,+  which the export already builds once per Entry group for the Entry records —+  so export pays for roughly two snapshot constructions per Entry rather than+  one. Export is on no recorded budget (Q116) and encodes the whole library to+  JSON, which dominates it; recorded so the cost is a known one rather than a+  discovered one.++### Impact++`BackupV4Exporter.mapV4EntryRecord` / `mapV4WorkRecord`,+`BackupGroupProjection`, and the design's Export section.++---++## Decision 23: Rule-group dedup is a membership mode; the converged predicate stays the validator's++**Date**: 2026-08-02+**Status**: accepted++### Context++Two things had to land together. Task 21 needs rule identity groups deduped+before the Site union, or a group reaches the archive twice and the reference+validator refuses the file for a duplicate rule ID. Task 20.4 asks for "one+statement of *these rows are a converged group*, shared by the validator and+both projections — two spellings would let the store validate and the export+refuse". The design asks for the dedup to arrive as "a value-based overload+taking pre-deduped rule rows", because `SiteUnionProjection.project` sources+rule rows from the Site relationships internally.++A value-based overload runs into what those relationships are *for*: the union+attributes each rule to a hostname through `pattern.site`, and the exporter+builds its nil-site `additionalPatterns` map from the same relationships. Handing+rows in means re-deriving that attribution in the caller.++### Decision++`SiteUnionProjection` gains a `RuleMembership` mode — `.rows` (the reconciler's+view, unchanged) and `.oneRowPerIdentityGroup` (the archive's). The export passes+the latter, and the dedup keeps `GroupOrdering.representativePattern` /+`representativeURLRule`, which is Req 6.1's convergence selector (Q63), so the+archive carries the definition the store is settling towards.++The projections deliberately do **not** consult+`GroupOrdering.isConvergedGroup`; it stays the validator's predicate alone.++**Amendment (2026-08-02).** As first written this decision closed by claiming+that a test over one library was a *stronger* statement than a shared call —+"a shared call proves the two agree about a predicate, and the test proves they+agree about a library". That is wrong, and it is wrong in the direction that+shipped a defect. A shared predicate constrains the state space; one library+constrains one point, and the point chosen was the benign one — the group whose+active row was also its representative. The direction it did not cover (the+active row above its twin) validated in the store and refused at the export,+which is exactly the sentence this decision exists to prevent (see Decision 24).++The rejections below stand unchanged. What replaces the claim is a weaker,+statable invariant that the archive genuinely needs, recorded as Decision 24:+**whatever row the archive keeps for a group, it keeps the group's+active/current custody.** That is not `isConvergedGroup` and does not need to+be — it constrains the dedup rather than the store, and it is enforced where the+dedup is.++### Rationale++`isConvergedGroup` answers the validator's question — is this *store* legal,+which is what decides a quarantine. The export's question is different and has+only one safe answer. A group archives once whether or not its rows have+converged, because the file keys rules by UUID and a rule row is never torn+(Q39, rules carry nothing reader-authored). Gating the dedup on the predicate+would produce an unreadable archive for exactly the groups that have not settled+yet — the arrival window Req 8.1 exists to keep exportable.++So the hazard task 20.4 names is real and the fix for it is the dedup, not a+shared predicate: before this, a converged group the validator now accepts made+`mapV4SiteRecord` list one UUID twice and the verify-decode refused with a+generic encoding failure.++What keeps the two answers together is **not** the library-level test. That test+is a point sample and it sampled the benign point. It is Decision 24's custody+invariant, stated over every group rather than over one library, and the tests+that pin it now run the *unfavourable* direction of each flag through the+validator and the export together.++### Alternatives Considered++- **The value-based overload the design names**: the caller hands in+  pre-deduped rows - Rejected: the union attributes rules to hostnames through+  the very relationships the overload would bypass, so the caller would+  re-derive the attribution and the two derivations could disagree about where a+  rule lives.+- **Gate the dedup on `isConvergedGroup`, archiving both rows otherwise**:+  literally one predicate shared by validator and projection - Rejected: it+  emits an archive the 4/4 reference validator refuses, for the unsettled+  groups. It satisfies the letter of "one statement" by making the export wrong.+- **Teach `assignVersions` to count identity groups rather than rows, so the+  row-based path stops reading a converged group as a version collision**:+  aligns the union with Decisions 15/16 everywhere - Rejected for now: the+  reconciler's renumbering of such a hostname converges after one pass and+  writes nothing thereafter, so the change would alter behaviour no test can+  currently distinguish. Recorded here rather than done.++### Consequences++**Positive:**+- A hostname whose rule group the store validates exports, decodes, and+  round-trips.+- The reconciler's view of rule membership is untouched: it still writes to rows,+  which is what Req 2.7 asks of it.++**Negative:**+- A rule group split across two *hostnames* would still archive twice. Nothing+  produces one, and the reasoning is now written out with its premise named in+  Q120 rather than asserted here: a duplicated rule row carries its Site+  relationship and re-parenting is hostname-local, and a nil-site row is placed+  at its first citer's hostname, where every citer of a rule sits on the rule's+  own hostname. The dedup runs over the combined per-hostname list, so the+  sited/nil-site pair is covered.+- `SiteUnionProjection` now has two membership modes to read rather than one.+- The claim this decision originally closed on was wrong and shipped a defect+  before it was caught. Left visible above rather than quietly rewritten,+  because the failure mode — "a test over one library is stronger than a shared+  rule" — is the kind of reasoning that will be attempted again.++### Impact++`SiteUnionProjection.RuleMembership` and both `project` overloads,+`LibraryRepository.projectV4Payload`, and the projection tests in+`ConvergedRuleGroupValidationTests` and `BackupGroupProjectionTests`.++---++## Decision 24: The archive dedup keeps the group's active/current custody++**Date**: 2026-08-02+**Status**: accepted — states the invariant Decision 23 left unstated++### Context++`SiteUnionProjection.RuleMembership.oneRowPerIdentityGroup` reduced a rule+identity group to `GroupOrdering.representativePattern` /+`representativeURLRule`, and `keptActive` / `keptCurrent` are computed over the+**reduced** list. So the flag survived the dedup only when the group's marked row+happened to be its representative.++It often is not. The representative ordering for rule rows is+`[hostname, createdAt, version, !isActive, canonicalDefinition]` compared+strictly lexicographically, so version decides wherever a group's rows differ in+it and the flag never gets a vote.++A group spanning versions on **one Site row** is this milestone's own canonical+converged group — it is what `ConvergedRuleGroupValidationTests` seeds to assert+the store accepts it — and it is a **fixed point**, not an arrival window.+`alignVersions` asks per row whether the target version is free on that row's+Site, reading live values, so two rows of one group on one Site row can never+both take it: the target is the representative's version and the representative+is sitting on that very row. That is Decision 13's "never within one Site row",+and `DuplicateReconciler` documents it as derived rather than special-cased.+`demoteWithinSites` demotes and never promotes. So a pass changes nothing, and+the next one changes nothing again.++Which row of such a group carries the flag is therefore not decided by anything+in the pass: the rows arrive carrying it, and the only constraint the+reconciler imposes is "at most one per Site row". Both directions are equally+admissible, and the shipped tests seeded one of them. (The+cross-Site-row case is the benign one and is why the two-Site-row tests never+saw this: there the target version *is* free on the other row, alignment+succeeds, and with the versions equal the flag component finally decides — which+is the Q68 behaviour the ordering's own comment claimed.)++Both consequences were reproduced before this was written, through+`V4LibraryValidator` and `backupV4Snapshot` over one library:++- **Title rules.** The archive holds no active rule for the hostname, so+  `SiteUnionProjection.mode` falls through to the survivor row's `.taught` and+  `requireProjectedTuplesRepresentable` throws `referencesStillArriving` —+  "site … is taught, and the one active title rule that state needs is not in+  the library". The library is at its fixed point, so it never clears: the reader+  is told to wait for a sync that has already finished.+- **URL rules.** Nothing refuses. `requireProjectedTuplesRepresentable`'s+  `.taught` arm checks active title rules only; the archive's own tuple table+  asks a `.taught` Site for nothing about URL rules; and+  `BackupV4ReferenceValidator.validateWork`'s `.rule` arm resolves a Work's+  identity rule by id, version and hostname without reading `isCurrent`. The+  hostname exports with+  its current URL rule quietly demoted to history — teaching lost inside a+  backup, which is the class of thing `requireUniqueIdentities` used to refuse+  over.++In both, the store validates a shape the export refuses or degrades: verbatim the+sentence task 20.4 existed to prevent, which Decision 23 had believed a+one-library test covered.++The question the review asked — *can a group hold two rows at different versions+with exactly one marked, where the marked one is not the lowest-versioned?* — is+therefore answered yes, and the invariant cannot be asserted and relied on. It+has to be produced, which is what this decision does.++### Decision++The archive dedup keeps, per group, the representative **among the group's marked+rows** where it has any, and the representative among all of them otherwise. So+`keptActive` / `keptCurrent` are invariant under the dedup: whatever row the+archive keeps for a group, it keeps that group's active/current custody.++### Rationale++This is the property the archive actually needs, and it is weaker than+`isConvergedGroup` in the direction that matters — it constrains the *dedup*, not+the store, so it costs nothing for a group that has not converged yet, which+Decision 23 rejected the convergence gate to protect.++It is also what Q68 already said the ordering was for ("the row that sets the+field wins — the row carrying trims, a canonical URL, or the active flag"). That+holds within a version and not across one, because version sorts first. Rather+than reorder the components — which would change the convergence selector and+Decisions 13/16's reasoning about `demoteWithinSites` — the dedup asks the+question directly where it needs the answer.++For a converged group the choice changes nothing: its rows hold one definition by+construction, so both candidates archive the same rule. For an unconverged group+it archives the definition the library is *deriving with* today rather than the+least row's, which is at least as defensible.++### Alternatives Considered++- **OR the marked flag onto the representative**: keeps the convergence+  selector's definition and the flag both - Rejected: export must not write, so+  the flag would have to travel beside the row as an override through+  `keptRule`, `assignVersions` and both projected types. More moving parts than+  choosing the row, for a difference only an unconverged group can observe.+- **Reorder `representativeComponents` to put the flag before the version**:+  fixes it once for every caller - Rejected: that tuple is also Req 6.1's+  convergence selector and the order `demoteWithinSites` keeps custody by, so+  the change would move which definition a group converges on and which row+  keeps a site's slot. A read-side defect does not justify moving the write+  side.+- **Gate on `isConvergedGroup` after all**: one predicate everywhere - Rejected+  by Decision 23 and still rejected: it emits an archive the 4/4 validator+  refuses for exactly the unsettled groups.++### Consequences++**Positive:**+- A hostname whose rule group the store validates exports with its teaching+  intact, in both directions of both flags.+- The invariant is statable in one sentence and enforced at one site, so the+  validator and the export can disagree about *convergence* — which is right,+  they are asking different questions — without disagreeing about custody.++**Negative:**+- An unconverged group archives the marked row's definition rather than the+  representative's, so "the archive carries the definition the store is settling+  towards" (Decision 23) is true only once the group has converged. No authored+  content is at stake — rule rows carry none (Q39) — and convergence writes the+  representative's definition to every row, after which the two agree.+- `reduced` now takes a `marked` predicate, so the dedup knows something about+  what it is deduping. It was already type-specific through `representative`.++### Impact++`SiteUnionProjection.RuleMembership.reduced` and both call sites,+`GroupOrdering.representativeComponents(_ pattern:)`'s and+`isConvergedGroup`'s doc comments, `V4LibraryValidator.repeatedIDsAreConverged`'s+doc comment, and the flag-direction tests in `BackupGroupProjectionTests` and+`ConvergedRuleGroupValidationTests`.++Also the design's Export section, for task 23: it says the dedup emits+`rewrites[ruleUUID] = representative.version`, which is now the *kept* row's+version and the representative's only where the group has no marked row.++---++## Decision 25: The blocking Work set is named only when every torn group waits behind the same one++**Date**: 2026-08-02+**Status**: accepted — promoted from Q111++### Context++Req 8.4 says the refusal SHALL point at the blocking Work set when a torn group's+resolution is deferred under Req 1.6 behind a Work set awaiting the reader. Taken+literally over a refusal that counts *every* torn group, that is unsatisfiable+whenever the torn groups do not share one blocker, and the requirement does not+say which one to name.++Three shapes reach it: torn groups deferred behind two different Work sets; a+torn group that is not deferred at all beside one that is; and a torn **Work**+group, which waits behind nothing, because Req 1.6 defers Entry sets and not Work+sets.++### Decision++`TornGroupsPayload.blockingWorkSet` is populated only when every torn group in+the refusal is deferred and every one of them is deferred behind the **same**+Work set. Every other shape falls to arm 1 — the count and Check Library.++### Rationale++The second arm exists so the refusal points at something the reader can act on,+singular. Naming one of two blockers sends the reader to a decision that unblocks+part of the refusal and returns them to the same message, which reads as the app+being wrong about its own state. Arm 1 is not a fallback here: Check Library+lists every torn group individually with its route, so the reader who follows it+sees more than the second arm could have told them, not less.++This narrows Req 8.4's SHALL rather than satisfying it as written, which is why+it is recorded as a decision rather than left as a table row.++### Alternatives Considered++- **Name the least blocking Work set by UUID when there are several**: the+  requirement's letter, and deterministic - Rejected: the message would claim+  the export unblocks when that set resolves, which is false for every other+  torn group, and the reader has no way to see that from the sentence.+- **Add a third message arm for "several Work sets"**: honest and specific -+  Rejected: it is the arm Decision 14 deleted, re-added under a different name,+  and it says the same thing arm 1 says — go to Check Library, which lists them.+- **Count only the torn groups sharing a blocker, so the second arm always+  applies**: a smaller, always-nameable refusal - Rejected: the count is how many+  records block the export (Req 8.4, Q114), and a count that omitted torn groups+  would understate what the reader has to resolve before the next export works.++### Consequences++**Positive:**+- Every sentence the refusal can produce is true of the whole refusal.+- The rule is one predicate over the torn set, not a per-group heuristic.++**Negative:**+- A reader with two blocked sets gets the general message where a partial+  pointer was possible. Check Library covers it, and the pointer would have been+  a half-truth.++### Impact++`BackupGroupProjection.tornGroupsPayload`, `SettingsBackupModel`'s two message+arms, and the two blocking-set tests in `BackupGroupProjectionTests`.++---++## Decision 26: Req 10.2 is two named baselines re-measured where they live, plus three read paths++**Date**: 2026-08-02+**Status**: accepted++### Context++Req 10.2 says the added detection must not regress "the recorded M4-family+baselines for diagnosis refresh and capture projection … re-measured under each+baseline's own fixture preconditions … by more than 10%, compared+median-to-median under the same protocol". Review asked, reasonably, whether+that is one measurement or three, because the milestone did not touch only those+two paths. It put `DuplicateScan` on three more:++- `recentPresentation` runs a full `DuplicateScan.run` on every publication,+  beside the `LibraryToleranceScan` it already ran;+- `works()` reads the whole Entry table rather than a `work == nil` predicate+  (Q104), because "unattached" is a property of the logical record and a+  predicate fetch hands back a *fragment* of a split group;+- `recordCounts()` replaced five SQL `fetchCount`s with four `context.enumerate`+  walks, because a count is now a count of logical records.++The export projection changed too (Q116), and is a fourth path.++### Decision++Req 10.2 is satisfied by **five measurements in two places**. The two baselines+it names — diagnosis refresh and capture projection — stay in+`M4ToleratedScalePerformanceTests`, which already owns them and already+re-measures them under their own fixture preconditions; the duplicate suite adds+none of its own for them. The three read paths it does not name are measured in+`M4DuplicateScalePerformanceTests` over a **duplicate-free** library, which is+the condition the requirement states. The export projection is measured there+too, informationally, because Q116 already recorded that export is on no budget.++Only `recentPresentation` has a recorded M4-family band to be compared 10%+against (0.686–0.713 s host, `library-integrity-tolerance` task 34). `works()`+and `recordCounts()` had no recorded baseline before this milestone; their+numbers are recorded here as new baselines and asserted against a generous+ceiling rather than against a bound they never had.++The debounce path is answered by an assertion rather than a number: over a+duplicate-free library the arrival tier must decline the duplicate phase+entirely (`duplicatePhaseRan == false`). That is the whole reason the added+detection costs an ordinary sync arrival nothing, and it is a stronger statement+than any timing of it.++### Rationale++The requirement's two named baselines are recorded facts about *another spec's*+fixtures, budgets, and assertions. Re-measuring them inside this milestone's+suite would produce a second number for one baseline, on a second fixture built+by a second store helper, and the two would eventually disagree — at which point+nobody could say which one the 10% comparison was against. Measuring where the+baseline already lives keeps one number per baseline.++The three unnamed paths cannot simply be waved through on the grounds that the+requirement did not list them: the requirement's *sentence* is "the added+detection SHALL NOT regress the recorded baselines", and the baselines it names+were chosen before the design put detection on the publication path. Measuring+what the milestone actually changed is what the requirement is for; measuring+only its literal list would satisfy the words while leaving the largest new cost+unmeasured.++### Alternatives Considered++- **One measurement (diagnosis refresh alone)**: the narrowest literal reading -+  Rejected: capture projection is named in the requirement too, and the+  publication path is where `DuplicateScan` actually runs on every observation.+  It would have measured the cheapest of the changed paths and none of the rest.+- **Duplicate both named baselines into the duplicate suite**: everything in one+  file - Rejected: two numbers per baseline, two fixtures, and a 10% comparison+  with no defined left-hand side. It also adds two 5,000-Entry seeds to a+  half-hour target for numbers that already exist.+- **Add budgets for `works()` and `recordCounts()`**: make them requirements -+  Rejected: a budget invented at measurement time is a number chosen to fit the+  number just measured. They get a recorded baseline and a ceiling; a budget is+  the design owner's to set if these paths ever matter interactively.++### Consequences++**Positive:**+- One number per baseline, in the file that recorded it, so the 10% comparison+  has an unambiguous left-hand side.+- The paths the milestone actually changed are measured, including the two the+  requirement's author could not have named.+- The debounce path is settled by an assertion that cannot drift with the+  machine.++**Negative:**+- Answering Req 10.2 needs two suites and therefore two commands' worth of+  output read together. `make test-performance-m4` runs both, and+  `implementation.md` records them in one table.+- `works()` and `recordCounts()` are asserted against a ceiling chosen for being+  generous, which will not catch a 30% regression. That is the honest bound for+  a path with no recorded history; the recorded band is what a later run+  compares against.++### Impact++`M4DuplicateScalePerformanceTests`, the `arguments` list of+`M4ToleratedScalePerformanceTests.captureRuleApplication` (Q127), and the+measurement tables in `specs/duplicate-reconciliation/implementation.md`.++---++## Decision 27: Req 10.1's 2 s budget is breached and accepted as a known issue++**Date**: 2026-08-02 (band and cost model corrected 2026-08-03)+**Status**: accepted — recorded breach, routed to the design owner. **Band+improved by Decision 29 and the breach stands**: 7.264–7.365 s against 2 s,+down from 8.861–9.080 s.++> **Amended after task 24's re-measurement.** Two things below are now known to+> be wrong and are corrected here rather than rewritten away, because the way+> they were wrong is the useful part.+>+> 1. **The cost model was wrong.** This entry attributed the breach to+>    `commitDeletions` running one save per set — "300 collapses are 300 saves …+>    at roughly 30 ms each", which accounted arithmetically for the whole ~9 s.+>    Decision 29 chunked those saves and the pass fell by ~1.6 s, not ~7 s. The+>    transaction count was worth ~18% of the pass. The remaining ~7 s is+>    **unattributed**, and the next attempt should profile rather than reason+>    from the shape of the code, which is what this entry did.+> 2. **The first alternative below — "batch the deletion saves now" — was+>    rejected on a reading of Q86 that is stronger than Q86 needs.** Q86's+>    guarantee is only observable *when a save fails*, so a chunk that replays+>    per set on failure keeps it. Decision 29 takes that route. The rejection+>    was right to refuse an unexamined trade and wrong about what the trade was.+>+> The count in Consequences ("four recorded breaches across three milestones") is+> also wrong in both directions: two of `cloudkit-mirroring`'s now measure green,+> and `relational-references` Req 2.6 was omitted. The current ledger is the+> table at the end of `implementation.md`.++### Context++Req 10.1 asks that a settled pass over the 5,000-Entry fixture seeded with 250+silently resolvable Entry sets, 50 Work sets and 10 rule groups — "measuring the+second pass, the one that performs the resolutions" — complete within 2 s.++Measured (M1 Max, release, `make test-performance-m4`, three runs, ten samples+per run), the settling pass takes **8.861–9.080 s** (medians over three runs; 8.939 s, 9.080 s, 8.861 s) against that 2 s budget,+with a min-to-max spread inside every run of ≤ 1.04x on two of the three runs — run 3 threw a single 15.29 s sample against a 8.86 s median, a 1.75x spread, which is the non-reproducibility CLAUDE.md documents rather than a second population. It is a+measurement, not a scheduling hiccup. The observation pass beside it — the first+pass, which writes the survivors' outcome content and moves every Entry of a+collapsing Work — measures **1.314–1.347 s** and is inside budget.++The cost model is legible and it is not detection. `DuplicateScan`'s two walks+and the reconciler's write phase are the observation pass, and that pass is+inside 2 s. What the settling pass does that the observation pass does not is+`commitDeletions`, which runs **one `saveStrategy.save(context)` per set**. That+is not incidental: a transaction per set is what makes a Req 2.9 rollback+discard one set's work rather than a chunk's, and Q86 chose it deliberately+while rejecting per-set *locking* on the grounds that 300 lock acquisitions+would not fit "inside Req 10.1's 2 s budget". The measurement says the saves do+not fit either: 300 collapses are 300 saves against a context holding ~6,000+rows, at roughly 30 ms each, where the budget allows 6.7 ms per set.++So Req 10.1's number and Q86's per-set transaction are in direct tension, and+this is the first measurement that could say so.++### Decision++Record the breach. The 2 s budget stays exactly as Req 10.1 states it, asserted+inside `withKnownIssue` so the requirement is still asserted and still visibly+forgiven, with a hard regression ceiling of 14 s asserted *outside* the+known-issue block so a change that made the pass materially worse still fails+the run. No budget is edited, no assertion is weakened to produce a green run,+and no optimisation is attempted from inside a measurement task.++`isIntermittent` is deliberately not set. Req 5.5's known issue sits 11% from+its budget, where a quiet run genuinely can dip under; this one is ~4.5× over,+and a run that passed would mean the fixture stopped seeding what it claims to.++### Rationale++This follows the two precedents the repository already set for a measured+breach, and for the same reason: a red number recorded accurately is worth more+than a green number obtained by moving the line. `library-integrity-tolerance`+Decision 11 did it for Req 5.5's 250 ms diagnosis budget, and+`cloudkit-mirroring` Q55 did it for the two budgets that milestone moved,+routing both to T-2053 rather than to a silently raised number.++It is also the honest scope boundary. Reconciling the tension needs a decision+this task cannot take on the design owner's behalf, because every option trades+away something a requirement asked for:++- **Batch the saves** — one transaction per chunk instead of per set. Fast, and+  it weakens Req 2.9: a rollback would discard the chunk's work, not one set's.+  Whether that matters depends on how likely a mid-pass arrival actually is,+  which the two-device runbook is better placed to answer than a benchmark.+- **Raise the budget** — say what 300 simultaneous collapses may cost. Defensible+  on the grounds that no interactive path waits on this pass (it runs after the+  first Recent publication and on the arrival debounce, never in the open path+  or the capture path), which is the same argument `cloudkit-mirroring` recorded+  for its 40 s worst-case consolidation. But a budget rewritten to fit a+  measurement is not a budget.+- **Bound the collapses per pass** — delete N sets and let the follow-up latch+  take the rest. Keeps both the budget and Req 2.9, at the cost of more passes+  and a longer window in which a library holds duplicates it has already decided+  to remove.++There is also a real question about whether 300 sets is the shape to budget for+at all. It is a *hydration* shape — a library that has just synced two devices'+worth of overlapping captures for the first time — not a steady-state one. In+steady state the pass collapses nothing, and that pass is the arrival-gate+measurement recorded beside this one.++### Alternatives Considered++- **Raise the budget to fit the measurement**: one line, green run - Rejected:+  it erases the only signal that says the deletion phase is the expensive half,+  and it is precisely the move Q55 refused. The budget is the design owner's to+  change, in the requirements document, with a reason.+- **Batch the deletion saves now**: probably fixes it, and the change is small -+  Rejected: it trades away Req 2.9's per-set rollback, which Q86 chose on+  purpose. Optimising blind from inside a measurement task is how a safety+  property gets spent for a benchmark.+- **Delete the assertion and record the number only**: no red run - Rejected for+  the reason Decision 11 gives: a suite that asserts nothing cannot tell 9 s+  from 90 s, and the requirement stops being tested at all.+- **Shrink the fixture until it passes**: 30 sets instead of 300 - Rejected: the+  fixture shape is Req 10.1's own, stated in the requirement. Changing it to+  make the number pass is tuning the test.++### Consequences++**Positive:**+- The requirement stays as written and stays asserted; the breach is visible in+  every run and in `implementation.md`.+- The cost model is named, so whoever takes the decision starts from "300 saves+  at ~30 ms" rather than from a profile.+- The 14 s ceiling still catches a regression, which a bare `withKnownIssue`+  would not.++**Negative:**+- `make test-performance-m4` reports a known issue for this milestone as well as+  for others. **The count here was wrong in both directions** (task 24):+  `cloudkit-mirroring`'s two now measure green and are retired, and+  `relational-references` Req 2.6 was omitted though it is in the same target and+  also wrapped in `withKnownIssue`. Three milestones carry a breach — Req 5.5,+  Req 2.6 and this one — and the current ledger is the table at the end of+  `implementation.md`. That it is a pattern worth its own look still stands.+- Until the decision is taken, the milestone ships with a stated performance+  requirement unmet on the one shape it names.++### Impact++`M4DuplicateScalePerformanceTests.settlingPassOverSeededDuplicates`,+`DuplicateReconciler.commitDeletions` and `LibraryRepository.commitCollapses` if+the decision is to batch, Req 10.1 if the decision is to re-budget, and the+measurement tables in `specs/duplicate-reconciliation/implementation.md`.++---++## Decision 28: The full-tier no-op reconcile pass regressed ~1,450× and is recorded, not tuned++> **Superseded by Decision 30 (2026-08-03).** The gate landed, and the pass+> measures **1.82–2.00 ms** — inside the 10 ms ceiling, a factor of ~150 back.+> The `withKnownIssue` wrapper and the 500 ms regression floor under it are+> removed from `M4ScalePerformanceTests`, and T-2092 is answered. This entry+> stands as the record of the regression and of why a measurement task declined+> to fix it: the judgement that gating the full tier was the design owner's call+> was correct, and Decision 30 is that call being taken rather than that+> boundary being crossed.+>+> One number in it needs qualifying: the restored value is ~10× the pre-M4c+> 0.186–0.200 ms, not equal to it. What remains is what a *declined* duplicate+> phase costs a pass that carries the duplicate plumbing either way.++**Date**: 2026-08-03+**Status**: accepted — recorded regression, tracked as **T-2092** (medium)++### Context++`M4ScalePerformanceTests.reconcileNoOpOverCoherentFixture` measures+`reconcileAfterSync()` over the coherent 5,000-Entry fixture — a pass with+nothing to do. `cloudkit-mirroring` recorded it at **0.186–0.200 ms** and gave it+a 10 ms regression ceiling, with a comment naming exactly the regression the+ceiling exists to catch: *"a pass that starts faulting the 5,000 Entries it+currently never touches (tens of milliseconds at least)"*.++This milestone did that. `duplicatePhaseRuns(tier: .full)` is unconditionally+true, so every full-tier pass runs `DuplicateScan.run`, which enumerates the+Entry table, the Work table and both rule tables. Measured over three host+release runs, the no-op pass is now **0.286–0.298 s** (0.286 s, 0.298 s, 0.296 s) — roughly 1,450× the recorded+median and ~29× the ceiling. The ceiling did its job: it caught the change it was+written for, on the first run after it landed.++The same walk is the most likely explanation for the other unnamed baseline that+moved: `recentPresentation` over the same fixture measures **1.098–1.116 s**+against a recorded 0.686–0.713 s, and `recentPresentation` gained a full+`DuplicateScan.run` per publication. 0.71 s + ~0.29 s lands within noise of the+measured number, and the machine control (`extension-open-and-validate` at+0.755–0.790 s against a recorded 0.745–0.766 s) says this is code rather than a+faster or slower machine.++Two things the measurement also says, and both matter:++- **The arrival tier is unaffected in kind.** A debounce pass over a+  duplicate-free library declines the phase entirely (Q53/Q58) and measures+  **1.74–1.91 ms**. The path that runs on every remote change is not the one that+  regressed.+- **Req 10.2's two *named* baselines did not regress at all.** Diagnosis refresh+  and capture projection both came in *better* than their last recorded values.+  The regression is on baselines Req 10.2 does not name.++### Decision++Record it. The 10 ms ceiling is not raised: its assertion is wrapped in+`withKnownIssue` naming the cause, and a second, much higher ceiling is asserted+outside the known-issue block so a further regression still fails a run. The+recorded 0.186–0.200 ms band stays in `cloudkit-mirroring`'s+`implementation.md` as what a *gated* pass costs, which is the number to compare+against if the full tier is ever gated.++No gating change is made here. Whether the full tier should consult the same+candidate count the arrival tier does is a design decision with a correctness+side — Req 1.2 requires the launch, import-completion and reader-action passes+to detect, and the gate reads the *previous* refresh's scan, which is precisely+the staleness Q58 designed the full tier to avoid.++### Rationale++The house rule the repository has followed twice already: a red number recorded+accurately beats a green number obtained by moving the line+(`library-integrity-tolerance` Decision 11, `cloudkit-mirroring` Q55). Raising+the 10 ms constant would erase the only artefact that says the pass changed+character, and the comment above it says so in as many words.++What makes this worth a decision rather than a table row is the second-order+cost, which Q90 predicted and this measurement prices. Q90 accepted that the+reader-action trigger over-fires on ordinary curation edits, on the grounds that+what over-firing costs is "a full-tier pass … cheap on a settled library", and+explicitly deferred the number to this task. The number is ~0.29 s of+locked-context work after **every note or rating edit**, on top of the ~0.30 s+`refreshDiagnostics` the same closure already runs — so an edit now schedules+roughly 0.6 s of background work over a 5,000-Entry library. Neither is on the+interactive write path, but "cheap" is no longer the right word, and the+sentence in Q90 should not be left standing unqualified.++### Alternatives Considered++- **Raise the 10 ms ceiling to fit**: one constant, green run - Rejected: the+  ceiling's own comment forbids it, and the old band is still the right+  comparison for a gated pass.+- **Gate the full tier on the candidate count too**: would restore the old+  number - Rejected here, not on the merits but on the authority: it trades+  Req 1.2's detection guarantee against a stale scan, which is the trade Q58+  already thought about and decided the other way. A measurement task must not+  re-take it.+- **Narrow the reader-action trigger (Q90) to actual deletions and+  resolutions**: removes the per-edit cost without touching detection - The most+  promising option, and still the design owner's: Q90 chose the wide trigger+  because the mutation closures do not report which kind of mutation they+  carried, and missing a deletion leaves a set unresolved until an unrelated+  trigger.+- **Delete the no-op test**: it no longer measures a no-op - Rejected: it+  measures the floor of a pass that runs on launch and after every reader action,+  which is more worth watching now than it was before.++### Consequences++**Positive:**+- The regression is recorded with its cause, its band, and the paths that pay+  it, rather than being discovered later as a mystery.+- The arrival-gate assertion beside it states, and proves, that the sync path did+  not regress — which is the property the tiering was designed for.+- A second ceiling keeps the measurement load-bearing instead of merely+  reported.++**Negative:**+- `make test-performance-m4` carries another known issue, in a suite that+  belongs to a different spec.+- Until the trigger question is settled, every curation edit over a large+  library schedules a whole-library walk it has no reason to need.++### Impact++`M4ScalePerformanceTests.reconcileNoOpOverCoherentFixture` and its ceiling+constants, Q90's cost sentence, `LibraryRepository.duplicatePhaseRuns` if the+gating decision changes, and the measurement tables in+`specs/duplicate-reconciliation/implementation.md`.++---++## Decision 29: Deletions commit in chunks, with a per-set replay when a chunk fails++**Date**: 2026-08-03+**Status**: accepted++### Context++Q86 gave `commitDeletions` one transaction per set, so that a Req 2.9 rollback+discards one set's work rather than a chunk's. Task 22 measured the settling pass+over 300 collapsible sets at **8.861–9.080 s** against Req 10.1's 2 s, and+attributed the whole of it to those 300 saves at "roughly 30 ms each".+Decision 27 recorded the breach and named "batch the saves" as the option it+could not take, on the grounds that it "weakens Req 2.9".++Re-reading Q86's guarantee narrows the tension considerably. The property it+buys — one set's work discarded rather than a chunk's — is only observable+**when a save fails**. On the path that always runs, a chunk and a set are+indistinguishable: every set in the chunk was verified, every deletion+committed, and no rollback happened.++### Decision++Stage a chunk's deletions (cut on rows deleted, at+`LibraryRepository.bulkOperationBatchSize`), verifying each set's fingerprint+individually as before, and save once. On success every set in the chunk is+committed. On failure, roll back, re-fault, and replay that chunk's sets one at+a time — exactly the old behaviour, verification included.++### Rationale++The per-set fingerprint re-verification is what Req 2.9 actually asks for, and+it is untouched: it is still per set, still against store state read in a fresh+context, and still decides individually whether a set is staged at all. What+changed is only how many verified sets share a `save()`.++The failure path restores Q86's property where it is observable. A chunk that+fails rolls back and each of its sets is then verified and committed alone, so a+set whose fingerprint still matches is not punished for sharing a transaction+with one whose did not.++### Alternatives Considered++- **Keep one transaction per set**: Q86 as written, Req 2.9 at its strongest -+  Rejected: it is the whole of Req 10.1's breach, and it buys a property that+  the replay reproduces at the only moment it can be observed.+- **Chunk with no replay** — a failing chunk abandons all its sets to the+  follow-up: simplest - Rejected: this is the reading Decision 27 called+  "weakens Req 2.9", and it is avoidable for a dozen lines.+- **Bound the collapses per pass** (Decision 27's third option) - Rejected: it+  keeps the per-set saves and buys the budget by doing less work per pass, so a+  library with 300 sets takes many more passes to settle.+- **Per-set locking**: rejected by Q86 already, on cost - Unchanged.++### Consequences++**Positive:**+- A real cost is removed without editing a budget or weakening the check Req 2.9+  names. **Measured (task 24): 8.861–9.080 s → 7.264–7.365 s**, about 1.6 s.+- The chunk boundary is the same one every other bulk path uses, so an+  interrupted pass stops in the same class of state as an interrupted import.+- It falsified task 22's cost model, which is worth as much as the seconds: the+  transaction count was ~18% of the pass, not ~80%, so the remaining ~7 s is+  something nobody has looked at yet.++**Negative:**+- **A chunk failure now retries N sets instead of failing 1.** The replay costs+  up to one extra save per set in the failing chunk — a slow path that only runs+  when a save has already failed.+- One more code path (`stage` / replay) where there was one straight loop.+- **It does not fix Req 10.1.** The budget is still breached at ~3.7×, so+  Decision 27 stands and the known issue with it. This bought 18%, and the+  entry that said it would buy the breach was wrong.++### Impact++`DuplicateReconciler.commitDeletions`, its `DeletionRows` reads, and Req 10.1's+recorded band in `specs/duplicate-reconciliation/implementation.md`.++---++## Decision 30: The full reconcile tier gates on the same counters the arrival tier does++**Date**: 2026-08-03+**Status**: accepted++### Context++`duplicatePhaseRuns(tier:)` returned `true` unconditionally for `.full`. Task 22+measured what that cost: `reconcile-noop-coherent` moved from 0.186–0.200 **ms**+to 0.286–0.298 s — ~1,450×, and ~29× the 10 ms ceiling — because every launch,+import-completion and reader-action pass walked four tables over a library with+no duplicates in it. Decision 28 recorded it and routed it to T-2092, explicitly+declining to gate the full tier from inside a measurement task because "it+trades Req 1.2's detection guarantee against a stale scan, which is the trade+Q58 already thought about and decided the other way".++Q58 decided it the other way for the **arrival** tier, where the gate reads the+previous refresh's scan because `handleSyncArrivals` reconciles *before* it+refreshes. Every full-tier caller is the opposite shape: `runLaunchReconcile`,+`handleImportCompletion` and the reader-action closures all refresh+*immediately before* scheduling their pass, so the count the gate would read is+the one that refresh produced.++### Decision++The full tier runs the duplicate phase when the session has not run one yet, or+when the same counters the arrival tier consults say there is work:+`lastDuplicateCandidateCount > 0`, a non-empty settling ledger, or an owed+follow-up. The session's first pass is unconditional.++### Rationale++The launch arm is what makes this safe to state simply: nothing has scanned for+candidates when the first pass runs, so a counter-only gate would decline the+one pass that has no fresher information to wait for.++The staleness window that remains — a set landing between a caller's refresh and+the pass it scheduled — is closed by the re-arm the arrival tier has always+relied on. A declined pass sets `duplicatePhaseSkipped`, and the next+`refreshDiagnostics` that reports candidates arms the follow-up. The set+converges one trigger later rather than being lost, which is the same property+Q58 accepted for a hydration's final batch.++### Alternatives Considered++- **Leave the full tier unconditional**: Decision 28's recorded state - Rejected:+  it charges every curation edit a whole-library walk (Q90's cost, priced by+  task 22 at ~0.29 s per edit) for a library that has no duplicates in it.+- **Narrow the reader-action trigger to deletions and resolutions** (Decision 28's+  most promising alternative) - Rejected as the *primary* fix: it removes the+  per-edit cost and leaves launch and import paying it, and it needs the mutation+  closures to report what kind of write they carried, which Q90 chose not to do.+  Still available and now cheaper, since a gated pass is nearly free.+- **Raise the 10 ms ceiling**: Rejected for the reason Decision 28 gave — the+  ceiling's own comment predicted this exact regression and caught it.++### Consequences++**Positive:**+- The full tier costs what the arrival tier costs on a coherent library, which is+  what the tiering was for.+- Req 1.2's four triggers all still run the phase whenever anything says there is+  work to do, and the session's first pass always does.++**Negative:**+- A set arriving inside the window between a caller's refresh and its pass now+  converges one trigger later than it did. The follow-up latch bounds that lag by+  the quiescence await rather than by an unrelated trigger.+- The gate now has four terms, and the launch arm is a session flag rather than a+  fact about the store.++### Impact++`LibraryRepository.duplicatePhaseRuns`, `hasRunDuplicatePhaseThisSession`,+`M4ScalePerformanceTests.reconcileNoOpOverCoherentFixture` and its ceiling,+Decision 28, and T-2092.++---++## Decision 31: "Same rule definition" is `RuleDefinitionComparator`'s question, asked in its words++**Date**: 2026-08-03+**Status**: accepted++### Context++`GroupOrdering.isConvergedGroup` — the predicate `V4LibraryValidator` uses to+decide whether a repeated rule UUID quarantines its hostname — compared rows by+`Set(rows.map(canonicalDefinition)).count == 1`. `canonicalDefinition` exists to+give the representative *ordering* a total tiebreak, and it is built for that: it+joins a segment rule's ignored anchors in **stored order**, so two permutations+stay distinguishable, and it is a `String`, whose `==` is Unicode canonical+equivalence.++Both properties are wrong for an identity question, and the app already has the+right one. `RuleDefinitionComparator.semanticallyEqual` compares ignored anchors+as a `Set` — their order carries no meaning — and phrase literals by exact+Unicode scalars, deliberately, because two byte-distinct literals are two rules+everywhere else in the identity-bearing domain. `trimsEqual` says the same about+the trims.++So two rows of one group with permuted anchors are **one rule** to every+teaching commit in the app and **two definitions** to the validator: it refused+membership for a group teaching calls a no-op.++### Decision++`isConvergedGroup([TitlePattern])` is expressed in terms of+`RuleDefinitionComparator.semanticallyEqual` plus `trimsEqual`.+`canonicalDefinition` keeps its ordering job and is no longer an identity+predicate. The URL-rule arm keeps the canonical encoding — `URLRuleDefinition`+is built entirely on `ExactScalarString` and carries no order-independent+redundancy — but compares it by exact scalars rather than through `Set<String>`.++### Rationale++One question, one answer. The validator's job is to decide whether the store is+legal; the teaching commit's job is to decide whether a definition changed. If+those two disagree, a hostname quarantines for a difference the app refuses to+write and cannot repair.++`canonicalDefinition` is not weakened to fit. Sorting its ignored anchors would+make two permuted rows tie in the representative ordering, and a tie there is a+non-deterministic representative — which Req 2.4 forbids outright.++### Alternatives Considered++- **Make `canonicalDefinition` sort its anchors and encode via+  `ExactScalarString`**: one function, both jobs - Rejected: sorting destroys the+  total order the representative selection depends on (Req 2.4).+- **Leave the drift recorded**: the reconciler writes the representative's+  columns to every row, so a converged group is literally identical afterwards -+  Rejected: it is only true *after* a pass. A group that arrives permuted+  quarantines its hostname in the window before one, and the window is a whole+  launch on a device that has not synced since.++### Consequences++**Positive:**+- The validator, the teaching commit and the no-op detector answer one question+  one way.+- Exact-scalar comparison reaches the converged-group check, matching the rest+  of the identity-bearing domain.++**Negative:**+- `isConvergedGroup` now decodes each row's definition, where it used to read+  columns. The rule tables are small and this runs at open, not on any budgeted+  path.+- An undecodable definition needs an arm of its own: a group of equally+  undecodable rows is converged only when their stored columns match by scalar.++### Impact++`GroupOrdering.isConvergedGroup`, `RuleDefinitionComparator.scalarEqual`+(internal rather than private), and `V4LibraryValidator.validate(site:)`.++---
Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift Added +1140 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swiftnew file mode 100644index 0000000..ae6defe--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift@@ -0,0 +1,1140 @@+import Foundation+import SwiftData++// The write half of duplicate reconciliation, in the `SiteReconciler` shape: a+// pure enum that takes a context and a save strategy, writes, and reports what+// it did. Nothing here reads a clock (Q56) — every value it writes is derived+// from synced content, or two devices would not reach the same fixed point+// (Req 2.4).+//+// Three phases in one pass, in this order:+//+// 1. **Rule groups converge** (Req 6.1). Rule state is what Work identity and+//    citation replay read, so it settles first.+// 2. **Work sets resolve** (Req 1.5, 5.1). Entry assignment agreement is judged+//    against the surviving Work, so an Entry set must never be evaluated over a+//    Work set this pass is still moving.+// 3. **Entry sets resolve** (Req 3.1).+//+// Deletion is *not* one of them. A collapse writes the outcome content in this+// pass and hands the deletion back as a plan, which the caller executes in a+// **fresh** context after the writes have committed (Req 2.1, Q61). A plan is+// only produced for a set the ledger saw unchanged at an earlier pass of the+// session (Req 2.3).++/// What one duplicate-reconciliation pass did.+///+/// Public because `LibraryProviding` vends the pass and the app's models take+/// the protocol, not the actor.+public struct DuplicateReconciliationOutcome: Equatable, Sendable {+    /// Rule rows whose definition, version or active flag the convergence+    /// rewrote (Req 6.1).+    public var convergedRuleRows = 0+    /// Records whose rule citations were re-pointed at a surviving version+    /// (Req 6.2).+    public var rewrittenCitations = 0+    /// Sets whose survivor rows this pass actually wrote. A set at its fixed+    /// point is not counted, because the value guard wrote nothing (Req 2.4).+    public var contentWrites = 0+    /// Entries re-pointed at a surviving Work (Req 5.2).+    public var movedEntries = 0+    /// Logical records deleted by a collapse; a split group counts once+    /// (Req 2.2).+    public var collapsedMembers = 0+    /// Sets whose deletion this pass held back — first observation, an observed+    /// change since the last one (Req 2.3), or a deleting commit that aborted+    /// (Req 2.9). **These, and only these, latch the follow-up** (Q62).+    public var settlingSetKeys: [DuplicateSetKey] = []+    /// Sets deferred behind a divergent Work set (Req 1.6). They wait on the+    /// reader, so re-arming on them would loop forever (Q62).+    public var blockedSetKeys: [DuplicateSetKey] = []+    /// Sets awaiting a reader decision (Reqs 4.1, 5.3, 5.4).+    public var reviewSetKeys: [DuplicateSetKey] = []++    public init() {}++    /// Nothing was written and nothing is waiting for anybody — so nothing a+    /// screen is built from changed, and the launch pass's refresh gate can+    /// skip its re-derivation.+    public var isEmpty: Bool {+        convergedRuleRows == 0 && rewrittenCitations == 0 && contentWrites == 0+            && movedEntries == 0 && collapsedMembers == 0+            && reviewSetKeys.isEmpty && blockedSetKeys.isEmpty+    }++    /// Req 1.3: a pass that deferred a deletion owes the session another one.+    public var followUpNeeded: Bool { !settlingSetKeys.isEmpty }++    /// Whether this pass wrote to the store at all.+    ///+    /// Narrower than `isEmpty`, which also counts the reader workload: a library+    /// holding an unresolved divergent set reports that set on every pass+    /// forever, and reporting it is not writing. Req 2.4's fixed point is about+    /// the writes.+    public var wroteNothing: Bool {+        convergedRuleRows == 0 && rewrittenCitations == 0 && contentWrites == 0+            && movedEntries == 0 && collapsedMembers == 0+    }++    mutating func formUnion(_ other: Self) {+        convergedRuleRows += other.convergedRuleRows+        rewrittenCitations += other.rewrittenCitations+        contentWrites += other.contentWrites+        movedEntries += other.movedEntries+        collapsedMembers += other.collapsedMembers+        settlingSetKeys += other.settlingSetKeys+        blockedSetKeys += other.blockedSetKeys+        reviewSetKeys += other.reviewSetKeys+    }+}++/// What "unchanged" means for a duplicate set (Req 2.3): its members, their+/// authored variants, and their timestamps.+///+/// Membership is *also* the set key, so a set that gains or loses a member+/// produces a new key rather than a changed fingerprint — which is exactly a+/// first observation.+public struct SetFingerprint: Hashable, Sendable {+    /// One entry per member, in member order: its UUID and its row count. A+    /// group gaining a row is a change, and one this must see.+    let members: [String]+    /// The set's authored variants, in variant order, by their stable names.+    let variants: [VariantID]+    /// Each member's earliest and latest timestamp, in member order.+    let timestamps: [Date]+}++/// Per-session settling state (Req 2.3, Q19): what each duplicate set looked+/// like at the **end** of the last pass that observed it.+///+/// End, not start. A pass writes the outcome content before it can delete+/// anything (Req 2.1), and those writes move the survivor's timestamps — so a+/// fingerprint taken before them would differ from the next pass's derivation+/// for no reason but reconciliation's own hand, and no set would ever settle+/// inside a two-pass session. Recording the post-write state keeps the property+/// that matters: any change made by *anything else* between two passes still+/// shows up.+///+/// Never persisted, never synced. A launch-and-quit session leaves distinct-UUID+/// duplicates tolerated until a longer one, which M4a guarantees is safe.+public struct DuplicateSettlingLedger: Sendable {+    private var fingerprints: [DuplicateSetKey: SetFingerprint] = [:]++    public init() {}++    public var observedSetCount: Int { fingerprints.count }++    /// Whether `key` was observed in exactly this shape at an earlier pass.+    func hasSettled(_ key: DuplicateSetKey, fingerprint: SetFingerprint) -> Bool {+        fingerprints[key] == fingerprint+    }++    mutating func record(_ key: DuplicateSetKey, fingerprint: SetFingerprint) {+        fingerprints[key] = fingerprint+    }++    mutating func forget(_ key: DuplicateSetKey) {+        fingerprints.removeValue(forKey: key)+    }++    /// Drops every set the current derivation no longer reports, so a ledger+    /// cannot grow across a session for sets that resolved long ago.+    mutating func retain(_ keys: Set<DuplicateSetKey>) {+        fingerprints = fingerprints.filter { keys.contains($0.key) }+    }+}++/// A settled set's losing members, ready for the verify-and-delete transaction.+public struct DuplicateDeletionPlan: Sendable, Equatable {+    public let key: DuplicateSetKey+    public let survivorID: UUID+    public let loserIDs: [UUID]+    let fingerprint: SetFingerprint+}++/// One reconciliation pass, whole: what the Site phases did and what the+/// duplicate phase did.+///+/// The two are reported side by side rather than merged. They repair different+/// things, and the refresh gates, the diagnoses and the follow-up latch all read+/// one half without the other.+public struct ReconciliationOutcome: Equatable, Sendable {+    public var site = SiteReconciliationOutcome()+    public var duplicates = DuplicateReconciliationOutcome()+    /// Whether the duplicate phase ran at all. False on an arrival-tier pass+    /// the gate declined (Q53/Q58).+    public var duplicatePhaseRan = false++    public init() {}++    public init(site: SiteReconciliationOutcome) { self.site = site }++    public var isEmpty: Bool { site.isEmpty && duplicates.isEmpty }+}++/// Which tier of pass this is (Q53/Q58).+public enum ReconcilePassTier: Sendable {+    /// Launch, import completion, a reader action, or a follow-up. The whole+    /// duplicate phase runs.+    case full+    /// The arrival debounce. The duplicate phase runs only where the last+    /// tolerance scan reported candidates or the session ledger holds pending+    /// work — `reconcileAfterSync`'s own design rejected charging every arrival+    /// for a whole-library walk.+    case arrival+}++enum DuplicateReconciler {++    /// One pass's writes, and the deletions it earned.+    struct PassResult {+        var outcome = DuplicateReconciliationOutcome()+        var deletions: [DuplicateDeletionPlan] = []+        /// The assignment normalisation the deletion phase re-derives its Entry+        /// fingerprints under, so the two halves of one pass agree about which+        /// assignments are equal.+        var canonicalWorkIDs: [UUID: UUID] = [:]+    }++    // MARK: - The pass++    /// Converges rule groups, resolves silently resolvable Work sets, then Entry+    /// sets, and returns the deletions the settling rule has cleared.+    ///+    /// `scan` is derived by the caller inside the same locked context — derived,+    /// never remembered, the rule the Site work list follows.+    static func run(+        scan: DuplicateScanResult,+        ledger: inout DuplicateSettlingLedger,+        batchSize: Int,+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy+    ) throws -> PassResult {+        var result = PassResult()+        ledger.retain(Set(+            scan.entrySets.map(\.key) + scan.workSets.map(\.key)+                + scan.titleRuleSets.map(\.key) + scan.urlRuleSets.map(\.key)))++        result.outcome.formUnion(+            try convergeRules(scan, batchSize: batchSize, context: context, saveStrategy: saveStrategy))++        let workPhase = try resolveWorkSets(+            scan.workSets, ledger: &ledger, batchSize: batchSize,+            context: context, saveStrategy: saveStrategy)+        result.outcome.formUnion(workPhase.outcome)+        result.deletions += workPhase.deletions++        let entryPhase = try resolveEntrySets(+            scan, ledger: &ledger, batchSize: batchSize,+            context: context, saveStrategy: saveStrategy)+        result.outcome.formUnion(entryPhase.outcome)+        result.deletions += entryPhase.deletions+        result.canonicalWorkIDs = entryPhase.canonicalWorkIDs++        return result+    }++    // MARK: - Req 6: rule convergence++    /// Makes every row of a rule identity group hold one definition, without+    /// deleting any of them (Req 6.1, Decision 4).+    ///+    /// The convergence target is `GroupOrdering`'s representative row, which+    /// doubles as Req 6.1's selector: absence sorts last in the canonical+    /// encoding, so the more-specified definition represents (Q68). Every write+    /// is value-guarded, so a converged group dirties nothing on the next pass.+    private static func convergeRules(+        _ scan: DuplicateScanResult,+        batchSize: Int,+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy+    ) throws -> DuplicateReconciliationOutcome {+        var outcome = DuplicateReconciliationOutcome()+        guard !scan.titleRuleSets.isEmpty || !scan.urlRuleSets.isEmpty else { return outcome }++        // Rule ids are unique per rule, so one map serves both types and both+        // citation shapes (Req 6.2).+        var rewrites: [UUID: Int] = [:]+        var dirty = false++        if !scan.titleRuleSets.isEmpty {+            let byID = Dictionary(grouping: try context.fetch(FetchDescriptor<TitlePattern>()), by: \.id)+            for set in scan.titleRuleSets {+                guard let id = set.key.memberIDs.first, let rows = byID[id], rows.count > 1 else {+                    continue+                }+                let group = try convergePatternGroup(rows)+                outcome.convergedRuleRows += group.rewritten+                if group.rewritten > 0 { dirty = true }+                if let version = group.rewrittenVersion { rewrites[id] = version }+            }+        }++        if !scan.urlRuleSets.isEmpty {+            let byID = Dictionary(+                grouping: try context.fetch(FetchDescriptor<URLRulePattern>()), by: \.id)+            for set in scan.urlRuleSets {+                guard let id = set.key.memberIDs.first, let rows = byID[id], rows.count > 1 else {+                    continue+                }+                let group = convergeURLRuleGroup(rows)+                outcome.convergedRuleRows += group.rewritten+                if group.rewritten > 0 { dirty = true }+                if let version = group.rewrittenVersion { rewrites[id] = version }+            }+        }++        if dirty { try saveStrategy.save(context) }+        outcome.rewrittenCitations = try rewriteCitations(+            rewrites, batchSize: batchSize, context: context, saveStrategy: saveStrategy)+        return outcome+    }++    /// The surviving definition is the representative row's, over the **full**+    /// definition surface including the trims — `setImmutableDefinition` omitted+    /// those, so a group converged through it could still derive two different+    /// chapter titles (Q63).+    private static func convergePatternGroup(+        _ rows: [TitlePattern]+    ) throws -> (rewritten: Int, rewrittenVersion: Int?) {+        let ordered = GroupOrdering.sortedPatternRows(rows)+        guard let representative = ordered.first else { return (0, nil) }+        // A row whose columns do not form a legal arm cannot be a convergence+        // target and must not be silently rewritten either: the validator+        // reports it, and this pass leaves it exactly as it found it.+        //+        // Recorded rather than changed: this returns before the version+        // alignment and the active-flag demotion too, so one malformed row —+        // which is the *representative* only because absence sorts last, and a+        // malformed definition often reads as absent — blocks the whole group's+        // convergence, including the two repairs that do not depend on the+        // definition at all. Falling back to the next legal row in representative+        // order would narrow it, at the cost of a selector that is no longer+        // "the least row" and a second rule to state. Left for the design owner.+        guard let definition = try? representative.definition else { return (0, nil) }++        var rewritten = 0+        for row in ordered where row !== representative {+            if try row.applyDefinition(+                definition, trimPrefix: representative.trimPrefix,+                trimSuffix: representative.trimSuffix) {+                rewritten += 1+            }+        }+        let version = alignVersions(+            ordered, representative: representative,+            version: { $0.version }, setVersion: { $0.version = $1 },+            permitsWrite: patternVersionIsFree)+        rewritten += version.rewritten+        rewritten += demoteWithinSites(+            ordered, isMarked: { $0.isActive }, demote: { $0.isActive = false })+        return (rewritten, version.converged)+    }++    private static func convergeURLRuleGroup(+        _ rows: [URLRulePattern]+    ) -> (rewritten: Int, rewrittenVersion: Int?) {+        let ordered = GroupOrdering.sortedURLRuleRows(rows)+        guard let representative = ordered.first else { return (0, nil) }++        var rewritten = 0+        for row in ordered where row !== representative {+            if row.definitionData != representative.definitionData {+                row.definitionData = representative.definitionData+                rewritten += 1+            }+        }+        let version = alignVersions(+            ordered, representative: representative,+            version: { $0.version }, setVersion: { $0.version = $1 },+            permitsWrite: urlRuleVersionIsFree)+        rewritten += version.rewritten+        rewritten += demoteWithinSites(+            ordered, isMarked: { $0.isCurrent }, demote: { $0.isCurrent = false })+        return (rewritten, version.converged)+    }++    /// Aligns the group's versions on the representative's, wherever the write+    /// leaves the owning Site row still validating (Decision 13).+    ///+    /// `V4LibraryValidator` requires each Site row's rule versions to be positive+    /// and **Site-unique over all of that row's patterns** (`:507-517`) or URL rules+    /// (`:528-538`) — not merely over this group's. Every new rule version gets a new+    /// UUID (`+ComposedTeaching.swift:120`), so a Site row routinely holds+    /// several rules at several versions, and a write that lands the group's row+    /// on a version an unrelated rule on the same Site row already holds+    /// manufactures a `.siteTuple`. A `.siteTuple` quarantines the hostname and+    /// takes teaching off the capture path, which is a worse state than the+    /// un-aligned versions it replaces: a pass that "converges" must not move a+    /// Site row from valid to invalid.+    ///+    /// `permitsWrite` is therefore asked, per row, whether the target version is+    /// still free on that row's Site — reading the Site's own membership, which+    /// is the set the invariant is over. It reads live values, so two rows of the+    /// group on one Site row can never both take the target: the second sees the+    /// first sitting on it. That is Decision 13's "never within one Site row",+    /// derived rather than special-cased.+    ///+    /// `converged` is non-nil only where every row ended on one version, which is+    /// the condition under which a citation naming a losing version has nowhere+    /// left to resolve and must be rewritten (Req 6.2).+    private static func alignVersions<Rule: AnyObject>(+        _ ordered: [Rule],+        representative: Rule,+        version: (Rule) -> Int,+        setVersion: (Rule, Int) -> Void,+        permitsWrite: (Rule, Int) -> Bool+    ) -> (rewritten: Int, converged: Int?) {+        var rewritten = 0+        let target = version(representative)+        for row in ordered where row !== representative {+            guard version(row) != target, permitsWrite(row, target) else { continue }+            setVersion(row, target)+            rewritten += 1+        }+        let converged = Set(ordered.map(version)) == [target]+        return (rewritten, rewritten > 0 && converged ? target : nil)+    }++    /// Whether `target` is free on `row`'s Site row — over **every** pattern that+    /// Site row holds, which is the membership `V4LibraryValidator:507-517` checks.+    ///+    /// An ownerless row belongs to no Site tuple, so it can threaten no+    /// Site-uniqueness invariant and always aligns.+    private static func patternVersionIsFree(_ row: TitlePattern, _ target: Int) -> Bool {+        guard let site = row.site else { return true }+        return !site.patternValues.contains { $0 !== row && $0.version == target }+    }++    /// The URL-rule counterpart, with the extra clause the validator carries for+    /// this type: the current rule must hold the greatest retained version+    /// (`V4LibraryValidator:558-567`). Lowering a current rule under a retained+    /// one, or raising a retained one over the current, is the same class of harm+    /// as a version collision — a validating hostname turned into a quarantined+    /// one by a pass that was only supposed to converge definitions.+    private static func urlRuleVersionIsFree(_ row: URLRulePattern, _ target: Int) -> Bool {+        guard let site = row.site else { return true }+        let others = site.urlRuleValues.filter { $0 !== row }+        guard !others.contains(where: { $0.version == target }) else { return false }+        if row.isCurrent { return others.allSatisfy { $0.version <= target } }+        if let current = others.first(where: \.isCurrent) { return target <= current.version }+        return true+    }++    /// Demotes duplicate active/current flags **within each owning Site row**,+    /// keeping the first in representative order and never activating anything.+    ///+    /// Q63 asks convergence not to manufacture the two-active `.siteTuple` state+    /// the Site phase just repaired, and the safe way to honour that is to demote+    /// per site rather than across sites: moving the flag onto the group's+    /// representative would strand a *second* taught row holding this rule's+    /// only active copy, which is illegal in every mode. Each site keeps its own+    /// active slot; convergence only removes copies of it.+    private static func demoteWithinSites<Rule: AnyObject>(+        _ ordered: [Rule], isMarked: (Rule) -> Bool, demote: (Rule) -> Void+    ) -> Int {+        var seen: Set<ObjectIdentifier> = []+        var sawOwnerless = false+        var demoted = 0+        for row in ordered where isMarked(row) {+            let site: Site? =+                switch row {+                case let pattern as TitlePattern: pattern.site+                case let rule as URLRulePattern: rule.site+                default: nil+                }+            let isFirstInBucket: Bool+            if let site {+                isFirstInBucket = seen.insert(ObjectIdentifier(site)).inserted+            } else {+                // A rule with no owning row cannot strand a site, so the whole+                // ownerless bucket is treated as one and keeps a single flag.+                isFirstInBucket = !sawOwnerless+                sawOwnerless = true+            }+            guard !isFirstInBucket else { continue }+            demote(row)+            demoted += 1+        }+        return demoted+    }++    /// Re-points every citation naming a rewritten rule at the version the group+    /// converged on (Req 6.2), reusing the Site reconciler's citation machinery.+    ///+    /// One walk for every rule group at once, and only when a version actually+    /// moved: rule groups are rare, and a walk per group would charge the+    /// library once each.+    private static func rewriteCitations(+        _ rewrites: [UUID: Int],+        batchSize: Int,+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy+    ) throws -> Int {+        guard !rewrites.isEmpty else { return 0 }+        var rewritten = 0+        for chunk in LibraryRepository.chunks(+            of: try context.fetch(FetchDescriptor<Entry>()), size: batchSize) {+            var dirty = false+            for entry in chunk where SiteReconciler.rewriteCitations(of: entry, rewrites) {+                rewritten += 1+                dirty = true+            }+            if dirty { try saveStrategy.save(context) }+        }+        for chunk in LibraryRepository.chunks(+            of: try context.fetch(FetchDescriptor<Work>()), size: batchSize) {+            var dirty = false+            for work in chunk where SiteReconciler.rewriteCitations(of: work, rewrites) {+                rewritten += 1+                dirty = true+            }+            if dirty { try saveStrategy.save(context) }+        }+        return rewritten+    }++    // MARK: - Req 5.1: Work sets++    private static func resolveWorkSets(+        _ sets: [WorkDuplicateSet],+        ledger: inout DuplicateSettlingLedger,+        batchSize: Int,+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy+    ) throws -> PassResult {+        var result = PassResult()+        let resolvable = sets.filter { $0.classification == .silentlyResolvable }+        for set in sets where set.classification != .silentlyResolvable {+            classify(set, into: &result.outcome)+        }+        guard !resolvable.isEmpty else { return result }++        // One fetch, bucketed in memory: a predicate fetch per set would scan+        // the table once per set, and the sets are the reason this pass is+        // running at all. Scoped to the members these sets name — the rows it+        // does not touch cost nothing to leave in the store.+        let rowsByID = try workRows(+            ids: Array(Set(resolvable.flatMap(\.key.memberIDs))), context: context)+        var pending = Chunk(batchSize: batchSize)++        for set in resolvable {+            guard let survivorID = set.members.first?.id,+                  let survivorRows = rowsByID[survivorID], !survivorRows.isEmpty+            else { continue }+            let memberRows = set.key.memberIDs.compactMap { rowsByID[$0] }+            guard memberRows.count == set.members.count else { continue }+            let allRows = memberRows.flatMap { $0 }++            var wrote = false+            let survivor = GroupOrdering.sortedWorkRows(survivorRows)+            if let content = set.variants.first?.content {+                guard let carrier = carrierRow(+                    among: allRows, content: content,+                    authored: GroupOrdering.authoredContent(of:),+                    ordered: GroupOrdering.sortedWorkRows)+                else { continue }+                wrote = apply(carrier, to: survivor) || wrote+            }+            // Q74: the group's rows must not differ in `modifiedAt` — it is the+            // last slot of the representative ordering, so a divergence there+            // moves which row represents the record.+            let latest = allRows.map(\.modifiedAt).max() ?? .distantPast+            for row in survivor where row.modifiedAt != latest {+                row.modifiedAt = latest+                wrote = true+            }+            // Req 5.2, and Req 2.1's ordering: the Entries move and commit+            // before anything is deleted, so no interruption can leave an Entry+            // reachable only through a doomed Work.+            let moved = repointEntries(+                from: set.members.dropFirst().compactMap { rowsByID[$0.id] }.flatMap { $0 },+                to: survivor)+            if moved > 0 { wrote = true }++            if wrote { result.outcome.contentWrites += 1 }+            result.outcome.movedEntries += moved+            pending.add(records: allRows.count)++            let fingerprint = workFingerprint(set.key.memberIDs, rowsByID: rowsByID)+            let settled = ledger.hasSettled(set.key, fingerprint: fingerprint)+            let losers = set.members.dropFirst().map(\.id)+            pending.note(+                key: set.key, fingerprint: fingerprint,+                deletion: settled && !losers.isEmpty+                    ? DuplicateDeletionPlan(+                        key: set.key, survivorID: survivorID, loserIDs: losers,+                        fingerprint: fingerprint)+                    : nil,+                settling: !settled && !losers.isEmpty)+            try pending.commitIfFull(+                context: context, saveStrategy: saveStrategy, ledger: &ledger, into: &result)+        }+        try pending.commit(+            context: context, saveStrategy: saveStrategy, ledger: &ledger, into: &result)+        return result+    }++    /// Req 5.2: every Entry of a losing Work moves to the survivor keeping its+    /// note, rating, per-field provenance and timestamps unchanged.+    ///+    /// Deliberately unlike Merge, which stamps `modifiedAt` from the clock on its+    /// reader-confirmed path — this is the silent path, and a clock write per+    /// device breaks the shared fixed point of Req 2.4 (Q56).+    /// Internal rather than private: the reader-confirmed resolution moves a+    /// losing Work's Entries under exactly the same rule (Req 5.2), and two+    /// spellings of "keep everything, change the pointer" is one too many.+    @discardableResult+    static func repointEntries(from losers: [Work], to survivor: [Work]) -> Int {+        guard let target = survivor.first else { return 0 }+        var moved = 0+        for loser in losers {+            for entry in loser.entryValues where entry.work !== target {+                entry.work = target+                moved += 1+            }+        }+        return moved+    }++    // MARK: - Req 3.1: Entry sets++    private static func resolveEntrySets(+        _ scan: DuplicateScanResult,+        ledger: inout DuplicateSettlingLedger,+        batchSize: Int,+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy+    ) throws -> PassResult {+        var result = PassResult()+        let resolvable = scan.entrySets.filter { $0.classification == .silentlyResolvable }+        for set in scan.entrySets where set.classification != .silentlyResolvable {+            classify(set, into: &result.outcome)+        }+        // The assignment normalisation the Definitions call for: two rows+        // pointing at two members of one Work set do not disagree. An equality+        // key only — nothing here writes an assignment (Q67). Read off the scan+        // rather than re-derived: the deletion phase re-verifies its Entry+        // fingerprints under this map, and a second spelling of it would let the+        // pass delete against a normalisation the scan disagrees with.+        let canonicalWorkIDs = scan.canonicalWorkIDs+        result.canonicalWorkIDs = canonicalWorkIDs+        guard !resolvable.isEmpty else { return result }++        // Scoped to these sets' members, for the reason the Work phase states.+        let rowsByID = try entryRows(+            ids: Array(Set(resolvable.flatMap(\.key.memberIDs))), context: context)+        var pending = Chunk(batchSize: batchSize)++        for set in resolvable {+            guard let survivorID = set.members.first?.id,+                  let survivorRows = rowsByID[survivorID], !survivorRows.isEmpty+            else { continue }+            let memberRows = set.key.memberIDs.compactMap { rowsByID[$0] }+            guard memberRows.count == set.members.count else { continue }+            let allRows = memberRows.flatMap { $0 }++            var wrote = false+            let survivor = GroupOrdering.sortedEntryRows(survivorRows)+            if let content = set.variants.first?.content {+                guard let carrier = carrierRow(+                    among: allRows, content: content,+                    authored: { GroupOrdering.authoredContent(of: $0)+                        .normalizingAssignment(using: canonicalWorkIDs) },+                    ordered: GroupOrdering.sortedEntryRows)+                else { continue }+                wrote = apply(carrier, to: survivor) || wrote+            }+            // Req 3.1: the survivor's `lastSharedAt` is raised to the set's+            // latest. `firstCapturedAt` is never written — the survivor rule+            // already selects the earliest, and split groups report member+            // timestamps by projection (Q37).+            let lastShared = allRows.map(\.lastSharedAt).max() ?? .distantPast+            let latest = allRows.map(\.modifiedAt).max() ?? .distantPast+            for row in survivor {+                if row.lastSharedAt != lastShared {+                    row.lastSharedAt = lastShared+                    wrote = true+                }+                if row.modifiedAt != latest {+                    row.modifiedAt = latest+                    wrote = true+                }+            }++            if wrote { result.outcome.contentWrites += 1 }+            pending.add(records: allRows.count)++            let fingerprint = entryFingerprint(+                set.key.memberIDs, rowsByID: rowsByID, canonicalWorkIDs: canonicalWorkIDs)+            let settled = ledger.hasSettled(set.key, fingerprint: fingerprint)+            let losers = set.members.dropFirst().map(\.id)+            pending.note(+                key: set.key, fingerprint: fingerprint,+                deletion: settled && !losers.isEmpty+                    ? DuplicateDeletionPlan(+                        key: set.key, survivorID: survivorID, loserIDs: losers,+                        fingerprint: fingerprint)+                    : nil,+                settling: !settled && !losers.isEmpty)+            try pending.commitIfFull(+                context: context, saveStrategy: saveStrategy, ledger: &ledger, into: &result)+        }+        try pending.commit(+            context: context, saveStrategy: saveStrategy, ledger: &ledger, into: &result)+        return result+    }++    // MARK: - Req 2.9: verify and delete++    /// Deletes one settled set's losing members, re-verifying at commit time+    /// that the set is still the one the pass qualified (Req 2.9).+    ///+    /// **`context` must be a fresh one** (Q61): re-fetching in the deriving+    /// context returns that context's own cache, which would make the+    /// verification a tautology. The window this leaves is the one Decision 5+    /// accepts, and the design claims no more.+    ///+    /// Returns the plans whose deletion committed; anything missing from the+    /// result aborted and re-arms the follow-up.+    ///+    /// One predicate fetch per record type for the whole batch, not one per+    /// plan, and scoped to the sets' own member UUIDs: these plans touch a few+    /// hundred rows and used to load every row of the table to find them.+    ///+    /// **Saves are chunked, with a per-set replay on failure** (Decision 29).+    /// Q86 chose a transaction per set so a Req 2.9 rollback discards one set's+    /// work rather than a chunk's — but that guarantee only has to hold *when a+    /// save fails*, and 300 sets were 300 saves against a context holding ~6,000+    /// rows (task 22's 8.9 s against a 2 s budget). A chunk is staged, verified+    /// per set, and saved once; a failing chunk rolls back, re-faults, and+    /// replays its sets one at a time exactly as before. The per-set fingerprint+    /// re-verification is unchanged and still per-set, so Req 2.9's check is the+    /// same check.+    ///+    /// **Plan order matters and nothing here states it.** Work plans must precede+    /// Entry plans, or an Entry deleted by an Entry plan could still be moved by+    /// a later Work plan's `repointEntries`. It holds today because `run` appends+    /// the Work phase's deletions before the Entry phase's, which is Req 1.5's+    /// ordering doing double duty. Recorded rather than enforced: a sort here+    /// would be a second statement of the same rule, and the one place it could+    /// drift is the two lines in `run`. Chunking preserves it — chunks are cut+    /// out of `plans` in order and never reorder within one.+    static func commitDeletions(+        _ plans: [DuplicateDeletionPlan],+        canonicalWorkIDs: [UUID: UUID],+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy+    ) throws -> [DuplicateSetKey] {+        guard !plans.isEmpty else { return [] }+        var rows = try DeletionRows(plans: plans, context: context)++        var committed: [DuplicateSetKey] = []+        for chunk in deletionChunks(of: plans) {+            var staged: [DuplicateDeletionPlan] = []+            for plan in chunk+            where stage(plan, rows: &rows, canonicalWorkIDs: canonicalWorkIDs, context: context) {+                staged.append(plan)+            }+            guard !staged.isEmpty else { continue }+            if try commitDeletion(+                context: context, saveStrategy: saveStrategy,+                refault: { try rows.refault(context: context) })+            {+                committed += staged.map(\.key)+                continue+            }+            // Q86's guarantee, where it is actually needed: the chunk rolled+            // back and its work is gone, so every set in it is re-verified and+            // committed on its own. A chunk failure retries N sets instead of+            // failing one — the cost of the chunking, and the reason the replay+            // exists rather than the whole chunk being abandoned.+            for plan in staged+            where stage(plan, rows: &rows, canonicalWorkIDs: canonicalWorkIDs, context: context) {+                if try commitDeletion(+                    context: context, saveStrategy: saveStrategy,+                    refault: { try rows.refault(context: context) })+                {+                    committed.append(plan.key)+                }+            }+        }+        return committed+    }++    /// Plans grouped into commit chunks, cut on the number of *rows* a chunk+    /// deletes rather than the number of sets — the same measure+    /// `LibraryRepository.chunks` uses, and the reason a set never straddles a+    /// chunk.+    private static func deletionChunks(+        of plans: [DuplicateDeletionPlan]+    ) -> [[DuplicateDeletionPlan]] {+        var chunks: [[DuplicateDeletionPlan]] = []+        var current: [DuplicateDeletionPlan] = []+        var rowsInChunk = 0+        for plan in plans {+            if rowsInChunk >= LibraryRepository.bulkOperationBatchSize, !current.isEmpty {+                chunks.append(current)+                current = []+                rowsInChunk = 0+            }+            current.append(plan)+            rowsInChunk += plan.loserIDs.count+        }+        if !current.isEmpty { chunks.append(current) }+        return chunks+    }++    /// Verifies one plan and stages its deletions in `context`, without saving.+    ///+    /// Returns whether the plan was staged: a fingerprint that no longer matches+    /// is Req 2.9's abort, and it is decided per set whether the save that+    /// follows covers one set or a chunk of them.+    private static func stage(+        _ plan: DuplicateDeletionPlan,+        rows: inout DeletionRows,+        canonicalWorkIDs: [UUID: UUID],+        context: ModelContext+    ) -> Bool {+        switch plan.key.recordType {+        case .entry:+            guard entryFingerprint(+                plan.key.memberIDs, rowsByID: rows.entries, canonicalWorkIDs: canonicalWorkIDs)+                == plan.fingerprint+            else { return false }+            for id in plan.loserIDs {+                for row in rows.entries[id] ?? [] { context.delete(row) }+            }+            return true+        case .work:+            guard workFingerprint(plan.key.memberIDs, rowsByID: rows.works) == plan.fingerprint+            else { return false }+            guard let survivor = rows.works[plan.survivorID].map(GroupOrdering.sortedWorkRows),+                  !survivor.isEmpty+            else { return false }+            // `Work.entries` nullifies on delete, so an Entry that arrived+            // pointing at a losing row between the write phase and here would+            // be unattached by the deletion — which Req 5.2 forbids outright.+            // The move is idempotent: the write phase already did it for+            // everything it saw.+            let losers = plan.loserIDs.flatMap { rows.works[$0] ?? [] }+            repointEntries(from: losers, to: survivor)+            for row in losers { context.delete(row) }+            return true+        case .titleRule, .urlRule:+            // Rule groups converge and are never deleted (Decision 4, Q39).+            return false+        }+    }++    /// The rows the deletion phase verifies and deletes, fetched by application+    /// UUID rather than by loading the tables whole.+    private struct DeletionRows {+        private let entryIDs: [UUID]+        private let workIDs: [UUID]+        private(set) var entries: [UUID: [Entry]] = [:]+        private(set) var works: [UUID: [Work]] = [:]++        init(plans: [DuplicateDeletionPlan], context: ModelContext) throws {+            entryIDs = Array(Set(+                plans.filter { $0.key.recordType == .entry }.flatMap(\.key.memberIDs)))+            workIDs = Array(Set(+                plans.filter { $0.key.recordType == .work }.flatMap(\.key.memberIDs)))+            try reload(context: context)+        }++        /// After a rollback. SwiftData restores the store but leaves `@Model`+        /// accessors reading stale cached values until something re-faults them,+        /// and the plans that follow re-verify their fingerprints against those+        /// accessors.+        mutating func refault(context: ModelContext) throws {+            try reload(context: context)+            // A Work collapse re-points Entries, so their accessors are stale+            // too even though nothing verifies them. Only after a rollback: the+            // whole-table read is the price of the repair, not of the pass.+            if !workIDs.isEmpty { _ = try context.fetch(FetchDescriptor<Entry>()) }+        }++        private mutating func reload(context: ModelContext) throws {+            entries = try DuplicateReconciler.entryRows(ids: entryIDs, context: context)+            works = try DuplicateReconciler.workRows(ids: workIDs, context: context)+        }+    }++    // MARK: - Scoped reads++    // Every phase below touches the rows of its own sets and nothing else. A+    // whole-table fetch to reach them charged the pass ~6,000 rows to write+    // ~600 (task 22), so the reads are scoped to the member UUIDs the sets+    // already name. Still one fetch per phase — the loop is what a predicate+    // fetch per set would have made expensive, and that is not what this is.++    static func entryRows(ids: [UUID], context: ModelContext) throws -> [UUID: [Entry]] {+        guard !ids.isEmpty else { return [:] }+        return Dictionary(+            grouping: try context.fetch(+                FetchDescriptor<Entry>(predicate: #Predicate { ids.contains($0.id) })),+            by: \.id)+    }++    static func workRows(ids: [UUID], context: ModelContext) throws -> [UUID: [Work]] {+        guard !ids.isEmpty else { return [:] }+        return Dictionary(+            grouping: try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { ids.contains($0.id) })),+            by: \.id)+    }++    /// Commits one set's deletion, rolling that set's transaction back — and+    /// only that set's — where the save fails.+    ///+    /// The discardable fetch after the rollback is not decoration: SwiftData's+    /// rollback restores the store but leaves `@Model` accessors reading stale+    /// cached values until something re-faults them, and this pass keeps using+    /// the context for the next set.+    private static func commitDeletion(+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy,+        refault: () throws -> Void+    ) rethrows -> Bool {+        do {+            try saveStrategy.save(context)+            return true+        } catch {+            context.rollback()+            // `try?`, recorded: a re-fault that itself fails leaves the context's+            // `@Model` accessors stale for the plans that follow, and each of+            // those re-verifies its fingerprint against those accessors. A stale+            // read fails the compare, so the failure mode is a set that defers+            // one more pass — never a deletion against content that moved.+            try? refault()+            return false+        }+    }++    // MARK: - Fingerprints++    static func entryFingerprint(+        _ memberIDs: [UUID], rowsByID: [UUID: [Entry]], canonicalWorkIDs: [UUID: UUID]+    ) -> SetFingerprint {+        fingerprint(+            memberIDs, rowsByID: rowsByID,+            authored: { GroupOrdering.authoredContent(of: $0)+                .normalizingAssignment(using: canonicalWorkIDs) },+            earliest: \.firstCapturedAt, latest: \.lastSharedAt)+    }++    static func workFingerprint(+        _ memberIDs: [UUID], rowsByID: [UUID: [Work]]+    ) -> SetFingerprint {+        fingerprint(+            memberIDs, rowsByID: rowsByID, authored: GroupOrdering.authoredContent(of:),+            earliest: \.createdAt, latest: \.modifiedAt)+    }++    private static func fingerprint<Row, Content: AuthoredContent>(+        _ memberIDs: [UUID],+        rowsByID: [UUID: [Row]],+        authored: (Row) -> Content,+        earliest: KeyPath<Row, Date>,+        latest: KeyPath<Row, Date>+    ) -> SetFingerprint {+        var members: [String] = []+        var timestamps: [Date] = []+        var variants: [AuthoredVariant<Content>] = []+        for id in memberIDs.sorted(by: { $0.uuidString < $1.uuidString }) {+            let rows = rowsByID[id] ?? []+            members.append("\(id.uuidString.lowercased())x\(rows.count)")+            timestamps.append(rows.map { $0[keyPath: earliest] }.min() ?? .distantPast)+            timestamps.append(rows.map { $0[keyPath: latest] }.max() ?? .distantPast)+            variants += GroupOrdering.variants(+                contents: rows.map(authored), dates: rows.map { $0[keyPath: earliest] })+        }+        return SetFingerprint(+            members: members,+            variants: GroupOrdering.mergedVariants(variants).map(\.id),+            timestamps: timestamps)+    }++    // MARK: - Outcome content++    /// The row the outcome content is copied *from*: the first row, in survivor+    /// then representative order, whose authored content is the set's variant.+    ///+    /// Decision 10's rule applied to the write. The authored fields do not travel+    /// alone — a manual chapter title has a provenance beside it, a tag list has+    /// the order the reader entered it — and rebuilding them from the content+    /// tuple would fabricate all of that.+    ///+    /// Nil means the set's own variant is on none of its rows, which is a state+    /// the derivation should make unreachable — the variant came from these rows.+    /// The callers treat it as a **guard** rather than as "nothing to copy": they+    /// skip the set without recording a fingerprint, so it is simply re-derived+    /// next pass. Writing nothing and settling anyway is the one combination that+    /// is unsafe, because two passes later the losing rows would be deleted while+    /// the survivor never received the content (Req 2.1). An all-bare set has no+    /// variant at all and never reaches here.+    private static func carrierRow<Row, Content: AuthoredContent>(+        among rows: [Row],+        content: Content,+        authored: (Row) -> Content,+        ordered: ([Row]) -> [Row]+    ) -> Row? {+        ordered(rows).first { authored($0) == content }+    }++    /// Copies the carrier's authored fields onto every survivor row (Req 3.1).+    ///+    /// **No assignment is ever written** (Q67). The survivor keeps its own+    /// physical assignment, and Req 5.2's collapse re-points it. Every other+    /// field copies straight: on the silent path the set has at most one variant,+    /// so a row differing from the carrier in an authored field would be a second+    /// variant and the set would not be here.+    private static func apply(_ carrier: Entry, to rows: [Entry]) -> Bool {+        var changed = false+        for row in rows where row !== carrier {+            if row.note != carrier.note {+                row.note = carrier.note+                changed = true+            }+            if row.rating != carrier.rating {+                row.rating = carrier.rating+                changed = true+            }+            if carrier.chapterTitleProvenance == .manual {+                if row.chapterTitle != carrier.chapterTitle {+                    row.chapterTitle = carrier.chapterTitle+                    changed = true+                }+                if row.chapterTitleProvenance != .manual {+                    row.chapterTitleProvenance = .manual+                    changed = true+                }+            }+            // Only ever set: a row holding the flag while the carrier does not+            // would be a second variant. `work` goes with it, because a row+            // that is intentionally unattached and still points somewhere is a+            // state no write path produces.+            if carrier.intentionallyUnattached, !row.intentionallyUnattached {+                row.intentionallyUnattached = true+                row.work = nil+                changed = true+            }+        }+        return changed+    }++    /// Q97, recorded and open: nothing here writes a **bucket key**.+    /// `lastParsedTitle` and `urlIdentity` are what `DuplicateScan.workBucketKey`+    /// relates Works by (§2.4), and neither is authored, so a group whose rows+    /// *arrived* holding different ones keeps them — and because both keys anchor+    /// the same UUID in the union–find, that group permanently bridges two+    /// otherwise unrelated Work sets into one. Converging them is a derived-field+    /// write (Q44), but *which* value a converged group should hold is a design+    /// question where the rows came from two different taught states, so it is+    /// recorded rather than guessed. It fails safe: nothing authored is lost+    /// either way.+    private static func apply(_ carrier: Work, to rows: [Work]) -> Bool {+        var changed = false+        let carried = GroupOrdering.authoredContent(of: carrier)+        for row in rows where row !== carrier {+            if row.genericNotes != carrier.genericNotes {+                row.genericNotes = carrier.genericNotes+                changed = true+            }+            // Q34: a title is authored only when manually set *and* differing+            // from the last parsed one, which is what `authoredContent` reports.+            if carried.manualTitle != nil {+                if row.displayTitle != carrier.displayTitle {+                    row.displayTitle = carrier.displayTitle+                    changed = true+                }+                if row.titleProvenance != .manual {+                    row.titleProvenance = .manual+                    changed = true+                }+            }+            if let url = carrier.workURLString, row.workURLString != url {+                row.workURLString = url+                changed = true+            }+            // The carrier's own ordering, not the normalised one the comparison+            // key holds: genre tags are a reader-ordered list on the row.+            if !carrier.genreTags.isEmpty, row.genreTags != carrier.genreTags {+                row.genreTags = carrier.genreTags+                changed = true+            }+            if carrier.type != .other, row.type != carrier.type {+                row.type = carrier.type+                changed = true+            }+        }+        return changed+    }++    // MARK: - Classification bookkeeping++    private static func classify<Content>(+        _ set: DuplicateSet<Content>, into outcome: inout DuplicateReconciliationOutcome+    ) {+        switch set.classification {+        case .divergent: outcome.reviewSetKeys.append(set.key)+        case .deferred: outcome.blockedSetKeys.append(set.key)+        case .silentlyResolvable: break+        }+    }++    // MARK: - Commit chunking++    /// Accumulates a chunk's worth of sets and commits them together.+    ///+    /// A set never straddles a chunk — its writes and its deletion plan land in+    /// one commit or none — and the ledger and the deletion plans are only+    /// released **after** the save. A fingerprint recorded for writes that then+    /// rolled back would let a later pass delete against content it never+    /// committed, which is exactly what Req 2.1 forbids.+    private struct Chunk {+        let batchSize: Int+        private var records = 0+        private var fingerprints: [(DuplicateSetKey, SetFingerprint)] = []+        private var deletions: [DuplicateDeletionPlan] = []+        private var settling: [DuplicateSetKey] = []++        init(batchSize: Int) { self.batchSize = batchSize }++        mutating func add(records count: Int) { records += count }++        mutating func note(+            key: DuplicateSetKey, fingerprint: SetFingerprint,+            deletion: DuplicateDeletionPlan?, settling isSettling: Bool+        ) {+            fingerprints.append((key, fingerprint))+            if let deletion { deletions.append(deletion) }+            if isSettling { settling.append(key) }+        }++        mutating func commitIfFull(+            context: ModelContext, saveStrategy: any RepositorySaveStrategy,+            ledger: inout DuplicateSettlingLedger, into result: inout PassResult+        ) throws {+            guard batchSize > 0, records >= batchSize else { return }+            try commit(+                context: context, saveStrategy: saveStrategy, ledger: &ledger, into: &result)+        }++        mutating func commit(+            context: ModelContext, saveStrategy: any RepositorySaveStrategy,+            ledger: inout DuplicateSettlingLedger, into result: inout PassResult+        ) throws {+            guard !fingerprints.isEmpty else { return }+            if context.hasChanges { try saveStrategy.save(context) }+            for (key, fingerprint) in fingerprints { ledger.record(key, fingerprint: fingerprint) }+            result.deletions += deletions+            result.outcome.settlingSetKeys += settling+            records = 0+            fingerprints.removeAll()+            deletions.removeAll()+            settling.removeAll()+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift Added +783 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swiftnew file mode 100644index 0000000..03beefb--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift@@ -0,0 +1,783 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Task 16: Requirement 4, and Req 5.4's Work half.+///+/// The resolution is the one path by which a divergent set stops being+/// divergent, and every promise it makes is asserted here rather than inferred+/// from a rendered sheet: the reader chooses **content** and the surviving row+/// is recomputed at commit (Q21); the confirmation applies in one commit with+/// bare and agreeing members disposed of alongside the losers (Reqs 3.3, 4.4);+/// staleness is judged on variants alone (Req 4.6); and the record does not move+/// in Recent for having been resolved (Req 4.5).+@Suite("Duplicate resolution contract", .serialized)+struct DuplicateResolutionTests {++    // MARK: - Projection (Req 4.2)++    @Test("The sheet presents every variant, the fields they differ in, and preselects the leading one")+    func projectionPresentsEveryVariant() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(key: "one", title: "A", offset: 0, note: "earlier", rating: .up)+            store.insertEntry(key: "one", title: "A", offset: 40, note: "later")+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)++        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        guard case .entry(_, let variants, let fields, let preselected) = contract else {+            Issue.record("expected an Entry contract")+            return+        }+        #expect(variants.count == 2)+        // Q42: earliest carrying-row capture date first, so the leading variant+        // is the one captured first and it is what the sheet preselects.+        #expect(variants.first?.note == "earlier")+        #expect(variants.first?.rating == .up)+        #expect(preselected == variants[0].id)+        // Req 4.2 names note and rating explicitly; nothing else differs here.+        #expect(fields == [.note, .rating])+        #expect(contract.noteAppendIsOptional)+    }++    @Test("A silently resolvable set has no sheet to present")+    func silentlyResolvableSetHasNoContract() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(key: "one", title: "A", offset: 0, note: "only")+            store.insertEntry(key: "one", title: "A", offset: 40)+        }+        let repository = try await library.openForApp()+        let scan = try DuplicateScan.run(context: try library.readContext())+        let setKey = try #require(scan.entrySets.first?.key)++        await #expect(throws: LibraryRepositoryError.self) {+            _ = try await repository.projectDuplicateResolution(setKey: setKey)+        }+    }++    // MARK: - Commit (Reqs 3.3, 4.4, 4.5)++    /// Req 4.4 in one assertion: the survivor is the earliest-captured member+    /// whatever the reader chose, it holds the chosen content, and the losers+    /// are gone — all in one commit.+    @Test("Confirming writes the chosen content to the recomputed survivor and deletes the losers")+    func confirmationCollapsesTheSet() async throws {+        let library = try ResolutionFixture()+        var earliest = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            earliest = store.insertEntry(+                key: "one", title: "A", offset: 0, note: "earlier").id+            store.insertEntry(key: "one", title: "A", offset: 40, note: "later")+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)+        // The *later* variant, so the choice and the survivor rule disagree —+        // which is the whole of Q21.+        let chosen = try #require(contract.variantIDs.last)++        let outcome = try await repository.commitDuplicateResolution(+            contract, choosing: chosen, appendingOtherNotes: false)++        #expect(outcome == .committed(survivorID: earliest))+        let rows = try library.entryRows()+        #expect(rows.count == 1)+        #expect(rows.first?.id == earliest)+        #expect(rows.first?.note == "later")+    }++    /// Req 4.3: nothing the reader wrote has to be lost, and the divider is what+    /// makes the composition readable afterwards.+    @Test("The append option carries every non-chosen note under a divider")+    func appendOptionCarriesTheOtherNotes() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(key: "one", title: "A", offset: 0, note: "first")+            store.insertEntry(key: "one", title: "A", offset: 40, note: "second")+            store.insertEntry(key: "one", title: "A", offset: 80, note: "third")+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        _ = try await repository.commitDuplicateResolution(+            contract, choosing: try #require(contract.variantIDs.first),+            appendingOtherNotes: true)++        let note = try #require(try library.entryRows().first?.note)+        #expect(note.contains("first"))+        #expect(note.contains("second"))+        #expect(note.contains("third"))+        #expect(note.contains(DuplicateNoteAppendFormatter.divider))+        // Order is variant order, so two devices given the same choice compose+        // the same text.+        #expect(note.range(of: "second")!.lowerBound < note.range(of: "third")!.lowerBound)+    }++    /// Req 3.3: bare members do not enlarge the decision, and they are still+    /// disposed of by the confirmation rather than surviving it.+    @Test("A bare member is absent from the sheet and gone after the confirmation")+    func bareMembersAreDisposedOfWithoutEnlargingTheSheet() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(key: "one", title: "A", offset: 0, note: "first")+            store.insertEntry(key: "one", title: "A", offset: 40, note: "second")+            store.insertEntry(key: "one", title: "A", offset: 80)+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        #expect(contract.variantCount == 2)++        _ = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        #expect(try library.entryRows().count == 1)+    }++    /// Req 4.5: resolution is a curation edit, not reading activity. The record+    /// keeps the Recent position the set already had.+    @Test("Resolution stamps modifiedAt and leaves the position at the set's latest share")+    func resolutionIsPositionNeutral() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(key: "one", title: "A", offset: 0, note: "earlier")+            store.insertEntry(key: "one", title: "A", offset: 40, note: "later")+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        _ = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        let row = try #require(try library.entryRows().first)+        #expect(row.lastSharedAt == ResolutionFixture.epoch.addingTimeInterval(40))+        #expect(row.modifiedAt == MillisecondInstant.quantize(ResolutionFixture.now))+        #expect(row.firstCapturedAt == ResolutionFixture.epoch)+    }++    // MARK: - Staleness (Req 4.6)++    @Test("A variant arriving after the sheet refuses the confirmation and re-presents")+    func aNewVariantRefusesTheConfirmation() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(key: "one", title: "A", offset: 0, note: "first")+            store.insertEntry(key: "one", title: "A", offset: 40, note: "second")+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        try library.mutate { store in+            store.insertEntry(key: "one", title: "A", offset: 80, note: "third")+        }++        let outcome = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        guard case .refreshed(let fresh) = outcome else {+            Issue.record("expected a refreshed contract, got \(String(describing: outcome))")+            return+        }+        #expect(fresh.variantCount == 3)+        // Nothing was written: the set is whole and still divergent.+        #expect(try library.entryRows().count == 3)+    }++    /// Q20/Q27: bare and agreeing arrivals move the membership and not the+    /// decision, so refusing for them would be friction with no protection.+    @Test("A bare arrival after the sheet does not refuse the confirmation")+    func aBareArrivalDoesNotRefuse() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(key: "one", title: "A", offset: 0, note: "first")+            store.insertEntry(key: "one", title: "A", offset: 40, note: "second")+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        try library.mutate { store in+            store.insertEntry(key: "one", title: "A", offset: 80)+        }++        let outcome = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        #expect(+            outcome == .committed(survivorID: try #require(try library.entryRows().first?.id)))+        #expect(try library.entryRows().count == 1)+    }++    // MARK: - Work sets (Req 5.4)++    @Test("A Work resolution appends unconditionally, unions tags, and adopts a missing URL")+    func workResolutionMatchesMergeParity() async throws {+        let library = try ResolutionFixture()+        var survivorID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            survivorID = store.insertWork(+                title: "Serial", offset: 0, notes: "kept notes", tags: ["a"]).id+            store.insertWork(+                title: "Serial", offset: 40, notes: "discarded notes", tags: ["b"],+                workURL: "https://dup.example/serial")+        }+        let repository = try await library.openForApp()+        let setKey = try Self.onlyWorkSetKey(library)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        guard case .work(_, let variants, _, _) = contract else {+            Issue.record("expected a Work contract")+            return+        }+        #expect(variants.count == 2)+        // Req 5.4 makes the append mandatory rather than the reader's option.+        #expect(!contract.noteAppendIsOptional)++        let outcome = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        #expect(outcome == .committed(survivorID: survivorID))+        let rows = try library.workRows()+        #expect(rows.count == 1)+        let survivor = try #require(rows.first)+        // The audit block lands even though the caller asked for no append.+        #expect(survivor.genericNotes.contains("kept notes"))+        #expect(survivor.genericNotes.contains("discarded notes"))+        #expect(survivor.genericNotes.contains("Merged from"))+        // Tag union, and the URL adopted from the variant that had one.+        #expect(survivor.genreTags == ["a", "b"])+        #expect(survivor.workURLString == "https://dup.example/serial")+    }++    /// Req 5.2: no Entry becomes unattached through a Work resolution.+    @Test("A Work resolution moves the losing Work's Entries to the survivor")+    func workResolutionMovesEntries() async throws {+        let library = try ResolutionFixture()+        var movedID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let survivor = store.insertWork(title: "Serial", offset: 0, notes: "kept")+            let loser = store.insertWork(title: "Serial", offset: 40, notes: "lost")+            store.insertEntry(key: "a", title: "One", offset: 0, work: survivor)+            movedID = store.insertEntry(key: "b", title: "Two", offset: 5, work: loser).id+        }+        let repository = try await library.openForApp()+        let setKey = try Self.onlyWorkSetKey(library)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        _ = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        let works = try library.workRows()+        #expect(works.count == 1)+        let moved = try #require(try library.entryRows().first { $0.id == movedID })+        #expect(moved.workID == works.first?.id)+        // Req 5.2: the move is not an edit to the Entry.+        #expect(moved.firstCapturedAt == ResolutionFixture.epoch.addingTimeInterval(5))+    }++    /// Req 5.4's "type follows the chosen variant".+    @Test("A Work resolution takes the chosen variant's type")+    func workResolutionTakesTheChosenType() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertWork(title: "Serial", offset: 0, notes: "first", type: .novel)+            store.insertWork(title: "Serial", offset: 40, notes: "second", type: .toon)+        }+        let repository = try await library.openForApp()+        let setKey = try Self.onlyWorkSetKey(library)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)+        let chosen = try #require(contract.variantIDs.last)++        _ = try await repository.commitDuplicateResolution(+            contract, choosing: chosen, appendingOtherNotes: false)++        let survivor = try #require(try library.workRows().first)+        #expect(survivor.type == .toon)+    }++    // MARK: - WorkVariantUnion (Q51)++    /// The extraction has to leave Merge's behaviour where it was, and the arms+    /// that are easiest to get wrong are the URL ones.+    @Test("The union adopts a missing URL, keeps a present one, and records the discard")+    func unionURLArms() {+        let withURL = WorkVariantSide(+            displayTitle: "T", titleProvenance: .parsed, workURLString: "https://a.example",+            genericNotes: "", genreTags: [], type: .other)+        let withOther = WorkVariantSide(+            displayTitle: "T", titleProvenance: .parsed, workURLString: "https://b.example",+            genericNotes: "", genreTags: [], type: .other)+        let bare = WorkVariantSide(+            displayTitle: "T", titleProvenance: .parsed, workURLString: nil,+            genericNotes: "", genreTags: [], type: .other)++        let adopted = WorkVariantUnion.fold(into: bare, others: [withURL])+        #expect(adopted.workURL == "https://a.example")+        #expect(adopted.retainedFields.contains(.sourceWorkURL))+        #expect(adopted.discardedFields.isEmpty)++        let kept = WorkVariantUnion.fold(into: withURL, others: [withOther])+        #expect(kept.workURL == "https://a.example")+        #expect(kept.discardedFields.contains(.sourceWorkURL))+        #expect(kept.auditBlock?.contains("https://b.example") == true)++        let nothing = WorkVariantUnion.fold(into: bare, others: [bare])+        #expect(nothing.workURL == nil)+        #expect(nothing.auditBlocks.isEmpty)+    }++    @Test("The union folds three sides, keeping the chosen side's tag order")+    func unionFoldsManySides() {+        let chosen = WorkVariantSide(+            displayTitle: "T", titleProvenance: .parsed, workURLString: nil,+            genericNotes: "chosen", genreTags: ["z", "a"], type: .other)+        let second = WorkVariantSide(+            displayTitle: "T", titleProvenance: .parsed, workURLString: nil,+            genericNotes: "second", genreTags: ["a", "m"], type: .other)+        let third = WorkVariantSide(+            displayTitle: "T", titleProvenance: .parsed, workURLString: nil,+            genericNotes: "third", genreTags: ["q"], type: .other)++        let union = WorkVariantUnion.fold(into: chosen, others: [second, third])++        #expect(union.genreTags == ["z", "a", "m", "q"])+        #expect(union.auditBlocks.count == 2)+        #expect(union.genericNotes.hasPrefix("chosen"))+        #expect(union.genericNotes.contains("second"))+        #expect(union.genericNotes.contains("third"))+    }++    // MARK: - The assignment normalisation, one spelling (Q38)++    /// The map the read surfaces derive from rows in hand has to be the map the+    /// scan derives, or a group reads torn on one screen and whole on another —+    /// which is what Req 3.2 forbids in as many words.+    @Test("The Work-row map and the scan's map agree")+    func canonicalWorkIDsAgreeAcrossItsTwoFeeds() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertWork(title: "Serial", offset: 0, notes: "one")+            let second = store.insertWork(title: "Serial", offset: 40, notes: "two")+            store.insertWork(title: "Unrelated", offset: 80)+            store.insertEntry(key: "a", title: "A", offset: 0, work: first)+            store.insertEntry(key: "b", title: "B", offset: 40, work: second)+        }+        let context = try library.readContext()++        let fromRows = DuplicateScan.canonicalWorkIDs(+            ofWorkRows: try context.fetch(FetchDescriptor<Work>()))+        let fromScan = DuplicateScan.canonicalWorkIDs(+            try DuplicateScan.run(context: context).workSets)++        #expect(fromRows == fromScan)+        // The two members of the one set map to the same survivor; the+        // unrelated Work is in no set and so in neither map.+        #expect(Set(fromRows.values).count == 1)+        #expect(fromRows.count == 2)+    }++    // MARK: - What the lookup found (Q99, and the two arms it collapsed)++    /// The happy ending: the reader's other device resolved it, or the+    /// reconciler collapsed it, and nothing of the set is left.+    @Test("A set whose members have all gone invalidates as gone")+    func aVanishedSetInvalidatesAsGone() async throws {+        let library = try ResolutionFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(key: "one", title: "A", offset: 0, note: "first")+            store.insertEntry(key: "one", title: "A", offset: 40, note: "second")+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        try library.mutate { store in+            for row in try store.context.fetch(FetchDescriptor<Entry>()) {+                store.context.delete(row)+            }+        }++        let outcome = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        #expect(outcome == .invalidated(reason: LibraryRepository.setGoneReason))+    }++    /// A set that **split** is not a set that stopped existing, and saying so+    /// would be a false sentence about copies the reader can still see.+    @Test("A set that has separated into two says so, rather than claiming it is gone")+    func aSplitSetSaysItSeparated() async throws {+        let library = try ResolutionFixture()+        var second = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(key: "one", title: "A", offset: 0, note: "first")+            second = store.insertEntry(key: "one", title: "A", offset: 40, note: "second").id+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        // The second member is re-keyed — a re-parse or a late-taught rule+        // backfilling its conservative key — and both halves find new company.+        try library.mutate { store in+            let moved = try store.context.fetch(FetchDescriptor<Entry>())+                .first { $0.id == second }+            moved?.conservativeIdentityKey = "https://dup.example/read/two"+            store.insertEntry(key: "one", title: "A", offset: 80, note: "third")+            store.insertEntry(key: "two", title: "B", offset: 120, note: "fourth")+        }++        let outcome = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        #expect(outcome == .invalidated(reason: LibraryRepository.setSplitReason))+        // Nothing was written: two decisions, and the reader has made neither.+        #expect(try library.entryRows().count == 4)+    }++    /// Partial overlap is one decision still, so it re-presents rather than+    /// refusing outright — the variant compare is what judges it, and here the+    /// set the members landed in is one the reader was never shown.+    @Test("A partial overlap onto an unseen set re-presents rather than invalidating")+    func aPartialOverlapRepresents() async throws {+        let library = try ResolutionFixture()+        var first = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            first = store.insertEntry(key: "one", title: "A", offset: 0, note: "first").id+            store.insertEntry(key: "one", title: "A", offset: 40, note: "second")+        }+        let repository = try await library.openForApp()+        let setKey = try await Self.onlyEntrySetKey(repository)+        let contract = try await repository.projectDuplicateResolution(setKey: setKey)++        try library.mutate { store in+            // One member leaves the library entirely; the other keeps company+            // with an arrival carrying a variant the sheet never showed.+            let gone = try store.context.fetch(FetchDescriptor<Entry>())+                .first { $0.id == first }+            if let gone { store.context.delete(gone) }+            store.insertEntry(key: "one", title: "A", offset: 80, note: "third")+        }++        let outcome = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        guard case .refreshed(let fresh) = outcome else {+            Issue.record("expected a refreshed contract, got \(String(describing: outcome))")+            return+        }+        #expect(fresh.variantCount == 2)+        #expect(try library.entryRows().count == 2)+    }++    // MARK: - Helpers++    private static func onlyEntrySetKey(_ repository: LibraryRepository) async throws+        -> DuplicateSetKey+    {+        let workload = try await repository.recentPresentation(calendar: .current)+            .duplicateWorkload+        return try #require(workload.reviewItems.first { $0.recordType == .entry }?.key)+    }++    private static func onlyWorkSetKey(_ library: ResolutionFixture) throws -> DuplicateSetKey {+        let scan = try DuplicateScan.run(context: try library.readContext())+        return try #require(scan.workSets.first(where: { $0.classification == .divergent })?.key)+    }+}++// MARK: - Fixture++private final class ResolutionFixture {+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)+    static let hostname = "dup.example"++    let directory: URL+    let configuration: LibraryConfiguration+    private var containers: [ModelContainer] = []++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismDuplicateResolution-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+        try FileManager.default.createDirectory(+            at: configuration.v4StoreURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)+    }++    func seed(_ body: (ResolutionSeedStore) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let store = ResolutionSeedStore(context: ModelContext(container))+        try body(store)+        try store.context.save()+        try V5RelationshipPass.run(context: store.context)+        withExtendedLifetime(container) {}+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)+    }++    /// A write against the same store *behind* the open repository — the arrival+    /// a mid-sheet sync would land.+    func mutate(_ body: (ResolutionSeedStore) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let store = ResolutionSeedStore(context: ModelContext(container))+        try body(store)+        try store.context.save()+        try V5RelationshipPass.run(context: store.context)+        withExtendedLifetime(container) {}+    }++    func readContext() throws -> ModelContext {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        containers.append(container)+        return ModelContext(container)+    }++    func entryRows() throws -> [EntryFacts] {+        try readContext().fetch(FetchDescriptor<Entry>())+            .map(EntryFacts.init)+            .sorted { $0.firstCapturedAt < $1.firstCapturedAt }+    }++    func workRows() throws -> [WorkFacts] {+        try readContext().fetch(FetchDescriptor<Work>())+            .map(WorkFacts.init)+            .sorted { $0.createdAt < $1.createdAt }+    }++    /// **The clock is offset from the seed epoch on purpose.** Every seeded row+    /// carries `epoch + <offset>`, so a repository clock reading `epoch` made+    /// `modifiedAt == quantize(epoch)` a value the fixture already held —+    /// Req 4.5's assertion passed whether or not the resolution stamped+    /// anything. `now` is a moment no seeded row can be holding.+    static let now = ResolutionFixture.epoch.addingTimeInterval(9_000)++    func openForApp() async throws -> LibraryRepository {+        let (_, repository) = try await LibraryRepository.openV4ForApp(+            configuration, capabilities: .m4,+            clock: FixedRepositoryClock(Self.now),+            saveStrategy: ModelContextSaveStrategy())+        return repository+    }++    deinit {+        try? FileManager.default.removeItem(at: directory)+    }+}++private final class ResolutionSeedStore {+    let context: ModelContext++    init(context: ModelContext) { self.context = context }++    @discardableResult+    func insertSite(hostname: String) -> Site {+        let site = Site(hostname: hostname)+        site.mode = .untaught+        context.insert(site)+        return site+    }++    @discardableResult+    func insertEntry(+        id: UUID = UUID(), key: String, title: String, offset: TimeInterval,+        note: String = "", rating: Rating? = nil, work: Work? = nil+    ) -> Entry {+        let rawURL = "https://\(ResolutionFixture.hostname)/read/\(key)"+        let entry = Entry(+            id: id, captureTitle: title, captureTitleSource: .host, rawURLString: rawURL,+            hostname: ResolutionFixture.hostname, entryIdentityKey: rawURL,+            timestamp: ResolutionFixture.epoch.addingTimeInterval(offset),+            note: note, rating: rating)+        entry.conservativeIdentityKey = rawURL+        entry.lastSharedAt = ResolutionFixture.epoch.addingTimeInterval(offset)+        entry.modifiedAt = ResolutionFixture.epoch.addingTimeInterval(offset)+        context.insert(entry)+        if let work {+            entry.work = work+            entry.workAssignmentProvenance = .manual+        }+        return entry+    }++    @discardableResult+    func insertWork(+        id: UUID = UUID(), title: String, offset: TimeInterval, notes: String = "",+        tags: [String] = [], workURL: String? = nil, type: WorkType = .other+    ) -> Work {+        let work = Work(+            id: id, displayTitle: title, siteHostname: ResolutionFixture.hostname,+            timestamp: ResolutionFixture.epoch.addingTimeInterval(offset))+        // Q75: a seeded Work carries a parsed title, so it reads as the parsed+        // Work the fixture means rather than as one holding an authored title.+        work.lastParsedTitle = title+        work.titleProvenance = .parsed+        work.genericNotes = notes+        work.genreTags = tags+        work.workURLString = workURL+        work.type = type+        work.modifiedAt = ResolutionFixture.epoch.addingTimeInterval(offset)+        context.insert(work)+        return work+    }+}++// MARK: - Assignment normalisation on the commit path++/// The carrier lookup has to compare under the **same** normalisation the scan+/// classified with (Q38): two Entries pointing at two members of one Work+/// duplicate set do not disagree about their assignment. Comparing an+/// un-normalised row against a normalised variant finds nothing, and the+/// resolution refuses a set the scan itself said was divergent on its *notes*.+@Suite("Duplicate resolution over normalised assignments", .serialized)+struct DuplicateResolutionAssignmentTests {++    @Test("A set whose members point at two Works of one Work set still resolves")+    func normalisedAssignmentsStillResolve() async throws {+        let library = try AssignmentFixture()+        try library.seed { store in+            store.insertSite()+            // One Work set: same site, same parsed title, both bare — silently+            // resolvable, so it blocks nothing (Decision 8).+            let first = store.insertWork(title: "Serial", offset: 0)+            let second = store.insertWork(title: "Serial", offset: 10)+            store.insertEntry(key: "one", offset: 0, note: "first", work: first)+            store.insertEntry(key: "one", offset: 40, note: "second", work: second)+        }+        let repository = try await library.openForApp()+        let scan = try DuplicateScan.run(context: try library.readContext())+        let setKey = try #require(+            scan.entrySets.first { $0.classification == .divergent }?.key)++        let contract = try await repository.projectDuplicateResolution(setKey: setKey)+        let outcome = try await repository.commitDuplicateResolution(+            contract, choosing: contract.preselected, appendingOtherNotes: false)++        guard case .committed = outcome else {+            Issue.record("expected a commit, got \(String(describing: outcome))")+            return+        }+        let entries = try library.entryRows()+        #expect(entries.count == 1)+        #expect(entries.first?.note == "first")+        // The survivor keeps a real assignment — the carrier's own pointer, not+        // the normalised equality key (Q67).+        #expect(entries.first?.workID != nil)+        #expect(entries.first?.workAssignmentProvenance == .manual)+    }+}++private final class AssignmentFixture {+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)+    static let hostname = "assign.example"++    let directory: URL+    let configuration: LibraryConfiguration+    private var containers: [ModelContainer] = []++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismDuplicateAssign-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+        try FileManager.default.createDirectory(+            at: configuration.v4StoreURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)+    }++    func seed(_ body: (AssignmentSeedStore) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let store = AssignmentSeedStore(context: ModelContext(container))+        try body(store)+        try store.context.save()+        try V5RelationshipPass.run(context: store.context)+        withExtendedLifetime(container) {}+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)+    }++    func readContext() throws -> ModelContext {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        containers.append(container)+        return ModelContext(container)+    }++    func entryRows() throws -> [EntryFacts] {+        try readContext().fetch(FetchDescriptor<Entry>()).map(EntryFacts.init)+    }++    func openForApp() async throws -> LibraryRepository {+        let (_, repository) = try await LibraryRepository.openV4ForApp(+            configuration, capabilities: .m4,+            clock: FixedRepositoryClock(Self.epoch),+            saveStrategy: ModelContextSaveStrategy())+        return repository+    }++    deinit { try? FileManager.default.removeItem(at: directory) }+}++private final class AssignmentSeedStore {+    let context: ModelContext++    init(context: ModelContext) { self.context = context }++    @discardableResult+    func insertSite() -> Site {+        let site = Site(hostname: AssignmentFixture.hostname)+        site.mode = .untaught+        context.insert(site)+        return site+    }++    @discardableResult+    func insertWork(title: String, offset: TimeInterval) -> Work {+        let work = Work(+            displayTitle: title, siteHostname: AssignmentFixture.hostname,+            timestamp: AssignmentFixture.epoch.addingTimeInterval(offset))+        work.lastParsedTitle = title+        work.titleProvenance = .parsed+        work.modifiedAt = AssignmentFixture.epoch.addingTimeInterval(offset)+        context.insert(work)+        return work+    }++    @discardableResult+    func insertEntry(key: String, offset: TimeInterval, note: String, work: Work) -> Entry {+        let rawURL = "https://\(AssignmentFixture.hostname)/read/\(key)"+        let entry = Entry(+            captureTitle: "Chapter", captureTitleSource: .host, rawURLString: rawURL,+            hostname: AssignmentFixture.hostname, entryIdentityKey: rawURL,+            timestamp: AssignmentFixture.epoch.addingTimeInterval(offset), note: note)+        entry.conservativeIdentityKey = rawURL+        entry.lastSharedAt = AssignmentFixture.epoch.addingTimeInterval(offset)+        context.insert(entry)+        entry.work = work+        entry.workAssignmentProvenance = .manual+        return entry+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift Added +776 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swiftnew file mode 100644index 0000000..cf66906--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift@@ -0,0 +1,776 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// The duplicate reconciler's write half: Reqs 6.1–6.2 (rule convergence),+/// 3.1–3.3 and 5.1–5.2 (silent resolution), and 2.1–2.3/2.9 (deletion safety).+///+/// What it must *not* do carries as much weight as what it must. It never reads+/// a clock, never writes a work assignment into outcome content, never deletes a+/// proper subset of an identity group, and never deletes anything at all on the+/// pass that first saw the set.+@Suite("Duplicate reconciliation", .serialized)+struct DuplicateReconcilerTests {++    // MARK: - Req 6.1: rule groups converge on one definition++    @Test("A rule group converges on the representative's whole definition, trims included")+    func ruleGroupConvergesIncludingTrims() throws {+        let store = try DuplicateStore()+        let site = store.addSite(mode: .taught)+        let ruleID = DuplicateStore.rankedID(1)+        // Two rows of one rule UUID: same arm, one carrying trims. Absence sorts+        // last in the canonical encoding, so the row that *sets* the trims is the+        // convergence target (Q68) — converging the other way would make every+        // citation replay a chapter title the group never produced.+        try store.addPattern(+            id: ruleID, site: site, version: 1, active: true, createdAt: 0,+            trimPrefix: "Read ", trimSuffix: " | Site")+        try store.addPattern(id: ruleID, site: site, version: 1, active: false, createdAt: 0)+        try store.commit()++        let outcome = try store.reconcile()++        #expect(outcome.convergedRuleRows == 1)+        let rows = try store.patternFacts()+        // Req 6.1: no row is deleted — a group converges, it never collapses.+        #expect(rows.count == 2)+        #expect(Set(rows.map(\.trimPrefix)) == ["Read "])+        #expect(Set(rows.map(\.trimSuffix)) == [" | Site"])+        #expect(Set(rows.map(\.canonicalDefinition)).count == 1)+    }++    @Test("A converged rule group is not written again")+    func ruleConvergenceIsIdempotent() throws {+        let store = try DuplicateStore()+        let site = store.addSite(mode: .taught)+        let ruleID = DuplicateStore.rankedID(1)+        try store.addPattern(+            id: ruleID, site: site, version: 2, active: true, trimPrefix: "Read ")+        try store.addPattern(id: ruleID, site: site, version: 5, active: false)+        try store.commit()++        _ = try store.reconcile()+        store.saveRecorder.resetCounts()+        let second = try store.reconcile()++        #expect(second.convergedRuleRows == 0)+        #expect(second.rewrittenCitations == 0)+        #expect(store.saveRecorder.attemptCount == 0, "a converged rule group was written again")+    }++    // MARK: - Req 6.2: citations keep resolving++    @Test("A citation naming a losing version is rewritten to the surviving one")+    func citationsFollowTheConvergedVersion() throws {+        let store = try DuplicateStore()+        let site = store.addSite(displayName: "first", mode: .taught)+        let twinRow = store.addSite(displayName: "second", mode: .taught)+        let ruleID = DuplicateStore.rankedID(1)+        // One rule UUID materialised onto two Site rows of one hostname — the+        // shape the Site phase declines, because rows sharing rule UUIDs tie in+        // its resolution order. Versions align across Site rows, never within+        // one, so this is where the alignment and its citation rewrite happen.+        try store.addPattern(id: ruleID, site: site, version: 3, active: true, createdAt: 0)+        try store.addPattern(id: ruleID, site: twinRow, version: 7, active: true, createdAt: 10)+        let entry = store.addEntry(key: "chapter-1", capturedAt: 0, site: site)+        entry.chapterTitle = "Chapter 1"+        entry.chapterTitleProvenance = .pattern+        entry.chapterPatternID = ruleID+        entry.chapterPatternVersion = 7+        try store.commit()++        let outcome = try store.reconcile()++        #expect(outcome.rewrittenCitations == 1)+        let facts = try #require(try store.entryFacts().first)+        #expect(facts.chapterPatternID == ruleID)+        #expect(facts.chapterPatternVersion == 3)+        // Every stored row now holds version 3, so the citation resolves.+        #expect(Set(try store.patternFacts().map(\.version)) == [3])+    }++    @Test("Convergence demotes a duplicate active flag and never activates a row")+    func convergenceDemotesRatherThanActivates() throws {+        let store = try DuplicateStore()+        let site = store.addSite(mode: .taught)+        let ruleID = DuplicateStore.rankedID(1)+        // Distinct versions, so the only thing wrong with the hostname is the+        // second active flag: the library validates once it is demoted.+        try store.addPattern(id: ruleID, site: site, version: 1, active: true, createdAt: 0)+        try store.addPattern(id: ruleID, site: site, version: 2, active: true, createdAt: 10)+        try store.commit()++        _ = try store.reconcile()++        let rows = try store.patternFacts()+        #expect(rows.count(where: \.isActive) == 1, "the two-active .siteTuple state survived")+        #expect(rows.map(\.version).sorted() == [1, 2], "versions collided on one Site row")+        // And the hostname now **validates**, which it did not when this test+        // was written. Q91 recorded the gap: the membership clause required a+        // Site row's pattern ids to be distinct, so two rows of one rule UUID+        // were `.siteTuple` before this pass and after it, and Req 6.2's "every+        // taught Site SHALL still hold a usable active rule set" was+        // unsatisfiable for the shape. Task 20 relaxed the clause to accept a+        // *converged* group as one rule — which is exactly what this pass has+        // just produced, and the reason the assertion flipped.+        #expect(try store.diagnose().tupleDiagnoses.isEmpty)+    }++    @Test("A group split across two taught rows keeps each row's own active slot")+    func convergenceNeverStrandsASecondTaughtRow() throws {+        let store = try DuplicateStore()+        let first = store.addSite(displayName: "first", mode: .taught)+        let second = store.addSite(displayName: "second", mode: .taught)+        let ruleID = DuplicateStore.rankedID(1)+        try store.addPattern(id: ruleID, site: first, version: 1, active: true, createdAt: 0)+        try store.addPattern(id: ruleID, site: second, version: 1, active: true, createdAt: 10)+        try store.commit()++        _ = try store.reconcile()++        // Each site keeps the active copy it had: demoting across sites would+        // leave a `.taught` row with no active title rule, which is illegal in+        // every mode.+        let rows = try store.patternFacts()+        #expect(rows.count(where: \.isActive) == 2)+        #expect(Set(rows.map(\.siteObjectID)).count == 2)+    }++    @Test("Version alignment never collides with another rule on the target Site row")+    func alignmentNeverManufacturesAVersionCollision() throws {+        let store = try DuplicateStore()+        let first = store.addSite(displayName: "first", mode: .taught)+        let second = store.addSite(displayName: "second", mode: .taught)+        let groupID = DuplicateStore.rankedID(2)+        let unrelatedID = DuplicateStore.rankedID(1)+        // The uniqueness the validator enforces is over *all* of a Site row's+        // patterns, not over this group's: every new rule version gets a new+        // UUID, so a Site row routinely holds several rules at several versions.+        // Here the first row holds an unrelated rule at version 3 and one row of+        // the group at 5, and the second row holds the group's other row at 3 and+        // represents it. Pulling the group's row onto 3 would put two of the+        // first row's patterns on one version — a `.siteTuple`, which quarantines+        // the hostname and takes teaching off capture.+        try store.addPattern(id: unrelatedID, site: first, version: 3, active: true, createdAt: 20)+        try store.addPattern(id: groupID, site: first, version: 5, active: false, createdAt: 20)+        try store.addPattern(id: groupID, site: second, version: 3, active: true, createdAt: 10)+        try store.commit()+        #expect(try store.diagnose().tupleDiagnoses.isEmpty, "the fixture did not start valid")++        _ = try store.reconcile()++        #expect(try store.diagnose().tupleDiagnoses.isEmpty, "convergence manufactured a .siteTuple")+        let versions = try store.patternFacts().filter { $0.id == groupID }.map(\.version).sorted()+        #expect(versions == [3, 5], "a row took a version its own Site row already held")+    }++    @Test("Version alignment never demotes a current URL rule below a retained one")+    func alignmentNeverDemotesTheCurrentURLRule() throws {+        let store = try DuplicateStore()+        let first = store.addSite(displayName: "first", mode: .taught)+        let second = store.addSite(displayName: "second", mode: .taught)+        try store.addPattern(+            id: DuplicateStore.rankedID(1), site: first, version: 1, active: true)+        try store.addPattern(+            id: DuplicateStore.rankedID(2), site: second, version: 1, active: true)+        let groupID = DuplicateStore.rankedID(4)+        // The validator's other Site-row clause: the current URL rule must hold+        // the greatest retained version (`V4LibraryValidator:523-525`). Aligning+        // the current row down to 3 would put it under the retained rule at 5.+        try store.addURLRule(+            id: DuplicateStore.rankedID(3), site: first, version: 5, current: false, createdAt: 20)+        try store.addURLRule(id: groupID, site: first, version: 7, current: true, createdAt: 20)+        try store.addURLRule(id: groupID, site: second, version: 3, current: false, createdAt: 10)+        try store.commit()+        #expect(try store.diagnose().tupleDiagnoses.isEmpty, "the fixture did not start valid")++        _ = try store.reconcile()++        #expect(try store.diagnose().tupleDiagnoses.isEmpty, "convergence manufactured a .siteTuple")+        let versions = try store.read { context in+            try context.fetch(FetchDescriptor<URLRulePattern>())+                .filter { $0.id == groupID }.map(\.version).sorted()+        }+        #expect(versions == [3, 7], "the current URL rule was demoted under a retained one")+    }++    @Test("A URL rule group converges on the representative's definition")+    func urlRuleGroupConverges() throws {+        let store = try DuplicateStore()+        let site = store.addSite(mode: .taught)+        let ruleID = DuplicateStore.rankedID(1)+        try store.addURLRule(+            id: ruleID, site: site, version: 1, current: true, createdAt: 0,+            definition: .work(locator: .query(name: ExactScalarString("series"))))+        try store.addURLRule(+            id: ruleID, site: site, version: 1, current: false, createdAt: 10,+            definition: .work(locator: .query(name: ExactScalarString("identity"))))+        try store.commit()++        let outcome = try store.reconcile()++        #expect(outcome.convergedRuleRows == 1)+        let definitions = try store.read { context in+            Set(try context.fetch(FetchDescriptor<URLRulePattern>())+                .map(GroupOrdering.canonicalDefinition))+        }+        #expect(definitions.count == 1)+        #expect(definitions.first?.contains("series") == true)+    }++    @Test("A Work citing a losing URL-rule version is re-pointed at the surviving one")+    func workCitationsFollowTheConvergedURLRuleVersion() throws {+        let store = try DuplicateStore()+        let site = store.addSite(displayName: "first", mode: .taught)+        let twinRow = store.addSite(displayName: "second", mode: .taught)+        let ruleID = DuplicateStore.rankedID(1)+        try store.addURLRule(id: ruleID, site: site, version: 3, current: true, createdAt: 0)+        try store.addURLRule(id: ruleID, site: twinRow, version: 7, current: true, createdAt: 10)+        let work = store.addWork(+            id: DuplicateStore.rankedID(2), title: "The Serial", urlIdentity: "series-a",+            createdAt: 0, site: site)+        work.urlIdentityRuleID = ruleID+        work.urlIdentityRuleVersion = 7+        try store.commit()++        let outcome = try store.reconcile()++        // The Work's citation is the one `SiteReconciler.rewriteCitations(of:)`+        // rewrites for Works, and it is the only citation on that side.+        #expect(outcome.rewrittenCitations == 1)+        let cited = try store.read { context in+            try context.fetch(FetchDescriptor<Work>()).map(\.urlIdentityRuleVersion)+        }+        #expect(cited == [3])+    }++    // MARK: - Req 3.1: silent Entry collapse++    @Test("A silently resolvable Entry set collapses onto the earliest capture")+    func entrySetCollapsesOntoTheEarliestCapture() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let survivorID = DuplicateStore.rankedID(9)+        let loserID = DuplicateStore.rankedID(1)+        // The survivor is the earliest capture, *not* the lowest UUID: the+        // tie-break only runs when the timestamps tie.+        store.addEntry(id: survivorID, key: "chapter-1", capturedAt: 0, sharedAt: 10, site: site)+        store.addEntry(+            id: loserID, key: "chapter-1", capturedAt: 100, sharedAt: 400,+            note: "the note", rating: .up, site: site)+        try store.commit()++        // Req 2.3: the first observation writes the content and defers the+        // deletion; the second one collapses.+        let first = try store.reconcile()+        #expect(first.collapsedMembers == 0)+        #expect(first.settlingSetKeys.count == 1)+        let second = try store.reconcile()+        #expect(second.collapsedMembers == 1)++        let entries = try store.entryFacts()+        #expect(entries.count == 1)+        let survivor = try #require(entries.first)+        #expect(survivor.id == survivorID)+        #expect(survivor.note == "the note")+        #expect(survivor.rating == .up)+        // firstCapturedAt is never written; lastSharedAt rises to the set's+        // latest (Q37).+        #expect(survivor.firstCapturedAt == DuplicateStore.epoch)+        #expect(survivor.lastSharedAt == DuplicateStore.epoch.addingTimeInterval(400))+        #expect(survivor.modifiedAt == DuplicateStore.epoch.addingTimeInterval(100))+    }++    @Test("Nothing on the silent path reads a clock")+    func theSilentPathReadsNoClock() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        store.addEntry(key: "chapter-1", capturedAt: 0, site: site)+        store.addEntry(key: "chapter-1", capturedAt: 100, note: "note", site: site)+        try store.commit()+        try store.reconcileToFixedPoint()++        // Every timestamp the pass could have written is a value the set already+        // held. A clock read would land somewhere near now, decades later.+        let latest = DuplicateStore.epoch.addingTimeInterval(1_000)+        for entry in try store.entryFacts() {+            #expect(entry.lastSharedAt <= latest)+            #expect(entry.modifiedAt <= latest)+            #expect(entry.firstCapturedAt <= latest)+        }+    }++    // MARK: - Req 2.2/3.2: identity groups converge, and are never split++    @Test("An identity group converges in place and no row of it is deleted")+    func identityGroupConvergesAndIsNeverSplit() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let shared = DuplicateStore.rankedID(1)+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 0, site: site)+        store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 0, sharedAt: 50, note: "kept", site: site)+        try store.commit()++        try store.reconcileToFixedPoint()++        let entries = try store.entryFacts()+        #expect(entries.count == 2, "a proper subset of an identity group was deleted")+        #expect(entries.allSatisfy { $0.note == "kept" })+        #expect(entries.allSatisfy { $0.lastSharedAt == DuplicateStore.epoch.addingTimeInterval(50) })+        // Q74: the rows must not differ in modifiedAt — it is the last slot of+        // the representative ordering.+        #expect(Set(entries.map(\.modifiedAt)).count == 1)+    }++    /// Q23's exact refinement of Decision 4, and the one shape where convergence+    /// and collapse meet: the losing *member* is itself a split group, so it is+    /// deleted whole rather than converged, and the surviving group converges+    /// rather than being collapsed onto one row.+    @Test("A losing member that is itself a split group is deleted whole")+    func aLosingSplitGroupIsDeletedWhole() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let survivorID = DuplicateStore.rankedID(1)+        let loserID = DuplicateStore.rankedID(2)+        store.addEntry(id: survivorID, key: "chapter-1", capturedAt: 0, site: site)+        store.addEntry(id: survivorID, key: "chapter-1", capturedAt: 0, sharedAt: 5, site: site)+        store.addEntry(+            id: loserID, key: "chapter-1", capturedAt: 100, note: "the note", site: site)+        store.addEntry(+            id: loserID, key: "chapter-1", capturedAt: 100, sharedAt: 150, note: "the note",+            site: site)+        try store.commit()++        try store.reconcileToFixedPoint()++        let entries = try store.entryFacts()+        #expect(entries.count == 2, "the losing group was split rather than deleted whole")+        #expect(entries.allSatisfy { $0.id == survivorID }, "the wrong group survived")+        // Req 2.1: the note reached every row of the survivor before its twin+        // group went.+        #expect(entries.allSatisfy { $0.note == "the note" })+    }++    @Test("A manual chapter title converges with its provenance, not without it")+    func manualChapterTitleCarriesItsProvenance() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let shared = DuplicateStore.rankedID(1)+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 0, site: site)+        let carrier = store.addEntry(id: shared, key: "chapter-1", capturedAt: 0, site: site)+        carrier.chapterTitle = "The Reader's Title"+        carrier.chapterTitleProvenance = .manual+        try store.commit()++        try store.reconcileToFixedPoint()++        let entries = try store.entryFacts()+        #expect(entries.count == 2)+        #expect(entries.allSatisfy { $0.chapterTitle == "The Reader's Title" })+        #expect(entries.allSatisfy { $0.chapterTitleProvenance == .manual })+    }++    // MARK: - Q67: outcome content never writes an assignment++    @Test("Outcome content never writes a work assignment")+    func outcomeContentNeverWritesAnAssignment() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let survivorWork = store.addWork(+            title: "Survivor Work", urlIdentity: "series-a", createdAt: 0, site: site)+        let otherWork = store.addWork(+            title: "Other Work", urlIdentity: "series-b", createdAt: 0, site: site)+        let shared = DuplicateStore.rankedID(1)+        let bare = store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 0, work: survivorWork, site: site)+        bare.workAssignmentProvenance = .pattern+        let authored = store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 0, note: "note", work: otherWork, site: site)+        authored.workAssignmentProvenance = .manual+        try store.commit()++        try store.reconcileToFixedPoint()++        // The note converged; the physical assignments did not move. The+        // normalised assignment is an equality key, and the two Works are not+        // one set, so the group is torn on the assignment and only the rows'+        // own pointers stand.+        let entries = try store.entryFacts()+        #expect(entries.count == 2)+        #expect(Set(entries.compactMap(\.workID)) == [survivorWork.id, otherWork.id])+    }++    // MARK: - Req 5.1/5.2: Work sets++    @Test("A Work collapse moves every Entry to the survivor, unchanged")+    func workCollapseMovesEntriesUnchanged() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let survivorID = DuplicateStore.rankedID(9)+        let loserID = DuplicateStore.rankedID(1)+        let survivor = store.addWork(+            id: survivorID, title: "The Serial", urlIdentity: "series-a", createdAt: 0, site: site)+        let loser = store.addWork(+            id: loserID, title: "The Serial", urlIdentity: "series-a", createdAt: 100,+            notes: "reader notes", site: site)+        let moved = store.addEntry(+            key: "chapter-2", capturedAt: 5, sharedAt: 7, note: "chapter note", rating: .down,+            work: loser, site: site)+        moved.workAssignmentProvenance = FieldProvenanceKind.manual+        store.addEntry(key: "chapter-1", capturedAt: 1, work: survivor, site: site)+        try store.commit()++        try store.reconcileToFixedPoint()++        let works = try store.workFacts()+        #expect(works.count == 1)+        let kept = try #require(works.first)+        #expect(kept.id == survivorID)+        #expect(kept.genericNotes == "reader notes")+        #expect(kept.entryIDs.count == 2)++        let entry = try #require(try store.entryFacts().first { $0.note == "chapter note" })+        #expect(entry.workID == survivorID)+        #expect(entry.rating == .down)+        #expect(entry.workAssignmentProvenance == .manual)+        // Req 5.2: nothing about the Entry itself moves.+        #expect(entry.firstCapturedAt == DuplicateStore.epoch.addingTimeInterval(5))+        #expect(entry.lastSharedAt == DuplicateStore.epoch.addingTimeInterval(7))+        #expect(entry.modifiedAt == DuplicateStore.epoch.addingTimeInterval(5))+    }++    @Test("No Entry becomes unattached through a Work collapse")+    func noEntryIsUnattachedByAWorkCollapse() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let survivor = store.addWork(+            id: DuplicateStore.rankedID(9), title: "The Serial", urlIdentity: "series-a",+            createdAt: 0, site: site)+        let loser = store.addWork(+            id: DuplicateStore.rankedID(1), title: "The Serial", urlIdentity: "series-a",+            createdAt: 100, site: site)+        for index in 0..<4 {+            store.addEntry(+                key: "chapter-\(index)", capturedAt: TimeInterval(index),+                work: index.isMultiple(of: 2) ? survivor : loser, site: site)+        }+        try store.commit()++        try store.reconcileToFixedPoint()++        let entries = try store.entryFacts()+        #expect(entries.count == 4)+        #expect(entries.allSatisfy { $0.workID == survivor.id })+    }++    /// Req 2.3 on the Work side. Only the Entry path asserted it, and the two+    /// phases keep their own ledger bookkeeping.+    @Test("A Work set defers its deletion until a second pass observes it unchanged")+    func workSetsSettleBeforeTheyCollapse() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let survivorID = DuplicateStore.rankedID(1)+        let survivor = store.addWork(+            id: survivorID, title: "The Serial", urlIdentity: "series-a", createdAt: 0, site: site)+        let loser = store.addWork(+            id: DuplicateStore.rankedID(2), title: "The Serial", urlIdentity: "series-a",+            createdAt: 100, notes: "reader notes", site: site)+        store.addEntry(key: "chapter-1", capturedAt: 0, work: survivor, site: site)+        store.addEntry(key: "chapter-2", capturedAt: 5, work: loser, site: site)+        try store.commit()++        let first = try store.reconcile()++        #expect(first.collapsedMembers == 0, "a Work set collapsed on the pass that first saw it")+        #expect(first.settlingSetKeys.contains { $0.recordType == .work })+        #expect(try store.workFacts().count == 2)+        // Req 2.1: the outcome content and the entry moves committed on the pass+        // that deferred the deletion, not on the one that performs it.+        let deferred = try #require(try store.workFacts().first { $0.id == survivorID })+        #expect(deferred.genericNotes == "reader notes")+        #expect(deferred.entryIDs.count == 2)++        let second = try store.reconcile()+        #expect(second.collapsedMembers == 1)+        #expect(try store.workFacts().count == 1)+    }++    // MARK: - Req 1.5/1.6: ordering and deferral++    @Test("An Entry set spanning a divergent Work set defers and never latches the follow-up")+    func spanningSetsDeferWithoutLatching() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        // A divergent Work set: two Works of one identity, both authored, and+        // disagreeing — so it has no determined survivor (Decision 8).+        let workA = store.addWork(+            id: DuplicateStore.rankedID(1), title: "The Serial", urlIdentity: "series-a",+            createdAt: 0, notes: "notes from A", site: site)+        let workB = store.addWork(+            id: DuplicateStore.rankedID(2), title: "The Serial", urlIdentity: "series-a",+            createdAt: 10, notes: "notes from B", site: site)+        store.addEntry(+            id: DuplicateStore.rankedID(3), key: "chapter-1", capturedAt: 0, work: workA, site: site)+        store.addEntry(+            id: DuplicateStore.rankedID(4), key: "chapter-1", capturedAt: 5, work: workB, site: site)+        try store.commit()++        let outcome = try store.reconcile()++        #expect(outcome.blockedSetKeys.count == 1)+        #expect(outcome.settlingSetKeys.isEmpty, "a Req 1.6 blockage latched the follow-up")+        #expect(outcome.reviewSetKeys.count == 1, "the blocking Work set awaits the reader")+        // The deferred Entry set is untouched: both members are still there.+        #expect(try store.entryFacts().count == 2)+    }++    @Test("A divergent Entry set is left exactly as it was")+    func divergentSetsAreUntouched() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        store.addEntry(+            id: DuplicateStore.rankedID(1), key: "chapter-1", capturedAt: 0, note: "device A",+            site: site)+        store.addEntry(+            id: DuplicateStore.rankedID(2), key: "chapter-1", capturedAt: 10, note: "device B",+            site: site)+        try store.commit()+        let before = try store.entryFacts()++        let outcome = try store.reconcile()+        _ = try store.reconcile()++        #expect(outcome.reviewSetKeys.count == 1)+        #expect(outcome.contentWrites == 0)+        #expect(try store.entryFacts() == before)+    }++    // MARK: - Req 2.4: the fixed point dirties nothing++    @Test("A library at its reconciled fixed point is written no further")+    func theFixedPointDirtiesNothing() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let shared = DuplicateStore.rankedID(1)+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 0, note: "kept", site: site)+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 0, site: site)+        store.addEntry(id: DuplicateStore.rankedID(2), key: "chapter-2", capturedAt: 0, site: site)+        store.addEntry(id: DuplicateStore.rankedID(3), key: "chapter-2", capturedAt: 20, site: site)+        try store.commit()++        try store.reconcileToFixedPoint()+        store.saveRecorder.resetCounts()+        let settled = try store.reconcile()++        #expect(settled.isEmpty)+        #expect(store.saveRecorder.attemptCount == 0, "a settled library was saved again")+    }++    // MARK: - Req 2.3: settling++    @Test("A set that changed since the last pass defers again")+    func aChangedSetDefersAgain() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        store.addEntry(+            id: DuplicateStore.rankedID(1), key: "chapter-1", capturedAt: 0, site: site)+        store.addEntry(+            id: DuplicateStore.rankedID(2), key: "chapter-1", capturedAt: 10, site: site)+        try store.commit()++        _ = try store.reconcile()+        // A third member arrives, which is a new set key and so a first+        // observation all over again.+        store.addEntry(+            id: DuplicateStore.rankedID(3), key: "chapter-1", capturedAt: 20, site: site)+        try store.commit()++        let second = try store.reconcile()+        #expect(second.collapsedMembers == 0)+        #expect(second.settlingSetKeys.count == 1)+        #expect(try store.entryFacts().count == 3)++        let third = try store.reconcile()+        #expect(third.collapsedMembers == 2)+        #expect(try store.entryFacts().count == 1)+    }++    // MARK: - Req 2.9: the deleting commit re-verifies++    @Test("An arrival between derivation and deletion aborts the deleting commit")+    func aMidPassArrivalAbortsTheDeletion() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let loserID = DuplicateStore.rankedID(2)+        store.addEntry(+            id: DuplicateStore.rankedID(1), key: "chapter-1", capturedAt: 0, site: site)+        store.addEntry(id: loserID, key: "chapter-1", capturedAt: 10, site: site)+        try store.commit()+        _ = try store.reconcile()++        // The set qualified on this pass; a re-share of the doomed row lands+        // between the derivation and the deleting transaction.+        let outcome = try store.reconcile { context in+            let descriptor = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == loserID })+            for row in try context.fetch(descriptor) {+                row.lastSharedAt = DuplicateStore.epoch.addingTimeInterval(9_000)+            }+        }++        #expect(outcome.collapsedMembers == 0, "a changed set was deleted anyway")+        #expect(outcome.settlingSetKeys.contains(where: { $0.recordType == .entry }))+        #expect(try store.entryFacts().count == 2)+    }++    @Test("The outcome content is committed before anything is deleted")+    func contentIsWrittenBeforeTheDeletion() throws {+        // The deleting commit is made to fail. What the store holds afterwards+        // is the state Req 2.1 promises: the survivor already carries the note,+        // and the row that was going to be deleted is still there.+        let strategy = FailFromNthSaveStrategy(failFrom: 2)+        let store = try DuplicateStore(saveStrategy: strategy)+        let site = store.addSite()+        let survivorID = DuplicateStore.rankedID(1)+        store.addEntry(id: survivorID, key: "chapter-1", capturedAt: 0, site: site)+        store.addEntry(+            id: DuplicateStore.rankedID(2), key: "chapter-1", capturedAt: 10, note: "the note",+            site: site)+        try store.commit()++        _ = try store.reconcile()+        let second = try store.reconcile()++        #expect(second.collapsedMembers == 0)+        #expect(second.settlingSetKeys.count == 1, "an aborted deletion did not re-arm")+        let entries = try store.entryFacts()+        #expect(entries.count == 2)+        #expect(entries.first { $0.id == survivorID }?.note == "the note")+    }++    /// Decision 12's stated positive consequence, which nothing tested: the+    /// ledger and the deletion plans are released only *after* the chunk's save,+    /// so a chunk whose **write** save failed leaves neither. The existing+    /// abort test fails the *deleting* save, which is the other half.+    @Test("A chunk whose write save fails leaves neither a ledger entry nor a plan")+    func aFailedWriteSaveSettlesNothing() throws {+        let strategy = ArmableFailingSaveStrategy()+        strategy.failFrom = 1+        let store = try DuplicateStore(saveStrategy: strategy)+        let site = store.addSite()+        let survivorID = DuplicateStore.rankedID(1)+        store.addEntry(id: survivorID, key: "chapter-1", capturedAt: 0, site: site)+        store.addEntry(+            id: DuplicateStore.rankedID(2), key: "chapter-1", capturedAt: 10, note: "the note",+            site: site)+        try store.commit()++        #expect(throws: (any Error).self) { try store.reconcile() }++        #expect(store.ledger.observedSetCount == 0, "a rolled-back chunk left a fingerprint")+        let interrupted = try store.entryFacts()+        #expect(interrupted.count == 2)+        #expect(+            interrupted.first { $0.id == survivorID }?.note == "",+            "an uncommitted write reached the store")++        // With saves working again the set starts over: the next pass is a first+        // observation, and only the one after it may delete. A ledger entry left+        // behind by the failed chunk would have let this pass delete against+        // content that never committed.+        strategy.failFrom = nil+        let first = try store.reconcile()+        #expect(first.collapsedMembers == 0)+        #expect(try store.entryFacts().count == 2)+        let second = try store.reconcile()+        #expect(second.collapsedMembers == 1)+        let survivor = try #require(try store.entryFacts().first)+        #expect(survivor.id == survivorID)+        #expect(survivor.note == "the note")+    }++    // MARK: - Decision 29: chunked deletions, replayed per set on failure++    @Test("A settled pass commits its deletions in chunks, not one save per set")+    func deletionsCommitInChunks() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        for index in 0..<6 {+            store.addEntry(+                id: DuplicateStore.rankedID(index * 2), key: "chapter-\(index)",+                capturedAt: TimeInterval(index), site: site)+            store.addEntry(+                id: DuplicateStore.rankedID(index * 2 + 1), key: "chapter-\(index)",+                capturedAt: TimeInterval(index) + 50, site: site)+        }+        try store.commit()++        _ = try store.reconcile()+        store.saveRecorder.resetCounts()+        let settled = try store.reconcile()++        #expect(settled.collapsedMembers == 6)+        #expect(+            store.saveRecorder.attemptCount < 6,+            "one save per set is the cost Decision 29 removed")+    }++    /// Q86's guarantee, at the only moment it is observable. The chunk save+    /// fails, its work rolls back, and every set in it is then verified and+    /// committed on its own — so a set whose fingerprint still matches is not+    /// punished for having shared a transaction with the failure.+    @Test("A failed deletion chunk is replayed one set at a time and still commits")+    func aFailedDeletionChunkReplaysPerSet() throws {+        // Armed on the shape rather than on a save index: the deletion chunk is+        // the first save of the whole run that deletes anything.+        let strategy = FailFirstDeletingSaveStrategy()+        let store = try DuplicateStore(saveStrategy: strategy)+        let site = store.addSite()+        for index in 0..<3 {+            store.addEntry(+                id: DuplicateStore.rankedID(index * 2), key: "chapter-\(index)",+                capturedAt: TimeInterval(index), site: site)+            store.addEntry(+                id: DuplicateStore.rankedID(index * 2 + 1), key: "chapter-\(index)",+                capturedAt: TimeInterval(index) + 50, site: site)+        }+        try store.commit()++        _ = try store.reconcile()+        let settled = try store.reconcile()++        // Every set collapsed anyway, through the replay.+        #expect(settled.collapsedMembers == 3)+        #expect(settled.settlingSetKeys.isEmpty, "no set was left behind by the failed chunk")+        #expect(strategy.hasFired, "the chunk save must actually have failed")+        #expect(try store.entryFacts().count == 3)+    }++    // MARK: - Req 2.5: every commit boundary is a library the app can open++    @Test("Chunked resolution leaves a legal library at every boundary")+    func chunkBoundariesAreLegal() throws {+        let validating = DuplicateBoundaryValidatingStrategy()+        let store = try DuplicateStore(saveStrategy: validating)+        let site = store.addSite()+        for index in 0..<6 {+            store.addEntry(+                id: DuplicateStore.rankedID(index * 2), key: "chapter-\(index)",+                capturedAt: TimeInterval(index), site: site)+            store.addEntry(+                id: DuplicateStore.rankedID(index * 2 + 1), key: "chapter-\(index)",+                capturedAt: TimeInterval(index) + 50, note: "note \(index)", site: site)+        }+        try store.commit()++        // Chunk size 2 records: every set commits on its own.+        _ = try store.reconcile(batchSize: 2)+        _ = try store.reconcile(batchSize: 2)++        #expect(validating.boundaries.count > 1, "the pass committed in one go")+        #expect(try store.entryFacts().count == 6)+    }+}
Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift Added +732 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swiftnew file mode 100644index 0000000..6c39512--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift@@ -0,0 +1,732 @@+import Foundation+import SwiftData++// The three orderings duplicate reconciliation runs on: which row of an identity+// group *represents* it, which authored variant *leads* a divergent set, and+// which member of a duplicate set *survives* its collapse.+//+// They are deliberately not extensions of `RecordResolutionOrder` (Q48). That+// order ends on `PersistentIdentifier`, which the codebase already documents as+// device-local and forbidden as a write basis (`IdentityResolution.swift:91-96`):+// two devices assign different identifiers to the same logical rows, so an+// ordering that ends there picks a different winner on each device. A+// presentation winner may be chosen that way; a *convergence target* or a+// *deletion survivor* may not, or two devices delete each other's rows.+//+// The consequence, accepted as Q36, is that the representative ordering is not+// total: rows equal in every synced field tie, and the tie is left standing+// rather than broken. It costs nothing — when rows agree on everything synced,+// every choice produces the same presented and exported values.++// MARK: - Order components++/// One slot of an ordering tuple.+///+/// A typed component list rather than a single encoded string: the tuples mix+/// dates, integers, flags and optionals, and any string encoding of a `Date`+/// either loses precision or misorders negative intervals — `firstCapturedAt`+/// defaults to the 1970 epoch, so pre-reference dates are reachable.+public enum OrderComponent: Sendable, Equatable {+    case string(String)+    /// Absence sorts **last**, the same rule `SiteResolutionOrder` uses. Treating+    /// absence as a tie and falling through to the next slot is the+    /// natural-looking form and it is intransitive.+    case absentableString(String?)+    case strings([String])+    case flag(Bool)+    case int(Int)+    case date(Date)++    /// Case rank, so a malformed pair of tuples still compares totally instead+    /// of silently reporting equality.+    private var rank: Int {+        switch self {+        case .string: 0+        case .absentableString: 1+        case .strings: 2+        case .flag: 3+        case .int: 4+        case .date: 5+        }+    }++    static func compare(_ lhs: OrderComponent, _ rhs: OrderComponent) -> ComparisonResult {+        switch (lhs, rhs) {+        case let (.string(lhs), .string(rhs)):+            compare(lhs, rhs)+        case let (.absentableString(lhs), .absentableString(rhs)):+            switch (lhs, rhs) {+            case (nil, nil): .orderedSame+            case (nil, _): .orderedDescending+            case (_, nil): .orderedAscending+            case let (lhs?, rhs?): compare(lhs, rhs)+            }+        case let (.strings(lhs), .strings(rhs)):+            compare(lhs, rhs)+        case let (.flag(lhs), .flag(rhs)):+            lhs == rhs ? .orderedSame : (rhs ? .orderedAscending : .orderedDescending)+        case let (.int(lhs), .int(rhs)):+            lhs == rhs ? .orderedSame : (lhs < rhs ? .orderedAscending : .orderedDescending)+        case let (.date(lhs), .date(rhs)):+            lhs == rhs ? .orderedSame : (lhs < rhs ? .orderedAscending : .orderedDescending)+        default:+            lhs.rank == rhs.rank ? .orderedSame+                : (lhs.rank < rhs.rank ? .orderedAscending : .orderedDescending)+        }+    }++    /// Plain scalar `String` comparison, never a locale-aware one: the result+    /// has to be the same on every device regardless of the reader's locale.+    private static func compare(_ lhs: String, _ rhs: String) -> ComparisonResult {+        if lhs == rhs { return .orderedSame }+        return lhs < rhs ? .orderedAscending : .orderedDescending+    }++    private static func compare(_ lhs: [String], _ rhs: [String]) -> ComparisonResult {+        for (lhs, rhs) in zip(lhs, rhs) {+            let result = compare(lhs, rhs)+            if result != .orderedSame { return result }+        }+        if lhs.count == rhs.count { return .orderedSame }+        return lhs.count < rhs.count ? .orderedAscending : .orderedDescending+    }++    /// Lexicographic comparison of two whole tuples.+    static func compare(_ lhs: [OrderComponent], _ rhs: [OrderComponent]) -> ComparisonResult {+        for (lhs, rhs) in zip(lhs, rhs) {+            let result = compare(lhs, rhs)+            if result != .orderedSame { return result }+        }+        if lhs.count == rhs.count { return .orderedSame }+        return lhs.count < rhs.count ? .orderedAscending : .orderedDescending+    }+}++// MARK: - Authored content++/// The reader-authored surface of one record type (requirements' Definitions).+///+/// One definition per type drives collapse eligibility, review routing and sheet+/// display alike (Q7): a narrower "agreement" list than the authored list is+/// exactly what would let a collapse delete a differing manual chapter title.+public protocol AuthoredContent: Sendable, Equatable {+    /// A value carrying nothing a reader wrote. Bare content never forms a+    /// variant and can never be lost by a collapse.+    var isBare: Bool { get }++    /// The bare value of this type — what a member carrying nothing reports as+    /// its authored content.+    static var bare: Self { get }++    /// The tuple this content contributes to the representative and variant+    /// orderings.+    var orderComponents: [OrderComponent] { get }+}++/// Entry authored fields: note, rating, work assignment when manual, chapter+/// title when manual, intentional unattachment.+public struct EntryAuthoredContent: AuthoredContent {+    public let note: String+    public let rating: Rating?+    /// The **physical** assignment: the application UUID of the Work this row+    /// actually points at, and only where the assignment is the reader's+    /// (`workAssignmentProvenance == .manual`). A derived assignment is+    /// re-derivable, not authored.+    ///+    /// This is the only assignment value a write may ever use. The normalisation+    /// below never touches it.+    public let workAssignment: UUID?+    /// Only where `chapterTitleProvenance == .manual`; a parsed or rule-derived+    /// chapter title is a derived field (Q44).+    public let chapterTitle: String?+    public let intentionallyUnattached: Bool++    /// The assignment as *equality* sees it — a comparison key, never a value.+    ///+    /// The Definitions make assignments referring to members of one Work+    /// duplicate set equal, which is a statement about comparison and nothing+    /// else. Writing the canonical member instead of the physical one would+    /// silently repoint a manual assignment, which Req 2.6 forbids: for a+    /// *divergent* Work set the surviving Work is the reader's Merge choice,+    /// not the survivor rule's candidate, and the canonical map names the+    /// latter. Keeping the normalised value private, read only by `==` and+    /// `orderComponents`, is what makes that write unreachable rather than+    /// merely unintended.+    private var assignmentEqualityKey: UUID?++    public init(+        note: String = "",+        rating: Rating? = nil,+        workAssignment: UUID? = nil,+        chapterTitle: String? = nil,+        intentionallyUnattached: Bool = false+    ) {+        self.note = note+        self.rating = rating+        self.workAssignment = workAssignment+        self.chapterTitle = chapterTitle+        self.intentionallyUnattached = intentionallyUnattached+        self.assignmentEqualityKey = workAssignment+    }++    public static let bare = EntryAuthoredContent()++    /// Compares the normalised assignment, not the physical one — two members of+    /// one Work duplicate set do not disagree (Definitions, Q38). Every other+    /// field compares literally.+    public static func == (lhs: Self, rhs: Self) -> Bool {+        lhs.note == rhs.note+            && lhs.rating == rhs.rating+            && lhs.assignmentEqualityKey == rhs.assignmentEqualityKey+            && lhs.chapterTitle == rhs.chapterTitle+            && lhs.intentionallyUnattached == rhs.intentionallyUnattached+    }++    /// A whitespace-only note counts as authored. Absence of a rating is the+    /// neutral default and cannot be told from a deliberate one (Decision 1).+    public var isBare: Bool {+        note.isEmpty && rating == nil && workAssignment == nil && chapterTitle == nil+            && !intentionallyUnattached+    }++    public var orderComponents: [OrderComponent] {+        [+            .string(note),+            .absentableString(rating?.rawValue),+            .absentableString(assignmentEqualityKey.map { $0.uuidString.lowercased() }),+            .absentableString(chapterTitle),+            .flag(intentionallyUnattached),+        ]+    }++    /// Definitions: work assignments referring to members of one Work duplicate+    /// set are equal. The map sends each such member's UUID to its set's+    /// canonical member; an unmapped assignment is left alone.+    ///+    /// Only the equality key moves. `workAssignment` still reports the row's own+    /// target, so content that reaches a write carries a real assignment rather+    /// than a canonical guess.+    public func normalizingAssignment(using canonicalWorkIDs: [UUID: UUID]) -> Self {+        guard let workAssignment, let canonical = canonicalWorkIDs[workAssignment] else {+            return self+        }+        var normalized = self+        normalized.assignmentEqualityKey = canonical+        return normalized+    }+}++/// Work authored fields: generic notes, title when manually set *and* differing+/// from the last parsed title (Q34), confirmed work URL, genre tags, type when+/// not the default (Q11).+public struct WorkAuthoredContent: AuthoredContent {+    public var genericNotes: String+    public var manualTitle: String?+    public var workURLString: String?+    /// Sorted at construction: tags are a set, and two rows listing the same+    /// tags in two orders do not disagree.+    public var genreTags: [String]+    public var type: WorkType?++    public init(+        genericNotes: String = "",+        manualTitle: String? = nil,+        workURLString: String? = nil,+        genreTags: [String] = [],+        type: WorkType? = nil+    ) {+        self.genericNotes = genericNotes+        self.manualTitle = manualTitle+        self.workURLString = workURLString+        self.genreTags = genreTags.sorted()+        self.type = type+    }++    public static let bare = WorkAuthoredContent()++    public var isBare: Bool {+        genericNotes.isEmpty && manualTitle == nil && workURLString == nil && genreTags.isEmpty+            && type == nil+    }++    public var orderComponents: [OrderComponent] {+        [+            .string(genericNotes),+            .absentableString(manualTitle),+            .absentableString(workURLString),+            .strings(genreTags),+            .absentableString(type?.rawValue),+        ]+    }+}++/// Rule records have no reader-authored fields (Q39): they are re-derivable+/// teaching knowledge, always bare, never torn.+public struct NoAuthoredContent: AuthoredContent {+    public init() {}+    public static let bare = NoAuthoredContent()+    public var isBare: Bool { true }+    public var orderComponents: [OrderComponent] { [] }+}++/// One distinct non-bare authored value present in a duplicate set, carrying the+/// earliest capture date among the rows holding it (Q42).+public struct AuthoredVariant<Content: AuthoredContent>: Sendable, Equatable {+    public let content: Content+    public let firstCapturedAt: Date++    public init(content: Content, firstCapturedAt: Date) {+        self.content = content+        self.firstCapturedAt = firstCapturedAt+    }++    var orderComponents: [OrderComponent] {+        [.date(firstCapturedAt)] + content.orderComponents+    }+}++/// A duplicate set member as the survivor rule sees it: an application UUID and+/// the member's earliest capture timestamp across its rows.+public struct SurvivorCandidate: Sendable, Equatable, Hashable {+    public let id: UUID+    public let timestamp: Date++    public init(id: UUID, timestamp: Date) {+        self.id = id+        self.timestamp = timestamp+    }+}++// MARK: - The orderings++public enum GroupOrdering {++    // MARK: Representative row++    /// The row of an identity group that supplies capture evidence wherever one+    /// row-level value is needed. Nil for an empty input.+    public static func representativeEntry(_ rows: [Entry]) -> Entry? {+        least(rows, key: representativeComponents)+    }++    public static func representativeWork(_ rows: [Work]) -> Work? {+        least(rows, key: representativeComponents)+    }++    /// Doubles as Req 6.1's convergence selector: the definition this row holds+    /// is the one the group converges on.+    public static func representativePattern(_ rows: [TitlePattern]) -> TitlePattern? {+        least(rows, key: representativeComponents)+    }++    public static func representativeURLRule(_ rows: [URLRulePattern]) -> URLRulePattern? {+        least(rows, key: representativeComponents)+    }++    /// Rows in representative order. The sort is **stable**, so interchangeable+    /// rows keep the order they arrived in rather than being separated by+    /// anything device-local.+    public static func sortedEntryRows(_ rows: [Entry]) -> [Entry] {+        stableSorted(rows, key: representativeComponents)+    }++    public static func sortedWorkRows(_ rows: [Work]) -> [Work] {+        stableSorted(rows, key: representativeComponents)+    }++    public static func sortedPatternRows(_ rows: [TitlePattern]) -> [TitlePattern] {+        stableSorted(rows, key: representativeComponents)+    }++    public static func sortedURLRuleRows(_ rows: [URLRulePattern]) -> [URLRulePattern] {+        stableSorted(rows, key: representativeComponents)+    }++    // MARK: Representative comparators, exposed for the order-algebra tests++    static func entryRowPrecedes(_ lhs: Entry, _ rhs: Entry) -> Bool {+        OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))+            == .orderedAscending+    }++    static func entryRowsAreInterchangeable(_ lhs: Entry, _ rhs: Entry) -> Bool {+        OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))+            == .orderedSame+    }++    static func workRowPrecedes(_ lhs: Work, _ rhs: Work) -> Bool {+        OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))+            == .orderedAscending+    }++    static func patternRowPrecedes(_ lhs: TitlePattern, _ rhs: TitlePattern) -> Bool {+        OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))+            == .orderedAscending+    }++    static func variantPrecedes<Content>(+        _ lhs: AuthoredVariant<Content>, _ rhs: AuthoredVariant<Content>+    ) -> Bool {+        OrderComponent.compare(lhs.orderComponents, rhs.orderComponents) == .orderedAscending+    }++    static func survivorPrecedes(_ lhs: SurvivorCandidate, _ rhs: SurvivorCandidate) -> Bool {+        OrderComponent.compare(survivorComponents(lhs), survivorComponents(rhs))+            == .orderedAscending+    }++    static func workRowsAreInterchangeable(_ lhs: Work, _ rhs: Work) -> Bool {+        OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))+            == .orderedSame+    }++    static func patternRowsAreInterchangeable(_ lhs: TitlePattern, _ rhs: TitlePattern) -> Bool {+        OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))+            == .orderedSame+    }++    static func urlRuleRowsAreInterchangeable(+        _ lhs: URLRulePattern, _ rhs: URLRulePattern+    ) -> Bool {+        OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))+            == .orderedSame+    }++    // MARK: Variant order (Q42)++    public static func sortedVariants<Content>(+        _ variants: [AuthoredVariant<Content>]+    ) -> [AuthoredVariant<Content>] {+        stableSorted(variants, key: \.orderComponents)+    }++    /// The least variant under the variant order — what a torn group presents+    /// and what the resolution sheet preselects.+    public static func leadingVariant<Content>(+        _ variants: [AuthoredVariant<Content>]+    ) -> AuthoredVariant<Content>? {+        least(variants, key: \.orderComponents)+    }++    /// The distinct non-bare values among `contents`, each dated by the earliest+    /// row carrying it (Q42), in variant order. `dates[i]` is `contents[i]`'s+    /// capture date.+    ///+    /// Dedup is linear: a set's variant count is bounded by how many devices+    /// wrote to it, never by library size.+    public static func variants<Content: AuthoredContent>(+        contents: [Content], dates: [Date]+    ) -> [AuthoredVariant<Content>] {+        var collected: [(content: Content, date: Date)] = []+        for (content, date) in zip(contents, dates) where !content.isBare {+            if let index = collected.firstIndex(where: { $0.content == content }) {+                collected[index].date = min(collected[index].date, date)+            } else {+                collected.append((content, date))+            }+        }+        return sortedVariants(+            collected.map { AuthoredVariant(content: $0.content, firstCapturedAt: $0.date) })+    }++    /// The union of several members' variants, re-dated to the earliest carrying+    /// row across all of them.+    public static func mergedVariants<Content: AuthoredContent>(+        _ variants: [AuthoredVariant<Content>]+    ) -> [AuthoredVariant<Content>] {+        self.variants(+            contents: variants.map(\.content), dates: variants.map(\.firstCapturedAt))+    }++    // MARK: Survivor order++    /// The member a set collapses onto: earliest timestamp, application UUID as+    /// tie-break. No device-local tiebreak is needed or wanted — members have+    /// distinct UUIDs by construction (Q48).+    public static func survivor(_ candidates: [SurvivorCandidate]) -> SurvivorCandidate? {+        least(candidates, key: survivorComponents)+    }++    public static func sortedSurvivorCandidates(+        _ candidates: [SurvivorCandidate]+    ) -> [SurvivorCandidate] {+        stableSorted(candidates, key: survivorComponents)+    }++    // MARK: Reading authored content off a row++    public static func authoredContent(of entry: Entry) -> EntryAuthoredContent {+        // `intentionallyUnattached` deliberately carries no provenance gate,+        // unlike the assignment and the chapter title beside it. The Articles+        // path sets the same flag with `.none` provenance (Decision 10), so a+        // gate on `workAssignmentProvenance == .manual` would read an Articles+        // unattachment as bare on one row and authored on another. Recorded as+        // known: the cost is that a derived unattachment counts as authored,+        // which can classify a group as non-bare and send it to the reader —+        // the safe direction.+        EntryAuthoredContent(+            note: entry.note,+            rating: entry.rating,+            workAssignment: entry.workAssignmentProvenance == .manual ? entry.work?.id : nil,+            chapterTitle: entry.chapterTitleProvenance == .manual ? entry.chapterTitle : nil,+            intentionallyUnattached: entry.intentionallyUnattached)+    }++    public static func authoredContent(of work: Work) -> WorkAuthoredContent {+        // Q34: `titleProvenance` defaults to `.manual`, so provenance alone+        // would make every Work non-bare and render silent Work resolution+        // inert. The title is authored only when it is *also* something other+        // than what parsing last produced.+        let manualTitle: String? =+            work.titleProvenance == .manual && work.displayTitle != (work.lastParsedTitle ?? "")+            ? work.displayTitle : nil+        return WorkAuthoredContent(+            genericNotes: work.genericNotes,+            manualTitle: manualTitle,+            workURLString: work.workURLString,+            genreTags: work.genreTags,+            type: work.type == .other ? nil : work.type)+    }++    // MARK: Canonical definition serialisations (Q63)++    /// Every decomposed column of a `TitlePattern`, in declaration order,+    /// **including the trims**. `setImmutableDefinition` writes every column but+    /// those two, so a canonical form built from `definition` alone would call+    /// two rows that derive different chapter titles converged.+    public static func canonicalDefinition(_ pattern: TitlePattern) -> String {+        canonicalFields([+            pattern.formRaw,+            pattern.segmentWorkAnchor.map(canonical),+            pattern.segmentIgnoredAnchors.map { $0.map(canonical).joined(separator: ",") },+            pattern.phrasePrefix,+            pattern.phraseSeparator,+            pattern.phraseSuffix,+            pattern.fieldOrderRaw,+            pattern.trimPrefix,+            pattern.trimSuffix,+            String(pattern.chapterless),+        ])+    }++    /// The URL rule's definition decoded and re-encoded with sorted keys, so two+    /// rows carrying one rule under different JSON byte layouts read as one+    /// definition. Undecodable bytes fall back to their own base64, which is+    /// deterministic and cannot collide with a decodable rule's encoding.+    public static func canonicalDefinition(_ rule: URLRulePattern) -> String {+        let encoder = JSONEncoder()+        encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]+        guard+            let definition = try? JSONDecoder().decode(+                URLRuleDefinition.self, from: rule.definitionData),+            let encoded = try? encoder.encode(definition),+            let text = String(data: encoded, encoding: .utf8)+        else {+            return "raw:" + rule.definitionData.base64EncodedString()+        }+        return text+    }++    // MARK: - Converged rule groups (Req 6.2, task 20)++    /// Whether rows sharing one rule application UUID are **converged**: they+    /// hold one definition, and at most one of them carries the site's flag.+    ///+    /// **Only `V4LibraryValidator` asks this question** (Decision 23). It is the+    /// validator's alone because it decides whether a hostname quarantines,+    /// which is a fact about the *store*. The projections deliberately do not+    /// consult it: a group that has not converged yet still has to archive once,+    /// so gating the archive dedup on convergence would emit a file the 4/4+    /// reference validator refuses for exactly the unsettled groups. What keeps+    /// the store and the archive from disagreeing is a narrower property, stated+    /// where the dedup is: whatever row the archive keeps for a group, it keeps+    /// the group's active/current custody (Decision 24).+    ///+    /// Versions are deliberately *not* part of it. Decision 13 leaves a group's+    /// rows on different versions wherever aligning them would break the owning+    /// Site row's own uniqueness, so a converged group routinely spans versions+    /// — and every row holds the same definition, so replay reproduces the same+    /// values whichever version a citation names.+    /// **"Same definition" is `RuleDefinitionComparator`'s question, and it is+    /// asked here in its words** (Decision 31). `canonicalDefinition` answers a+    /// different one — it is a total *order* over rows, so it joins the ignored+    /// anchors in stored order to keep two permutations distinguishable, and it+    /// is a `String` whose equality is Unicode-canonical rather than by scalar.+    /// Both are right for an ordering and wrong for an identity: the anchors of+    /// a segment rule carry no order, so two permutations are one rule to every+    /// teaching commit in the app, and phrase literals and trims are compared by+    /// exact scalars everywhere else because two byte-distinct literals are two+    /// rules. Read through the ordering, the validator refused membership for a+    /// group the teaching commit calls a no-op.+    public static func isConvergedGroup(_ rows: [TitlePattern]) -> Bool {+        guard rows.count > 1 else { return true }+        guard rows.count(where: \.isActive) <= 1 else { return false }+        guard let first = rows.first else { return true }+        guard let firstDefinition = try? first.definition else {+            // An undecodable arm has no semantics to compare. Fall back to the+            // stored columns by exact scalars, so a group of equally broken rows+            // is still converged and a mixed one is not.+            return rows.allSatisfy {+                (try? $0.definition) == nil+                    && RuleDefinitionComparator.scalarEqual(+                        canonicalDefinition($0), canonicalDefinition(first))+            }+        }+        return rows.dropFirst().allSatisfy { row in+            guard let definition = try? row.definition else { return false }+            return RuleDefinitionComparator.semanticallyEqual(firstDefinition, definition)+                && RuleDefinitionComparator.trimsEqual(row.trimPrefix, first.trimPrefix)+                && RuleDefinitionComparator.trimsEqual(row.trimSuffix, first.trimSuffix)+        }+    }++    /// The URL-rule counterpart. `URLRuleDefinition` is built entirely on+    /// `ExactScalarString` and carries no order-independent redundancy, so the+    /// canonical encoding *is* the identity — but its comparison is not, and+    /// `Set<String>` equated two scalar-distinct locators the rest of the app+    /// treats as two rules. Same encoding, exact-scalar compare.+    public static func isConvergedGroup(_ rows: [URLRulePattern]) -> Bool {+        guard rows.count > 1 else { return true }+        guard rows.count(where: \.isCurrent) <= 1 else { return false }+        let canonical = rows.map(canonicalDefinition)+        return canonical.dropFirst().allSatisfy {+            RuleDefinitionComparator.scalarEqual($0, canonical[0])+        }+    }++    // MARK: - Tuples++    /// Entry: the immutable capture evidence, then the authored tuple, then the+    /// timestamps. Evidence leads because it is the half §2.6 declares+    /// immutable, so it decides wherever the rows differ at all in what they+    /// were captured from.+    ///+    /// It is **not** a fixed point under writes: where rows tie on evidence the+    /// authored tuple and the timestamps decide, and both move when an edit+    /// lands. That is right for a presentation order — the representative+    /// supplies evidence, and rows tied on evidence supply the same evidence+    /// whichever one is picked. It was wrong for the single-row write paths+    /// phase 1 briefly pointed at it (Decision 9), and those are gone: a write+    /// addresses every row of a group (Req 2.7), so no write path reads any+    /// ordering to decide where to land.+    private static func representativeComponents(_ entry: Entry) -> [OrderComponent] {+        [+            .string(entry.captureTitle),+            .string(entry.captureTitleSourceRaw),+            .string(entry.rawURLString),+            .absentableString(entry.canonicalURLString),+            .string(entry.hostname),+            .string(entry.conservativeIdentityKey),+            .date(entry.firstCapturedAt),+        ]+            + authoredContent(of: entry).orderComponents+            + [.date(entry.lastSharedAt), .date(entry.modifiedAt)]+    }++    /// Work: a Work's only immutable evidence is where and when it came into+    /// being.+    private static func representativeComponents(_ work: Work) -> [OrderComponent] {+        [.string(work.siteHostname), .date(work.createdAt)]+            + authoredContent(of: work).orderComponents+            + [.date(work.modifiedAt)]+    }++    /// Rule rows carry no authored content, so their tuple is evidence only.+    ///+    /// The active flag sorts **active first, but last of five**: hostname,+    /// `createdAt` and `version` all precede it, and the comparison is strict+    /// lexicographic. So the flag decides only between rows that agree about all+    /// three — the double-import case — and where a group spans versions the+    /// version decides alone and the flag never gets a vote. Q68's "the row that+    /// sets the field wins" therefore holds within a version and not across one.+    ///+    /// That is fine for the reconciler, which converges definitions and demotes+    /// per Site row rather than moving custody (Q83), and it was **not** fine for+    /// the archive dedup, which read this order as "the row that owns the flag":+    /// see `SiteUnionProjection.RuleMembership.reduced`, which picks among the+    /// marked rows for exactly that reason (Decision 24).+    ///+    /// Reading `site` faults one relationship, which only a duplicated rule UUID+    /// ever pays for.+    private static func representativeComponents(_ pattern: TitlePattern) -> [OrderComponent] {+        [+            .absentableString(pattern.site?.hostname),+            .date(pattern.createdAt),+            .int(pattern.version),+            .flag(!pattern.isActive),+            .string(canonicalDefinition(pattern)),+        ]+    }++    private static func representativeComponents(_ rule: URLRulePattern) -> [OrderComponent] {+        [+            .absentableString(rule.site?.hostname),+            .date(rule.createdAt),+            .int(rule.version),+            .flag(!rule.isCurrent),+            .string(canonicalDefinition(rule)),+        ]+    }++    private static func survivorComponents(_ candidate: SurvivorCandidate) -> [OrderComponent] {+        [.date(candidate.timestamp), .string(candidate.id.uuidString.lowercased())]+    }++    // MARK: - Serialisation helpers++    /// Fixed field-order encoding: fields are joined by a unit separator and an+    /// absent field encodes distinctly from a present empty one, so no value can+    /// impersonate a different field layout. The absent marker sorts after any+    /// present value, matching `absentableString`.+    private static func canonicalFields(_ fields: [String?]) -> String {+        fields.map { $0.map { "=" + $0 } ?? "~" }.joined(separator: "\u{1F}")+    }++    private static func canonical(_ spec: SegmentRangeSpec) -> String {+        "\(spec.origin.rawValue):\(spec.offset):\(spec.length)"+    }++    private static func canonical(_ spec: SegmentPositionSpec) -> String {+        "\(spec.origin.rawValue):\(spec.offset)"+    }++    // MARK: - Sorting++    /// A stable sort over precomputed keys. Stable because the orderings tie on+    /// interchangeable rows by design (Q36) and a tie must not be resolved by+    /// whatever `sorted(by:)` happens to do; precomputed because the keys read+    /// relationships and re-deriving them per comparison would fault O(n log n)+    /// times.+    private static func stableSorted<Element>(+        _ elements: [Element], key: (Element) -> [OrderComponent]+    ) -> [Element] {+        guard elements.count > 1 else { return elements }+        return elements.enumerated()+            .map { (offset: $0.offset, key: key($0.element), element: $0.element) }+            .sorted { lhs, rhs in+                switch OrderComponent.compare(lhs.key, rhs.key) {+                case .orderedAscending: true+                case .orderedDescending: false+                case .orderedSame: lhs.offset < rhs.offset+                }+            }+            .map(\.element)+    }++    private static func least<Element>(+        _ elements: [Element], key: (Element) -> [OrderComponent]+    ) -> Element? {+        var best: (key: [OrderComponent], element: Element)?+        for element in elements {+            let candidate = key(element)+            guard let current = best else {+                best = (candidate, element)+                continue+            }+            if OrderComponent.compare(candidate, current.key) == .orderedAscending {+                best = (candidate, element)+            }+        }+        return best?.element+    }+}
Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift Added +688 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swiftnew file mode 100644index 0000000..2be809b--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift@@ -0,0 +1,688 @@+import Foundation+import SwiftData++// Detection: what the store currently holds, derived and returned, writing+// nothing. The reconciler drives resolution from this; the tolerance scan's+// arrival tier gates on it.+//+// It rides the tolerance scan's enumerate shape deliberately (Q50, Q58):+// `LibraryDiagnostics.scan` already walks all five tables after every arrival,+// and duplicate detection has no aggregate form in SwiftData — no `DISTINCT`,+// no `GROUP BY` — so ids are bucketed in a Swift `Dictionary` either way. No+// schema change, no `#Index`, no V6 stage: an index would cost a migration+// stage for a path that meets its budget without one.++// MARK: - Public shapes++public enum DuplicateRecordType: String, Sendable, Comparable, CaseIterable {+    case entry+    case work+    case titleRule+    case urlRule++    public static func < (lhs: Self, rhs: Self) -> Bool {+        (Self.allCases.firstIndex(of: lhs) ?? 0) < (Self.allCases.firstIndex(of: rhs) ?? 0)+    }+}++/// A duplicate set's identity: its record type plus its sorted member UUIDs.+///+/// Membership *is* the key, so a set that gains or loses a member produces a new+/// key — which is exactly a first observation for the settling ledger (Req 2.3).+public struct DuplicateSetKey: Hashable, Sendable, Comparable {+    public let recordType: DuplicateRecordType+    /// Sorted by lowercased UUID string, so the key is independent of fetch and+    /// `Dictionary` iteration order.+    public let memberIDs: [UUID]++    public init(recordType: DuplicateRecordType, memberIDs: [UUID]) {+        self.recordType = recordType+        self.memberIDs = memberIDs.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }+    }++    public static func < (lhs: Self, rhs: Self) -> Bool {+        if lhs.recordType != rhs.recordType { return lhs.recordType < rhs.recordType }+        let lhsText = lhs.memberIDs.map { $0.uuidString.lowercased() }+        let rhsText = rhs.memberIDs.map { $0.uuidString.lowercased() }+        return lhsText.lexicographicallyPrecedes(rhsText)+    }+}++/// One logical record of a set: a single row, or the whole identity group of+/// rows sharing its application UUID.+public struct DuplicateMember<Content: AuthoredContent>: Sendable, Equatable {+    public let id: UUID+    public let rowCount: Int+    /// The member's distinct non-bare authored values, in variant order. Empty+    /// for a bare member; two or more only for a torn group.+    public let variants: [AuthoredVariant<Content>]+    /// Earliest `firstCapturedAt`/`createdAt` across the member's rows.+    public let firstCapturedAt: Date+    /// Latest `lastSharedAt`/`modifiedAt` across the member's rows.+    public let lastActivityAt: Date++    public var isSplit: Bool { rowCount > 1 }++    /// A split group whose rows disagree about something the reader wrote.+    public var isTorn: Bool { variants.count > 1 }++    /// The member's single authored variant if any, else bare — and nil when+    /// torn, where no single value stands for the member (Definitions).+    public var authoredContent: Content? {+        isTorn ? nil : (variants.first?.content ?? Content.bare)+    }+}++public enum DuplicateSetClassification: Sendable, Equatable {+    /// No two members carry disagreeing authored content, so the app resolves it+    /// without the reader (Decision 1).+    case silentlyResolvable+    /// Two or more authored variants: the reader decides (Req 4.1, 5.3, 5.4).+    case divergent+    /// Req 1.6: the members' work assignments span a Work set with no+    /// determined survivor — a divergent one, awaiting the reader — so the+    /// survivor's assignment target has no value yet.+    case deferred(blockedBy: DuplicateSetKey)+}++public struct DuplicateSet<Content: AuthoredContent>: Sendable, Equatable {+    public let key: DuplicateSetKey+    /// Ordered by the survivor rule, so `members.first` is the member a collapse+    /// would keep.+    public let members: [DuplicateMember<Content>]+    /// The set's distinct authored variants, in variant order. The leading+    /// variant is `variants.first`.+    public let variants: [AuthoredVariant<Content>]+    public let classification: DuplicateSetClassification++    /// Req 8.1: a torn group is the one duplicate state the archive cannot+    /// represent.+    public var isTorn: Bool { members.contains(where: \.isTorn) }+}++public typealias EntryDuplicateSet = DuplicateSet<EntryAuthoredContent>+public typealias WorkDuplicateSet = DuplicateSet<WorkAuthoredContent>+public typealias RuleDuplicateSet = DuplicateSet<NoAuthoredContent>++public struct DuplicateScanResult: Sendable, Equatable {+    public let entrySets: [EntryDuplicateSet]+    public let workSets: [WorkDuplicateSet]+    public let titleRuleSets: [RuleDuplicateSet]+    public let urlRuleSets: [RuleDuplicateSet]++    public var isEmpty: Bool {+        entrySets.isEmpty && workSets.isEmpty && titleRuleSets.isEmpty && urlRuleSets.isEmpty+    }++    /// Every set, for callers that only need "is there work" or a count.+    public var setCount: Int {+        entrySets.count + workSets.count + titleRuleSets.count + urlRuleSets.count+    }++    /// The Definitions' assignment normalisation for this scan (Q38).+    ///+    /// A pure function of `workSets`, and it lives here so that it is *one*+    /// derivation. Written out beside the scan it drifts: the reconciler's+    /// deletion phase re-verifies Entry fingerprints under this map, and a+    /// second copy of the same three lines is a copy that can compare an+    /// assignment the scan called equal against one it did not.+    public var canonicalWorkIDs: [UUID: UUID] {+        DuplicateScan.canonicalWorkIDs(workSets)+    }+}++// MARK: - The scan++public enum DuplicateScan {++    /// The tolerance scan's batch size, for the same round-trip/peak-memory+    /// tradeoff it records.+    static var batchSize: Int { LibraryRepository.enumerationBatchSize }++    /// Two walks per record type, and the split is the cost model.+    ///+    /// The first reads **scalar columns only** — the id, the bucket key, and+    /// nothing else — so it never faults a relationship. That is the cost+    /// `LibraryDiagnostics.scan` documents itself refusing to pay for+    /// `Entry.work`, and a library with no duplicate candidates now does not pay+    /// it either: the components come out empty and the second walk never runs+    /// (Req 10.2).+    ///+    /// The second reads the authored content and the work pointer for the rows+    /// inside a candidate component and skips every other row. Only those rows+    /// need either: classification is a question about a set, and a row in no+    /// set is in no set whatever it holds.+    public static func run(context: ModelContext) throws -> DuplicateScanResult {+        try run(context: context, ruleRows: nil)+    }++    static func run(+        context: ModelContext, ruleRows: RuleRowSnapshot?+    ) throws -> DuplicateScanResult {+        let entryComponents = try candidateComponents(+            FetchDescriptor<Entry>(), context: context, id: \.id,+            bucketKey: {+                entryBucketKey(+                    hostname: $0.hostname, conservativeIdentityKey: $0.conservativeIdentityKey)+            })+        let workComponents = try candidateComponents(+            FetchDescriptor<Work>(), context: context, id: \.id,+            bucketKey: {+                workBucketKey(+                    siteHostname: $0.siteHostname, urlIdentity: $0.urlIdentity,+                    lastParsedTitle: $0.lastParsedTitle)+            })++        var entryRows: [EntryRow] = []+        var workRows: [WorkRow] = []+        var patternRows: [RuleRow] = []+        var urlRuleRows: [RuleRow] = []++        if !entryComponents.candidates.isEmpty {+            let candidates = entryComponents.candidates+            try context.enumerate(FetchDescriptor<Entry>(), batchSize: batchSize) { entry in+                guard candidates.contains(entry.id) else { return }+                entryRows.append(EntryRow(entry))+            }+        }+        if !workComponents.candidates.isEmpty {+            let candidates = workComponents.candidates+            try context.enumerate(FetchDescriptor<Work>(), batchSize: batchSize) { work in+                guard candidates.contains(work.id) else { return }+                workRows.append(WorkRow(work))+            }+        }+        // Rule rows relate by application UUID alone and carry no authored+        // content, so one walk of two columns answers everything about them —+        // and where the caller already made that walk, none at all.+        if let ruleRows {+            patternRows = ruleRows.titleRules+            urlRuleRows = ruleRows.urlRules+        } else {+            try context.enumerate(FetchDescriptor<TitlePattern>(), batchSize: batchSize) { pattern in+                patternRows.append(RuleRow(id: pattern.id, createdAt: pattern.createdAt))+            }+            try context.enumerate(FetchDescriptor<URLRulePattern>(), batchSize: batchSize) { rule in+                urlRuleRows.append(RuleRow(id: rule.id, createdAt: rule.createdAt))+            }+        }++        // Req 1.5: Work sets first. Entry classification reads their survivors,+        // both to normalise assignments and to spot the Req 1.6 blockages.+        let workSets = buildWorkSets(workRows, components: workComponents.components)++        return DuplicateScanResult(+            entrySets: buildEntrySets(+                entryRows,+                components: entryComponents.components,+                canonicalWorkIDs: canonicalWorkIDs(workSets),+                divergentWorkSetKeysByMember: divergentWorkSetKeys(workSets)),+            workSets: workSets,+            titleRuleSets: buildRuleSets(patternRows, type: .titleRule),+            urlRuleSets: buildRuleSets(urlRuleRows, type: .urlRule))+    }++    // MARK: - The assignment normalisation (Q38)++    /// Every member of a Work set mapped to the member a collapse would keep.+    ///+    /// **One spelling, used everywhere an Entry is bucketed into a logical+    /// record.** The Definitions make assignments to two members of one Work set+    /// equal, so a group whose rows point at two such Works is *not* torn — and a+    /// surface that builds its groups without this map reads the same group as+    /// torn while another reads it whole, which is exactly what Req 3.2 forbids.+    /// It was written out three times (the scan, the Recent builder, the+    /// resolution commit) and omitted on two more; it is derived here so it+    /// cannot drift.+    ///+    /// It is an equality key only. `EntryAuthoredContent` keeps the physical+    /// assignment beside it and that is what a write uses (Q67).+    public static func canonicalWorkIDs(_ workSets: [WorkDuplicateSet]) -> [UUID: UUID] {+        var result: [UUID: UUID] = [:]+        for set in workSets {+            guard let survivor = set.members.first?.id else { continue }+            for member in set.members { result[member.id] = survivor }+        }+        return result+    }++    /// The same map for a caller that holds the Work table but no scan.+    ///+    /// The read surfaces (`works()`, the Work snapshot) already fetch every Work+    /// for their own projection, so the sets come from rows in hand rather than+    /// from a second walk of the store.+    static func canonicalWorkIDs(ofWorkRows works: [Work]) -> [UUID: UUID] {+        canonicalWorkIDs(workSets(of: works))+    }++    /// Every member of a **divergent** Work set mapped to that set's key — the+    /// Req 1.6 blockages, derived once for both feeds.+    static func divergentWorkSetKeys(_ workSets: [WorkDuplicateSet]) -> [UUID: DuplicateSetKey] {+        var result: [UUID: DuplicateSetKey] = [:]+        for set in workSets where set.classification == .divergent {+            for member in set.members { result[member.id] = set.key }+        }+        return result+    }++    /// The Entry sets a set of rows implies, classified against Work sets the+    /// caller already holds.+    ///+    /// The value-based counterpart of `workSets(of:)`, and it exists for the+    /// same reason: the backup projection holds both tables and must not walk+    /// the store a second time to find out whether a torn group's set is+    /// deferred behind a Work set the reader still owes a decision (Req 8.4).+    /// One derivation, so the refusal and the reader's workload cannot disagree+    /// about what is blocking what.+    static func entrySets(+        of entries: [Entry], workSets: [WorkDuplicateSet]+    ) -> [EntryDuplicateSet] {+        let candidates = candidateComponents(+            ids: entries.map(\.id),+            bucketKeys: entries.map {+                entryBucketKey(+                    hostname: $0.hostname, conservativeIdentityKey: $0.conservativeIdentityKey)+            })+        guard !candidates.candidates.isEmpty else { return [] }+        let rows = entries.filter { candidates.candidates.contains($0.id) }.map(EntryRow.init)+        return buildEntrySets(+            rows,+            components: candidates.components,+            canonicalWorkIDs: canonicalWorkIDs(workSets),+            divergentWorkSetKeysByMember: divergentWorkSetKeys(workSets))+    }++    /// The Work sets a set of rows implies, derived by the same bucketing,+    /// union–find and classification `run` uses.+    static func workSets(of works: [Work]) -> [WorkDuplicateSet] {+        let candidates = candidateComponents(+            ids: works.map(\.id),+            bucketKeys: works.map {+                workBucketKey(+                    siteHostname: $0.siteHostname, urlIdentity: $0.urlIdentity,+                    lastParsedTitle: $0.lastParsedTitle)+            })+        guard !candidates.candidates.isEmpty else { return [] }+        let rows = works.filter { candidates.candidates.contains($0.id) }.map(WorkRow.init)+        return buildWorkSets(rows, components: candidates.components)+    }++    // MARK: - Row facts++    /// Value copies rather than model references: `enumerate` may discard its+    /// objects between batches, and every fact needed downstream is read once+    /// here.+    private struct EntryRow {+        let id: UUID+        let hostname: String+        let conservativeIdentityKey: String+        let content: EntryAuthoredContent+        let firstCapturedAt: Date+        let lastSharedAt: Date+        /// The physical assignment, whatever its provenance. Req 1.6 is about+        /// the survivor's assignment *target* having a value, which a derived+        /// assignment needs just as much as a manual one.+        let workID: UUID?++        init(_ entry: Entry) {+            id = entry.id+            hostname = entry.hostname+            conservativeIdentityKey = entry.conservativeIdentityKey+            content = GroupOrdering.authoredContent(of: entry)+            firstCapturedAt = entry.firstCapturedAt+            lastSharedAt = entry.lastSharedAt+            workID = entry.work?.id+        }+    }++    private struct WorkRow {+        let id: UUID+        let siteHostname: String+        let urlIdentity: String?+        let lastParsedTitle: String?+        let content: WorkAuthoredContent+        let createdAt: Date+        let modifiedAt: Date++        init(_ work: Work) {+            id = work.id+            siteHostname = work.siteHostname+            urlIdentity = work.urlIdentity+            lastParsedTitle = work.lastParsedTitle+            content = GroupOrdering.authoredContent(of: work)+            createdAt = work.createdAt+            modifiedAt = work.modifiedAt+        }+    }++    struct RuleRow {+        let id: UUID+        let createdAt: Date+    }++    /// The two rule tables' identity columns, materialised by a caller that had+    /// to walk them anyway.+    ///+    /// `reconcileWorkLists` enumerates both tables in the same locked pass, one+    /// statement above this scan, and a rule row's whole contribution here is+    /// two scalars. Handing them over is one walk instead of two; the tables are+    /// small, so this is tidiness rather than speed, but two walks of one table+    /// in one pass is the kind of thing that stops being small.+    struct RuleRowSnapshot {+        let titleRules: [RuleRow]+        let urlRules: [RuleRow]+    }++    // MARK: - Per-type assembly++    private static func buildEntrySets(+        _ rows: [EntryRow],+        components: [[UUID]],+        canonicalWorkIDs: [UUID: UUID],+        divergentWorkSetKeysByMember: [UUID: DuplicateSetKey]+    ) -> [EntryDuplicateSet] {+        let rowsByID = Dictionary(grouping: rows, by: \.id)++        return components.compactMap { ids -> EntryDuplicateSet? in+            let memberRows = ids.map { id in+                (id, rowsByID[id] ?? [])+            }+            guard isDuplicateSet(memberRows.map(\.1.count)) else { return nil }+            let members = memberRows.map { id, rows in+                member(+                    id: id,+                    contents: rows.map { $0.content.normalizingAssignment(using: canonicalWorkIDs) },+                    earliest: rows.map(\.firstCapturedAt),+                    latest: rows.map(\.lastSharedAt))+            }+            let ordered = orderedBySurvivorRule(members)+            let key = DuplicateSetKey(recordType: .entry, memberIDs: ids)+            let variants = setVariants(ordered)+            let blocking = blockingWorkSet(+                assignments: memberRows.flatMap { $0.1.compactMap(\.workID) },+                divergentWorkSetKeysByMember: divergentWorkSetKeysByMember)+            return DuplicateSet(+                key: key,+                members: ordered,+                variants: variants,+                classification: blocking.map { .deferred(blockedBy: $0) }+                    ?? (variants.count > 1 ? .divergent : .silentlyResolvable))+        }+        .sorted { $0.key < $1.key }+    }++    private static func buildWorkSets(+        _ rows: [WorkRow], components: [[UUID]]+    ) -> [WorkDuplicateSet] {+        let rowsByID = Dictionary(grouping: rows, by: \.id)++        return components.compactMap { ids -> WorkDuplicateSet? in+            let memberRows = ids.map { id in (id, rowsByID[id] ?? []) }+            guard isDuplicateSet(memberRows.map(\.1.count)) else { return nil }+            let members = memberRows.map { id, rows in+                member(+                    id: id,+                    contents: rows.map(\.content),+                    earliest: rows.map(\.createdAt),+                    latest: rows.map(\.modifiedAt))+            }+            let ordered = orderedBySurvivorRule(members)+            let variants = setVariants(ordered)+            return DuplicateSet(+                key: DuplicateSetKey(recordType: .work, memberIDs: ids),+                members: ordered,+                variants: variants,+                classification: variants.count > 1 ? .divergent : .silentlyResolvable)+        }+        .sorted { $0.key < $1.key }+    }++    // MARK: - Bucket keys++    /// Q8: the Entry relation is the conservative (raw-URL) key, never the+    /// derived one. Q64: an empty key never joins a bucket, or every keyless+    /// Entry on a hostname would congeal into one spurious set.+    ///+    /// Shared with `LibraryToleranceScan`, whose walk computes the arrival-tier+    /// gate from the same relation (Q58) — two spellings of one relation would+    /// let the gate and the pass disagree about what a duplicate is.+    static func entryBucketKey(hostname: String, conservativeIdentityKey: String) -> String? {+        conservativeIdentityKey.isEmpty+            ? nil : "e\u{1F}\(hostname)\u{1F}\(conservativeIdentityKey)"+    }++    /// §2.4: site plus URL identity where taught, otherwise site plus parsed+    /// title. Q64: a blank parsed title never buckets — `createWork` never sets+    /// one, so without the guard every reader-created Work on a hostname would+    /// join one set.+    static func workBucketKey(+        siteHostname: String, urlIdentity: String?, lastParsedTitle: String?+    ) -> String? {+        if let identity = urlIdentity, !M2Unicode.isBlank(identity) {+            return "wi\u{1F}\(siteHostname)\u{1F}\(identity)"+        }+        if let title = lastParsedTitle, !M2Unicode.isBlank(title) {+            return "wt\u{1F}\(siteHostname)\u{1F}\(title)"+        }+        return nil+    }++    /// The first, scalar-only walk: bucket the table by application UUID and by+    /// its duplicate relation, and return the components that hold a duplicate+    /// set together with every UUID inside one.+    ///+    /// Nothing here reads a relationship or an authored field, so the walk itself+    /// costs two scalar columns per row. What follows it is deliberately narrowed+    /// to the rows that could possibly be in a set: only a UUID whose bucket also+    /// holds a *second* UUID, or that names more than one row, reaches the+    /// union–find. Feeding the whole table in instead was materially more than+    /// "two columns and nothing else" — `components()` sorts its members by+    /// `uuidString`, which builds a fresh `String` per comparison, so a+    /// duplicate-free 5,000-Entry library paid a full-table O(n log n) sort per+    /// full-tier pass before the filter threw every component away (Req 10.2).+    private static func candidateComponents<Model: PersistentModel>(+        _ descriptor: FetchDescriptor<Model>,+        context: ModelContext,+        id: KeyPath<Model, UUID>,+        bucketKey: (Model) -> String?+    ) throws -> (components: [[UUID]], candidates: Set<UUID>) {+        var ids: [UUID] = []+        var keys: [String?] = []+        try context.enumerate(descriptor, batchSize: batchSize) { row in+            ids.append(row[keyPath: id])+            keys.append(bucketKey(row))+        }+        return candidateComponents(ids: ids, bucketKeys: keys)+    }++    /// The same derivation over rows already in memory.+    ///+    /// Split out so a caller holding the table (the Works read, which fetches+    /// every Work for its own snapshots) can derive the sets from what it has+    /// rather than walking the store a second time.+    private static func candidateComponents(+        ids: [UUID], bucketKeys keys: [String?]+    ) -> (components: [[UUID]], candidates: Set<UUID>) {+        var rowCounts: [UUID: Int] = [:]+        // The first UUID seen in each bucket, and the buckets that went on to see+        // a different one. Two rows of *one* UUID in a bucket are a split group,+        // not two members, so they do not make the bucket shared.+        var firstInBucket: [String: UUID] = [:]+        var sharedBuckets: Set<String> = []+        for (rowID, key) in zip(ids, keys) {+            rowCounts[rowID, default: 0] += 1+            guard let key else { continue }+            if let first = firstInBucket[key] {+                if first != rowID { sharedBuckets.insert(key) }+            } else {+                firstInBucket[key] = rowID+            }+        }++        // A row is a candidate when its bucket is shared (it has a set-mate) or+        // its UUID names several rows (it is a split group, a lone duplicate set+        // in its own right). Everything else is in no set whatever it holds.+        var candidateIDs: [UUID] = []+        var candidateKeys: [String?] = []+        for (rowID, key) in zip(ids, keys) {+            let shared = key.map(sharedBuckets.contains) ?? false+            guard shared || (rowCounts[rowID] ?? 0) > 1 else { continue }+            candidateIDs.append(rowID)+            candidateKeys.append(shared ? key : nil)+        }+        guard !candidateIDs.isEmpty else { return ([], []) }++        let components = connectedComponents(ids: candidateIDs, bucketKeys: candidateKeys)+            .filter { isDuplicateSet($0.map { rowCounts[$0] ?? 0 }) }+        return (components, Set(components.flatMap { $0 }))+    }++    /// Rule rows relate by application UUID only: cross-row rule custody within+    /// a hostname is the shipped Site reconciler's job, not this spec's.+    private static func buildRuleSets(+        _ rows: [RuleRow], type: DuplicateRecordType+    ) -> [RuleDuplicateSet] {+        Dictionary(grouping: rows, by: \.id)+            .compactMap { id, rows -> RuleDuplicateSet? in+                guard rows.count > 1 else { return nil }+                let member = DuplicateMember<NoAuthoredContent>(+                    id: id,+                    rowCount: rows.count,+                    variants: [],+                    firstCapturedAt: rows.map(\.createdAt).min() ?? .distantPast,+                    lastActivityAt: rows.map(\.createdAt).max() ?? .distantPast)+                return DuplicateSet(+                    key: DuplicateSetKey(recordType: type, memberIDs: [id]),+                    members: [member],+                    variants: [],+                    classification: .silentlyResolvable)+            }+            .sorted { $0.key < $1.key }+    }++    // MARK: - Shared assembly++    /// A component is a duplicate set when it holds two or more logical records,+    /// or one logical record materialised more than once (Definitions).+    private static func isDuplicateSet(_ rowCounts: [Int]) -> Bool {+        rowCounts.count > 1 || (rowCounts.first ?? 0) > 1+    }++    private static func member<Content: AuthoredContent>(+        id: UUID, contents: [Content], earliest: [Date], latest: [Date]+    ) -> DuplicateMember<Content> {+        DuplicateMember(+            id: id,+            rowCount: contents.count,+            variants: GroupOrdering.variants(contents: contents, dates: earliest),+            firstCapturedAt: earliest.min() ?? .distantPast,+            lastActivityAt: latest.max() ?? .distantPast)+    }++    private static func setVariants<Content: AuthoredContent>(+        _ members: [DuplicateMember<Content>]+    ) -> [AuthoredVariant<Content>] {+        GroupOrdering.mergedVariants(members.flatMap(\.variants))+    }++    private static func orderedBySurvivorRule<Content: AuthoredContent>(+        _ members: [DuplicateMember<Content>]+    ) -> [DuplicateMember<Content>] {+        let order = GroupOrdering.sortedSurvivorCandidates(+            members.map { SurvivorCandidate(id: $0.id, timestamp: $0.firstCapturedAt) })+        let byID = Dictionary(uniqueKeysWithValues: members.map { ($0.id, $0) })+        return order.compactMap { byID[$0.id] }+    }++    /// Req 1.6: the set defers when two or more of the Works its rows point at+    /// are members of one **divergent** Work set.+    ///+    /// Two conditions, both load-bearing. *Two or more* Works of one set,+    /// because assignments all landing on a single Work — even one inside a+    /// Work set — have a defined target already; the Work collapse or Merge+    /// re-points them (Req 5.2). *Divergent*, because "unresolved" in Req 1.6+    /// means the Work set has no determined survivor, which is Q26's stated+    /// reason ("no survivor to point at") and Q62's ("they wait on the+    /// reader"). A silently resolvable Work set that has not collapsed yet+    /// already names its survivor by the same deterministic rule the collapse+    /// will use, so the assignment target *is* defined and the Entry set has+    /// nothing to wait for — deferring there costs a whole extra pass and, per+    /// Q62, gets no re-arm of its own.+    private static func blockingWorkSet(+        assignments: [UUID], divergentWorkSetKeysByMember: [UUID: DuplicateSetKey]+    ) -> DuplicateSetKey? {+        var spanned: [DuplicateSetKey: Set<UUID>] = [:]+        for workID in assignments {+            guard let key = divergentWorkSetKeysByMember[workID] else { continue }+            spanned[key, default: []].insert(workID)+        }+        return spanned.filter { $0.value.count > 1 }.keys.min()+    }++    // MARK: - Union–find over member UUIDs++    /// Connected components of the graph whose nodes are application UUIDs and+    /// whose edges are shared bucket keys. `bucketKeys[i]` is `ids[i]`'s key, or+    /// nil where the row joins no bucket at all.+    private static func connectedComponents(+        ids: [UUID], bucketKeys: [String?]+    ) -> [[UUID]] {+        var find = UnionFind()+        for id in ids { find.add(id) }+        var firstInBucket: [String: UUID] = [:]+        for (id, key) in zip(ids, bucketKeys) {+            guard let key else { continue }+            if let anchor = firstInBucket[key] {+                find.union(anchor, id)+            } else {+                firstInBucket[key] = id+            }+        }+        return find.components()+    }++    private struct UnionFind {+        private var parent: [UUID: UUID] = [:]++        mutating func add(_ id: UUID) {+            if parent[id] == nil { parent[id] = id }+        }++        mutating func find(_ id: UUID) -> UUID {+            var root = id+            while let next = parent[root], next != root { root = next }+            var walk = id+            while let next = parent[walk], next != root {+                parent[walk] = root+                walk = next+            }+            return root+        }++        mutating func union(_ lhs: UUID, _ rhs: UUID) {+            let lhsRoot = find(lhs)+            let rhsRoot = find(rhs)+            guard lhsRoot != rhsRoot else { return }+            // Rooted on the lower UUID, so the structure — and therefore nothing+            // observable — depends on insertion order.+            if lhsRoot.uuidString.lowercased() < rhsRoot.uuidString.lowercased() {+                parent[rhsRoot] = lhsRoot+            } else {+                parent[lhsRoot] = rhsRoot+            }+        }++        /// Components with their members sorted, and the components themselves+        /// ordered: `Dictionary` iteration is per-process seeded, so nothing may+        /// leave here in its natural order.+        mutating func components() -> [[UUID]] {+            var grouped: [UUID: [UUID]] = [:]+            for id in parent.keys.sorted(by: { $0.uuidString < $1.uuidString }) {+                grouped[find(id), default: []].append(id)+            }+            return grouped.values+                .map { $0.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() } }+                .sorted { $0.lexicographicallyPrecedes($1) { $0.uuidString < $1.uuidString } }+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift Added +683 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swiftnew file mode 100644index 0000000..3bd8fd2--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift@@ -0,0 +1,683 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Req 2.7/2.8/2.9: an app-mediated write addressed to a logical record that is+/// a split identity group applies to **every** row in one commit, and refuses+/// outright when the group is torn.+///+/// Phase 1 left the single-row paths choosing a row (`writeTarget`, Decision 9).+/// One row of a group taking an edit is what re-diverges it: the twin keeps the+/// old value, the group is torn, and the reader is handed a conflict the app+/// manufactured. These tests are the removal proof for that accessor.+@Suite("Fan-out writes", .serialized)+struct FanOutWriteTests {++    // MARK: - updateEntry++    @Test("updateEntry writes note and rating to every row of a split group")+    func updateEntryFansOut() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateEntry(+            id: shared, basis: library.entryBasis(id: shared), note: "one note", rating: .up)++        #expect(outcome == .committed)+        let rows = try library.entryRows(id: shared)+        #expect(rows.count == 2)+        #expect(rows.allSatisfy { $0.note == "one note" })+        #expect(rows.allSatisfy { $0.rating == .up })+    }++    @Test("updateEntry refuses a torn group and writes nothing")+    func updateEntryRefusesTornGroup() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            first.note = "device one"+            let second = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            second.note = "device two"+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateEntry(+            id: shared,+            basis: EntryEditBasis(+                note: "device one", rating: nil, hostname: "dup.example",+                conservativeIdentityKey: library.identityKey),+            note: "an overwrite", rating: .down)++        guard case .conflict(.torn(let recordID, let variants)) = outcome else {+            Issue.record("expected a torn conflict, got \(outcome)")+            return+        }+        #expect(recordID == shared)+        #expect(variants.count == 2)+        let rows = try library.entryRows(id: shared)+        #expect(Set(rows.map(\.note)) == ["device one", "device two"])+    }++    /// Q55: lookup-time routing cannot enforce Req 2.8, because tornness can+    /// arrive between the surface's read and the commit. The enforcement is the+    /// re-derivation inside the write's own transaction — so a basis describing+    /// a bare, non-torn record (what a screen that loaded the group before the+    /// second copy landed would carry) authorises nothing.+    @Test("The torn refusal is derived at commit, not from what the caller passed")+    func tornStateIsRederivedInsideTheTransaction() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let held = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            held.note = "written here"+            let arrived = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            arrived.note = "arrived from the phone"+        }+        let repository = try await library.openForApp()++        // The basis says the record was bare and whole when the screen read it.+        let outcome = try await repository.updateEntry(+            id: shared, basis: library.entryBasis(id: shared),+            note: "the reader's edit", rating: nil)++        guard case .conflict(.torn) = outcome else {+            Issue.record("expected a torn conflict, got \(outcome)")+            return+        }+        #expect(try library.entryRows(id: shared).allSatisfy { $0.note != "the reader's edit" })+    }++    @Test("An edit basis carries the edited fields and the identity fields")+    func editBasisCarriesIdentity() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let entry = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            entry.note = "persisted"+            entry.rating = .up+        }+        let repository = try await library.openForApp()++        let snapshot = try await repository.entry(id: shared)+        let basis = EntryEditBasis(entry: snapshot)++        #expect(basis.note == "persisted")+        #expect(basis.rating == .up)+        #expect(basis.hostname == "dup.example")+        #expect(basis.conservativeIdentityKey == library.identityKey)+    }++    // MARK: - moveEntry++    @Test("moveEntry repoints every row of a split group")+    func moveEntryFansOut() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        let workID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertWork(id: workID, hostname: "dup.example", title: "A Serial", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.moveEntry(+            shared, basis: library.assignmentBasis(id: shared), to: .existing(workID))++        #expect(outcome == .committed)+        let rows = try library.entryRows(id: shared)+        #expect(rows.count == 2)+        #expect(rows.allSatisfy { $0.work?.id == workID })+        #expect(rows.allSatisfy { $0.workAssignmentProvenance == .manual })+    }++    /// The `.existing` no-op guard read `Set(rows.compactMap { $0.work?.id })`,+    /// which drops the rows pointing nowhere: a group with one row on the+    /// destination and one unattached reported `[workID]`, matched, and returned+    /// `.committed` having written nothing — leaving the twin unattached and its+    /// provenance never set to `.manual`, since that block sits after the early+    /// return. A no-op is only a no-op when every row is already where the write+    /// would put it.+    @Test("moveEntry to the Work one row already holds still repoints the twin")+    func moveEntryDoesNotShortCircuitAPartlyAssignedGroup() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        let workID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let work = store.insertWork(+                id: workID, hostname: "dup.example", title: "A Serial", offset: 0)+            let assigned = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            assigned.work = work+            assigned.workAssignmentProvenance = .manual+            // The twin points nowhere: the row a sync arrival left behind before+            // the assignment reached it.+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.moveEntry(+            shared, basis: library.assignmentBasis(id: shared), to: .existing(workID))++        #expect(outcome == .committed)+        let rows = try library.entryRows(id: shared)+        #expect(rows.count == 2)+        #expect(rows.allSatisfy { $0.work?.id == workID })+        #expect(rows.allSatisfy { $0.workAssignmentProvenance == .manual })+    }++    /// The converse: a group already wholly where the move would put it writes+    /// nothing, so the no-op is still a no-op.+    @Test("moveEntry is a no-op when every row already holds the Work manually")+    func moveEntryIsANoOpWhenTheGroupIsAlreadyThere() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        let workID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let work = store.insertWork(+                id: workID, hostname: "dup.example", title: "A Serial", offset: 0)+            for offset in [TimeInterval(0), 30] {+                let row = store.insertEntry(+                    id: shared, hostname: "dup.example", title: "Chapter", offset: offset)+                row.work = work+                row.workAssignmentProvenance = .manual+                // A sentinel the write would overwrite with the fixed clock's+                // value, so "nothing was written" is observable.+                row.modifiedAt = WriteFixture.epoch.addingTimeInterval(12_345)+            }+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.moveEntry(+            shared, basis: library.assignmentBasis(id: shared), to: .existing(workID))++        #expect(outcome == .committed)+        #expect(try library.entryRows(id: shared).allSatisfy {+            $0.modifiedAt == WriteFixture.epoch.addingTimeInterval(12_345)+        })+    }++    @Test("moveEntry refuses a torn group")+    func moveEntryRefusesTornGroup() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        let workID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertWork(id: workID, hostname: "dup.example", title: "A Serial", offset: 0)+            let first = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            first.note = "device one"+            let second = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            second.note = "device two"+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.moveEntry(+            shared,+            basis: EntryAssignmentBasis(+                workID: nil, intentionallyUnattached: false, hostname: "dup.example",+                conservativeIdentityKey: library.identityKey),+            to: .existing(workID))++        guard case .conflict(.torn) = outcome else {+            Issue.record("expected a torn conflict, got \(outcome)")+            return+        }+        #expect(try library.entryRows(id: shared).allSatisfy { $0.work == nil })+    }++    // MARK: - updateWork++    @Test("updateWork writes metadata to every row of a split Work group")+    func updateWorkFansOut() async throws {+        let library = try WriteFixture()+        let workID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertWork(id: workID, hostname: "dup.example", title: "A Serial", offset: 0)+            store.insertWork(id: workID, hostname: "dup.example", title: "A Serial", offset: 30)+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateWork(+            id: workID, basis: try library.workBasis(id: workID),+            draft: WorkMetadataDraft(+                displayTitle: "Renamed", type: .novel, genreTags: ["fantasy"],+                genericNotes: "reader prose"))++        #expect(outcome == .committed)+        let rows = try library.workRows(id: workID)+        #expect(rows.count == 2)+        #expect(rows.allSatisfy { $0.displayTitle == "Renamed" })+        #expect(rows.allSatisfy { $0.genericNotes == "reader prose" })+        #expect(rows.allSatisfy { $0.genreTags == ["fantasy"] })+        #expect(rows.allSatisfy { $0.type == .novel })+    }++    @Test("updateWork refuses a torn Work group")+    func updateWorkRefusesTornGroup() async throws {+        let library = try WriteFixture()+        let workID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertWork(+                id: workID, hostname: "dup.example", title: "A Serial", offset: 0)+            first.genericNotes = "device one"+            let second = store.insertWork(+                id: workID, hostname: "dup.example", title: "A Serial", offset: 30)+            second.genericNotes = "device two"+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateWork(+            id: workID,+            basis: WorkEditBasis(+                displayTitle: "A Serial", type: .other, genreTags: [],+                genericNotes: "device one", siteHostname: "dup.example",+                urlIdentity: nil, lastParsedTitle: "A Serial", titleProvenance: .parsed),+            draft: WorkMetadataDraft(+                displayTitle: "Renamed", type: .other, genreTags: [], genericNotes: "overwrite"))++        guard case .conflict(.torn) = outcome else {+            Issue.record("expected a torn conflict, got \(outcome)")+            return+        }+        #expect(Set(try library.workRows(id: workID).map(\.genericNotes))+            == ["device one", "device two"])+    }++    // MARK: - deleteEntry++    @Test("deleteEntry removes the whole group in one commit")+    func deleteEntryDeletesTheGroupWhole() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 60)+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.deleteEntry(+            id: shared, basis: library.entryBasis(id: shared), disclosedVariants: nil)++        #expect(outcome == .committed)+        #expect(try library.entryRows(id: shared).isEmpty)+    }++    /// Req 2.8: a reader deletion addressed to a torn group SHALL first disclose+    /// that differing copies exist. `nil` says no disclosure was made, and there+    /// is no disclosure a caller without an alert can honestly claim — so the+    /// delete refuses and the reader is sent to the resolution instead of the+    /// unseen variant going with the group.+    @Test("deleteEntry on a torn group refuses when nothing was disclosed")+    func deleteEntryRefusesAnUndisclosedTornGroup() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            first.note = "device one"+            let second = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            second.note = "device two"+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.deleteEntry(+            id: shared, basis: library.entryBasis(id: shared), disclosedVariants: nil)++        guard case .conflict(.torn(let recordID, let variants)) = outcome else {+            Issue.record("expected a torn refusal, got \(outcome)")+            return+        }+        #expect(recordID == shared)+        #expect(variants.count == 2)+        #expect(try library.entryRows(id: shared).count == 2)+    }++    /// The commit re-verifies the disclosed variants, so a copy that arrived+    /// after the alert was raised refuses the delete rather than being taken+    /// away unseen (Req 2.9).+    @Test("deleteEntry on a torn group refuses a disclosure that no longer matches")+    func deleteEntryRefusesAStaleDisclosure() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            first.note = "device one"+            let second = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            second.note = "device two"+        }+        // The alert listed the two copies the reader saw; a third landed before+        // they tapped Delete.+        let disclosed = WriteFixture.noteVariantIDs("device one", "device two")+        try library.seedAdditional { store in+            let arrived = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 60)+            arrived.note = "device three"+        }+        let reopened = try await library.openForApp()++        let outcome = try await reopened.deleteEntry(+            id: shared, basis: library.entryBasis(id: shared), disclosedVariants: disclosed)++        guard case .conflict(.disclosureStale(let recordID, let variants)) = outcome else {+            Issue.record("expected a stale disclosure, got \(outcome)")+            return+        }+        #expect(recordID == shared)+        #expect(variants.count == 3)+        #expect(try library.entryRows(id: shared).count == 3)+    }++    /// The disclosure is derived from the content the alert *showed*, not read+    /// back off the record at commit time — that is what makes it a disclosure+    /// and not a formality. `VariantID` is a digest over the variant's content+    /// (Q72), so the reader's side and the store's side can compute it+    /// independently and still agree.+    @Test("deleteEntry on a torn group proceeds when the disclosure still matches")+    func deleteEntryAcceptsAMatchingDisclosure() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            first.note = "device one"+            let second = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            second.note = "device two"+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.deleteEntry(+            id: shared, basis: library.entryBasis(id: shared),+            disclosedVariants: WriteFixture.noteVariantIDs("device one", "device two"))++        #expect(outcome == .committed)+        #expect(try library.entryRows(id: shared).isEmpty)+    }++    // MARK: - Successive authored writes (Req 2.7's headline invariant)++    /// Two ordinary curation edits in a row. This is what phase 1's single-row+    /// paths could not do: the first edit landed on one row, moved which row the+    /// ordering named, and the second landed on the twin — two edits, two rows,+    /// a group torn by the app rather than by sync (Q17, Decision 9). Every+    /// write addresses every row now, so nothing can move under the second one.+    @Test("Two successive curation edits leave a split group non-torn")+    func successiveCurationEditsLeaveTheGroupWhole() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            // Adversarial evidence: the rows differ in capture title, so the+            // representative order has something to move on.+            store.insertEntry(id: shared, hostname: "dup.example", title: "zzz", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", title: "aaa", offset: 30)+        }+        let repository = try await library.openForApp()++        let first = try await repository.updateEntry(+            id: shared, basis: library.entryBasis(id: shared), note: "first edit", rating: .up)+        #expect(first == .committed)++        let afterFirst = try await repository.entry(id: shared)+        let second = try await repository.updateEntry(+            id: shared, basis: EntryEditBasis(entry: afterFirst), note: "second edit",+            rating: .down)++        #expect(second == .committed)+        let rows = try library.entryRows(id: shared)+        #expect(rows.count == 2)+        #expect(Set(rows.map(\.note)) == ["second edit"])+        #expect(rows.allSatisfy { $0.rating == .down })+        let group = try #require(LibraryRepository.entryGroup(id: shared, rows: rows, canonicalWorkIDs: [:]))+        #expect(group.isTorn == false)+    }+}++// MARK: - Fixture++/// A fixed-path V5 library seeded through plain `insert`/`save` — the validating+/// commit path refuses these shapes, which is the point — then opened the way the+/// app opens it.+final class WriteFixture {+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    let directory: URL+    let configuration: LibraryConfiguration+    let identityKey = "https://dup.example/read/1"++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismFanOut-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+        try FileManager.default.createDirectory(+            at: configuration.v4StoreURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)+    }++    func seed(_ body: (WriteSeedStore) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let store = WriteSeedStore(context: ModelContext(container), identityKey: identityKey)+        try body(store)+        try store.context.save()+        try V5RelationshipPass.run(context: store.context)+        withExtendedLifetime(container) {}+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)+    }++    /// Adds rows to an already-seeded store, standing in for records arriving+    /// from a peer device.+    func seedAdditional(_ body: (WriteSeedStore) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let store = WriteSeedStore(context: ModelContext(container), identityKey: identityKey)+        try body(store)+        try store.context.save()+        try V5RelationshipPass.run(context: store.context)+        withExtendedLifetime(container) {}+    }++    func readContext() throws -> ModelContext {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        containers.append(container)+        return ModelContext(container)+    }++    func entryRows(id: UUID) throws -> [Entry] {+        try readContext().fetch(FetchDescriptor<Entry>()).filter { $0.id == id }+    }++    func workRows(id: UUID) throws -> [Work] {+        try readContext().fetch(FetchDescriptor<Work>()).filter { $0.id == id }+    }++    func allEntryRows() throws -> [Entry] {+        try readContext().fetch(FetchDescriptor<Entry>())+    }++    /// The contract the share extension commits from a `.new` lookup: race-guard+    /// keys set to the lookup candidate, which every Entry also stores as its+    /// conservative key (`ShareCaptureRootView.startNewCapture`).+    func guardedCaptureContract(+        repository: LibraryRepository, lookup: NewLookupBasis, note: String+    ) async throws -> CaptureContract {+        let base = try await repository.projectCapture(+            hostname: lookup.hostname, captureTitle: "Chapter",+            captureTitleSource: .safariDocument, rawURLString: identityKey,+            canonicalURLString: nil, note: note, rating: nil)+        return CaptureContract(+            basis: base.basis,+            request: CaptureRequest(+                captureTitle: "Chapter", captureTitleSource: .safariDocument,+                rawURLString: identityKey, canonicalURLString: nil, note: note,+                rating: nil, raceGuardKeys: [lookup.identityKey]),+            outcome: base.outcome)+    }++    func entryBasis(id: UUID) -> EntryEditBasis {+        EntryEditBasis(+            note: "", rating: nil, hostname: "dup.example", conservativeIdentityKey: identityKey)+    }++    func assignmentBasis(id: UUID) -> EntryAssignmentBasis {+        EntryAssignmentBasis(+            workID: nil, intentionallyUnattached: false, hostname: "dup.example",+            conservativeIdentityKey: identityKey)+    }++    func workBasis(id: UUID) throws -> WorkEditBasis {+        WorkEditBasis(+            displayTitle: "A Serial", type: .other, genreTags: [], genericNotes: "",+            siteHostname: "dup.example", urlIdentity: nil, lastParsedTitle: "A Serial",+            titleProvenance: .parsed)+    }++    /// The variant names an alert listing these notes would have shown. Derived+    /// from the content, not read back off the record — the whole point of a+    /// content-derived `VariantID` (Q72) is that the two sides can compute it+    /// separately.+    static func noteVariantIDs(_ notes: String...) -> Set<VariantID> {+        Set(notes.map { VariantID(components: EntryAuthoredContent(note: $0).orderComponents) })+    }++    /// A one-Entry archive addressed at `entryID` on this fixture's hostname and+    /// identity key — the upsert shape (Decision 8), so the plan updates rather+    /// than inserts.+    func importPlan(entryID: UUID, note: String, modifiedAt: Date) throws -> BackupImportV4Plan {+        let entry = BackupV4Entry(+            id: entryID, captureTitle: "Chapter", captureTitleSource: .host,+            rawURL: identityKey, canonicalURL: nil, hostname: "dup.example",+            entryIdentityKey: identityKey, identityKeyVersion: 1,+            conservativeIdentityKey: identityKey, identityBasis: .conservative,+            identityURLRuleID: nil, identityURLRuleVersion: nil,+            identityNameTitleRuleID: nil, identityNameTitleRuleVersion: nil,+            urlWorkIdentity: nil, urlWorkRuleID: nil, urlWorkRuleVersion: nil,+            chapterSequence: nil, chapterSequenceRuleID: nil, chapterSequenceRuleVersion: nil,+            chapterTitle: nil, chapterTitleProvenance: try FieldProvenance(kind: .none),+            note: note, rating: nil, firstCapturedAt: Self.epoch, lastSharedAt: Self.epoch,+            modifiedAt: modifiedAt, workID: nil,+            workAssignmentProvenance: try FieldProvenance(kind: .none),+            workURLRuleID: nil, workURLRuleVersion: nil, workURLAssignmentKind: nil,+            workPatternID: nil, workPatternVersion: nil, intentionallyUnattached: false)+        let site = BackupV4Site(+            hostname: "dup.example", displayName: "Dup", mode: .untaught,+            patternIDs: [], urlRuleIDs: [], junkSuffixRule: nil)+        let payload = BackupV4Payload(+            entries: [entry], works: [], sites: [site], titlePatterns: [], urlRules: [])+        return BackupImportV4Plan(+            metadata: BackupImportMetadata(+                formatVersion: 4, schemaVersion: 4, appBuild: "test",+                exportedAt: Self.epoch, capabilityGate: "m4",+                entryCount: 1, workCount: 0),+            payload: payload,+            counts: try LibraryRepository.validateImportPlanPayloadV4(payload))+    }++    func openForApp() async throws -> LibraryRepository {+        let (_, repository) = try await LibraryRepository.openV4ForApp(+            configuration, capabilities: .m4,+            clock: FixedRepositoryClock(Self.epoch),+            saveStrategy: ModelContextSaveStrategy())+        repositories.append(repository)+        return repository+    }++    private var containers: [ModelContainer] = []+    private var repositories: [LibraryRepository] = []++    deinit { try? FileManager.default.removeItem(at: directory) }+}++final class WriteSeedStore {+    let context: ModelContext+    let identityKey: String++    init(context: ModelContext, identityKey: String) {+        self.context = context+        self.identityKey = identityKey+    }++    @discardableResult+    func insertSite(hostname: String, displayName: String? = nil) -> Site {+        let site = Site(hostname: hostname, displayName: displayName)+        context.insert(site)+        return site+    }++    @discardableResult+    func insertEntry(+        id: UUID = UUID(), hostname: String, title: String, offset: TimeInterval,+        url: String? = nil+    ) -> Entry {+        let rawURL = url ?? identityKey+        let entry = Entry(+            id: id, captureTitle: title, captureTitleSource: .host, rawURLString: rawURL,+            hostname: hostname, entryIdentityKey: rawURL,+            timestamp: WriteFixture.epoch.addingTimeInterval(offset))+        entry.conservativeIdentityKey = rawURL+        context.insert(entry)+        return entry+    }++    @discardableResult+    func insertTitlePattern(+        id: UUID = UUID(), site: Site, isActive: Bool = false, offset: TimeInterval = 0+    ) throws -> TitlePattern {+        let pattern = try TitlePattern(+            id: id, version: (site.patternValues.map(\.version).max() ?? 0) + 1,+            isActive: isActive, createdAt: WriteFixture.epoch.addingTimeInterval(offset),+            definition: .phrase(prefix: "", separator: " - ", suffix: "", order: .workThenChapter), site: site)+        context.insert(pattern)+        site.patterns = site.patternValues + [pattern]+        return pattern+    }++    @discardableResult+    func insertWork(+        id: UUID = UUID(), hostname: String, title: String, offset: TimeInterval+    ) -> Work {+        let work = Work(+            id: id, displayTitle: title, siteHostname: hostname,+            timestamp: WriteFixture.epoch.addingTimeInterval(offset))+        // A parsed Work, which is what teaching produces and what "bare" means+        // for a Work: `titleProvenance` defaults to `.manual`, so a seeded Work+        // left alone would read as reader-authored and every pair of them as+        // torn (Q34).+        work.lastParsedTitle = title+        work.titleProvenanceRaw = TitleProvenance.parsed.rawValue+        context.insert(work)+        return work+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift Added +626 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swiftnew file mode 100644index 0000000..2b0c444--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift@@ -0,0 +1,626 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Task 21: backup export over identity groups (Req 8.1–8.5).+///+/// The state this replaces: `requireUniqueIdentities` refused the export for+/// **any** repeated application UUID, torn or not. Two rows of one Entry that+/// agree about everything the reader wrote are one record — the app presents+/// them as one everywhere else — and refusing a backup over them is the dead end+/// this milestone removes. What the archive genuinely cannot hold is a *torn*+/// group: two authored variants under one UUID, which is one record with two+/// notes.+///+/// So the projection resolves what it can and refuses only what it must, and the+/// refusal carries a payload the Settings surface can turn into a count and a+/// route (Req 8.4).+@Suite("Backup export group projection", .serialized)+struct BackupGroupProjectionTests {++    // MARK: - Req 8.2: a non-torn split group exports as one record++    @Test("A split Entry group agreeing on authored content exports as one record")+    func nonTornEntryGroupExportsOnce() throws {+        let store = try DuplicateStore()+        store.addSite()+        let shared = UUID()+        // Two rows, one UUID, one note between them: the bare twin carries+        // nothing to lose, so the group has a single authored variant (Decision+        // 1) and is not torn.+        store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 10, sharedAt: 10,+            title: "Chapter 1", note: "the note")+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 40, sharedAt: 90, title: "Chapter 1")+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.entries.count == 1)+        let entry = try #require(payload.entries.first)+        #expect(entry.id == shared)+        // The group's authored content, not a possibly-bare representative+        // row's (Q41).+        #expect(entry.note == "the note")+        // Member timestamps: earliest capture, latest share (Definitions).+        #expect(entry.firstCapturedAt == DuplicateStore.epoch.addingTimeInterval(10))+        #expect(entry.lastSharedAt == DuplicateStore.epoch.addingTimeInterval(90))+    }++    @Test("A split Entry group's archive re-decodes as a strict 4/4 document")+    func nonTornEntryGroupArchiveDecodes() throws {+        let store = try DuplicateStore()+        store.addSite()+        let shared = UUID()+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 10, note: "the note")+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 40)+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }+        let encoded = try BackupV4Codec.encode(+            payload: payload,+            metadata: BackupV4Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))++        // The decode gate is the reference validator, which refuses a payload+        // holding one UUID twice — the shape the projection exists to prevent+        // reaching it.+        let decoded = try BackupV4Codec.decode(encoded)+        #expect(decoded.payload.entries.count == 1)+    }++    @Test("A split Work group exports once, its Entries unioned across every row")+    func nonTornWorkGroupExportsOnce() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let shared = UUID()+        let first = store.addWork(+            id: shared, title: "A Serial", urlIdentity: "serial", createdAt: 5, site: site)+        let second = store.addWork(+            id: shared, title: "A Serial", urlIdentity: "serial", createdAt: 20, site: site)+        // One Entry on each row of the group. Req 5.5: Entries assigned to any+        // row count under the one Work, so the archive's `entryIDs` is the+        // union.+        store.addEntry(key: "chapter-1", capturedAt: 10, work: first, site: site)+        store.addEntry(key: "chapter-2", capturedAt: 20, work: second, site: site)+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.works.count == 1)+        let work = try #require(payload.works.first)+        #expect(work.id == shared)+        #expect(work.entryIDs.count == 2)+        #expect(work.createdAt == DuplicateStore.epoch.addingTimeInterval(5))+    }++    /// The dedup is by *projected* Entry UUID, not by row: a split Entry group+    /// whose rows hang off both rows of a split Work group is one Entry under+    /// one Work, and listing its UUID twice is a duplicate reference the+    /// archive's own validator refuses.+    @Test("A Work group's entryIDs are deduped by projected Entry UUID")+    func workGroupEntryIDsDedupeByEntryUUID() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let workID = UUID()+        let entryID = UUID()+        let first = store.addWork(+            id: workID, title: "A Serial", urlIdentity: "serial", createdAt: 5, site: site)+        let second = store.addWork(+            id: workID, title: "A Serial", urlIdentity: "serial", createdAt: 20, site: site)+        store.addEntry(id: entryID, key: "chapter-1", capturedAt: 10, work: first, site: site)+        store.addEntry(id: entryID, key: "chapter-1", capturedAt: 30, work: second, site: site)+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.entries.count == 1)+        let work = try #require(payload.works.first)+        #expect(work.entryIDs == [entryID])+    }++    /// A **derived** assignment is not authored content: `authoredContent` reads+    /// `work?.id` only where the provenance is `.manual` (Decision 10), so a+    /// group whose rows disagree about a derived assignment is bare-and-bare,+    /// not torn — and the archived `workID` is whatever the carrier holds, which+    /// for an all-bare group is the representative.+    ///+    /// Recorded rather than repaired (Q117). The consequence is visible here: the+    /// Work still lists the Entry, because `entryIDs` unions every row, while the+    /// Entry names no Work. The file decodes — nothing cross-checks the two — and+    /// a re-import reads `record.workID` alone, so the derived assignment does+    /// not survive the round trip. Req 2.7 fans derived writes across every row,+    /// so the state is an arrival window rather than a settled shape.+    @Test("An Entry group whose rows disagree about a derived assignment is not torn")+    func derivedAssignmentDisagreementIsNotTorn() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let work = store.addWork(+            id: DuplicateStore.rankedID(1), title: "A Serial", createdAt: 5, site: site)+        let shared = UUID()+        // The representative — earliest capture — is the row pointing nowhere.+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 10, site: site)+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 40, work: work, site: site)+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        let entry = try #require(payload.entries.first)+        let archivedWork = try #require(payload.works.first)+        #expect(entry.workID == nil)+        #expect(archivedWork.entryIDs == [shared])+        // It is a legal 4/4 document: the reference validator checks that a+        // Work's Entries exist, never that they name it back.+        let encoded = try BackupV4Codec.encode(+            payload: payload,+            metadata: BackupV4Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+        _ = try BackupV4Codec.decode(encoded)+    }++    /// The Definitions' assignment normalisation, in the export (Q106): rows+    /// pointing at two *members of one Work set* agree about their assignment, so+    /// the group is whole rather than torn — without it this library would refuse+    /// an export the screens all present as one record.+    ///+    /// What the archive then holds is the cross-listing: the Entry names the+    /// carrier's Work, and **both** members list it, because each one's row+    /// genuinely holds a row of the Entry group (Req 5.5's union). The+    /// normalisation is an equality key and never a write value (Q67), so no+    /// re-pointing happens here.+    @Test("An Entry group spanning two members of one Work set exports whole")+    func entryGroupSpanningOneWorkSetExportsWhole() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        // One Work set by parsed title (Q64), which is all the normalisation+        // needs — and no `urlIdentity`, whose state column the 4/4 tuple table+        // would then require.+        let first = store.addWork(+            id: DuplicateStore.rankedID(1), title: "A Serial", createdAt: 5, site: site)+        let second = store.addWork(+            id: DuplicateStore.rankedID(2), title: "A Serial", createdAt: 6, site: site)+        let shared = UUID()+        let rowA = store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 10, work: first, site: site)+        let rowB = store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 40, work: second, site: site)+        // Manual, so the assignment *is* authored content and the normalisation+        // is what keeps the two rows from reading as two variants.+        rowA.workAssignmentProvenanceRaw = FieldProvenanceKind.manual.rawValue+        rowB.workAssignmentProvenanceRaw = FieldProvenanceKind.manual.rawValue+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.entries.count == 1)+        let entry = try #require(payload.entries.first)+        #expect(entry.workID == DuplicateStore.rankedID(1))+        #expect(payload.works.allSatisfy { $0.entryIDs == [shared] })+        let encoded = try BackupV4Codec.encode(+            payload: payload,+            metadata: BackupV4Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+        _ = try BackupV4Codec.decode(encoded)+    }++    // MARK: - Req 8.3: unique-UUID set members never block export++    @Test("Two Entries sharing a conservative key but not a UUID both export")+    func distinctUUIDSetMembersBothExport() throws {+        let store = try DuplicateStore()+        store.addSite()+        // A duplicate set of two logical records — the ordinary sync duplicate.+        // Nothing about it is unrepresentable: the archive holds two records,+        // and reconciliation collapses them later.+        store.addEntry(key: "chapter-1", capturedAt: 10, note: "from the phone")+        store.addEntry(key: "chapter-1", capturedAt: 20, note: "from the laptop")+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.entries.count == 2)+        #expect(Set(payload.entries.map(\.note)) == ["from the phone", "from the laptop"])+    }++    // MARK: - Req 8.1: a torn group, and only a torn group, refuses++    @Test("A torn Entry group refuses with a payload naming how many block the export")+    func tornEntryGroupRefuses() throws {+        let store = try DuplicateStore()+        store.addSite()+        let shared = UUID()+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 10, note: "from the phone")+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 20, note: "from the laptop")+        try store.commit()++        let payload = try expectTornRefusal {+            _ = try store.read { try LibraryRepository.projectV4Payload(context: $0) }+        }++        #expect(payload.count == 1)+        // Decision 14: two arms, not three. Nothing here is "still settling" —+        // a torn group is divergent by definition and always ends at the reader.+        #expect(payload.blockingWorkSet == nil)+    }++    @Test("The refusal counts every torn group, Entries and Works alike")+    func tornGroupCountCoversBothTypes() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let entryID = UUID()+        store.addEntry(id: entryID, key: "chapter-1", capturedAt: 10, note: "phone", site: site)+        store.addEntry(id: entryID, key: "chapter-1", capturedAt: 20, note: "laptop", site: site)+        let workID = UUID()+        store.addWork(+            id: workID, title: "A Serial", urlIdentity: "serial", createdAt: 5,+            notes: "phone notes", site: site)+        store.addWork(+            id: workID, title: "A Serial", urlIdentity: "serial", createdAt: 6,+            notes: "laptop notes", site: site)+        try store.commit()++        let payload = try expectTornRefusal {+            _ = try store.read { try LibraryRepository.projectV4Payload(context: $0) }+        }++        #expect(payload.count == 2)+    }++    @Test("A torn Work group refuses even though its Entries are whole")+    func tornWorkGroupRefuses() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let workID = UUID()+        store.addWork(+            id: workID, title: "A Serial", urlIdentity: "serial", createdAt: 5,+            notes: "phone notes", site: site)+        store.addWork(+            id: workID, title: "A Serial", urlIdentity: "serial", createdAt: 6,+            notes: "laptop notes", site: site)+        try store.commit()++        let payload = try expectTornRefusal {+            _ = try store.read { try LibraryRepository.projectV4Payload(context: $0) }+        }++        #expect(payload.count == 1)+        #expect(payload.blockingWorkSet == nil)+    }++    // MARK: - Req 8.4: the refusal points at the blocking Work set++    /// Req 1.6/Q32: a torn group whose set is deferred behind a divergent Work+    /// set has no reachable sheet of its own, so the refusal names the Work set+    /// the reader can actually act on.+    @Test("A torn group deferred behind a divergent Work set names that Work set")+    func deferredTornGroupNamesItsBlockingWorkSet() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        // A divergent Work set: two logical Works, one identity, disagreeing+        // notes. No torn member, so it does not block the export on its own+        // account (Req 8.3) — it blocks the Entry set's resolution.+        let workA = store.addWork(+            id: DuplicateStore.rankedID(1), title: "A Serial", urlIdentity: "serial",+            createdAt: 5, notes: "phone notes", site: site)+        let workB = store.addWork(+            id: DuplicateStore.rankedID(2), title: "A Serial", urlIdentity: "serial",+            createdAt: 6, notes: "laptop notes", site: site)+        let shared = UUID()+        store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 10, note: "phone", work: workA, site: site)+        store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 20, note: "laptop", work: workB, site: site)+        try store.commit()++        let payload = try expectTornRefusal {+            _ = try store.read { try LibraryRepository.projectV4Payload(context: $0) }+        }++        #expect(payload.count == 1)+        let blocking = try #require(payload.blockingWorkSet)+        #expect(blocking.recordType == .work)+        #expect(Set(blocking.memberIDs) == [workA.id, workB.id])+    }++    /// Q111's whole justification, exercised: two torn groups deferred behind+    /// **two different** Work sets have no single target, and naming one would+    /// send the reader to a decision that unblocks half the refusal and returns+    /// them to the same message. Arm 1 — the count, and Check Library, which+    /// lists all of them — is the honest thing to say.+    @Test("Torn groups deferred behind two different Work sets name neither")+    func tornGroupsBehindTwoWorkSetsNameNoBlockingSet() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        for (suffix, rank) in [("a", 1), ("b", 3)] {+            let first = store.addWork(+                id: DuplicateStore.rankedID(rank), title: "Serial \(suffix)",+                urlIdentity: "serial-\(suffix)", createdAt: 5, notes: "phone notes", site: site)+            let second = store.addWork(+                id: DuplicateStore.rankedID(rank + 1), title: "Serial \(suffix)",+                urlIdentity: "serial-\(suffix)", createdAt: 6, notes: "laptop notes", site: site)+            let shared = UUID()+            store.addEntry(+                id: shared, key: "chapter-\(suffix)", capturedAt: 10, note: "phone",+                work: first, site: site)+            store.addEntry(+                id: shared, key: "chapter-\(suffix)", capturedAt: 20, note: "laptop",+                work: second, site: site)+        }+        try store.commit()++        let payload = try expectTornRefusal {+            _ = try store.read { try LibraryRepository.projectV4Payload(context: $0) }+        }++        #expect(payload.count == 2)+        #expect(payload.blockingWorkSet == nil)+    }++    /// A torn **Work** group waits behind nothing at all — Req 1.6 defers Entry+    /// sets, not Work sets — so one in the refusal takes the blocking name off+    /// even where every torn Entry group is deferred behind one Work set.+    @Test("A torn Work group beside a deferred torn Entry group names no blocking set")+    func aTornWorkGroupSuppressesTheBlockingWorkSet() throws {+        let store = try DuplicateStore()+        let site = store.addSite()+        let workA = store.addWork(+            id: DuplicateStore.rankedID(1), title: "A Serial", urlIdentity: "serial",+            createdAt: 5, notes: "phone notes", site: site)+        let workB = store.addWork(+            id: DuplicateStore.rankedID(2), title: "A Serial", urlIdentity: "serial",+            createdAt: 6, notes: "laptop notes", site: site)+        let shared = UUID()+        store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 10, note: "phone", work: workA, site: site)+        store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 20, note: "laptop", work: workB, site: site)+        // A torn Work group of its own, on a different work identity.+        let tornWorkID = DuplicateStore.rankedID(3)+        store.addWork(+            id: tornWorkID, title: "Another Serial", urlIdentity: "other", createdAt: 5,+            notes: "phone notes", site: site)+        store.addWork(+            id: tornWorkID, title: "Another Serial", urlIdentity: "other", createdAt: 6,+            notes: "laptop notes", site: site)+        try store.commit()++        let payload = try expectTornRefusal {+            _ = try store.read { try LibraryRepository.projectV4Payload(context: $0) }+        }++        #expect(payload.count == 2)+        #expect(payload.blockingWorkSet == nil)+    }++    // MARK: - Req 8.5: the next export succeeds++    @Test("Resolving the last torn group lets the next export succeed unaided")+    func nextExportSucceedsAfterResolution() throws {+        let store = try DuplicateStore()+        store.addSite()+        let shared = UUID()+        let first = store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 10, note: "from the phone")+        let second = store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 20, note: "from the laptop")+        try store.commit()++        _ = try expectTornRefusal {+            _ = try store.read { try LibraryRepository.projectV4Payload(context: $0) }+        }++        // The resolution outcome: both rows carry the chosen variant (Req+        // 2.7/4.4). Nothing else about the store changes.+        first.note = "from the phone"+        second.note = "from the phone"+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }+        #expect(payload.entries.count == 1)+        #expect(payload.entries.first?.note == "from the phone")+    }++    // MARK: - Rule groups (Req 8.2, task 20.4)++    @Test("One rule UUID across two Site rows of a hostname archives as one rule")+    func ruleGroupAcrossTwoSiteRowsArchivesOnce() throws {+        let store = try DuplicateStore()+        let ruleID = UUID()+        let first = store.addSite(displayName: "first", mode: .taught)+        let second = store.addSite(displayName: "second", mode: .taught)+        try store.addPattern(id: ruleID, site: first, version: 1, active: true, createdAt: 0)+        try store.addPattern(id: ruleID, site: second, version: 3, active: false, createdAt: 0)+        store.addEntry(key: "chapter-1", capturedAt: 10, site: first)+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.titlePatterns.count == 1)+        #expect(payload.sites.count == 1)+        #expect(payload.sites.first?.patternIDs == [ruleID])+        // The archive re-decodes: a payload holding one rule UUID twice is what+        // the reference validator refuses, and what the store validates it must+        // be able to export (task 20.4).+        let encoded = try BackupV4Codec.encode(+            payload: payload,+            metadata: BackupV4Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+        _ = try BackupV4Codec.decode(encoded)+    }++    /// The dedup must not cost a hostname its active title rule.+    ///+    /// The representative ordering runs hostname, `createdAt`, **version**, then+    /// the flag, so version decides wherever two rows of a group differ in it+    /// and the flag never gets a vote. Deduped to the lowest-versioned row, a+    /// group whose active row is the higher-versioned one archives with no+    /// active rule at all — and a `.taught` Site with no active title rule is+    /// the shape `requireProjectedTuplesRepresentable` refuses as+    /// references-still-arriving, forever, since the library is already at the+    /// reconciler's fixed point.+    @Test("A rule group whose active row is the higher version keeps its active flag")+    func activeRuleRowAtAHigherVersionKeepsItsFlag() throws {+        let store = try DuplicateStore()+        let site = store.addSite(mode: .taught)+        let ruleID = UUID()+        try store.addPattern(id: ruleID, site: site, version: 1, active: false, createdAt: 0)+        try store.addPattern(id: ruleID, site: site, version: 3, active: true, createdAt: 0)+        store.addEntry(key: "chapter-1", capturedAt: 10, site: site)+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.titlePatterns.count == 1)+        #expect(payload.titlePatterns.first?.isActive == true)+        let encoded = try BackupV4Codec.encode(+            payload: payload,+            metadata: BackupV4Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+        _ = try BackupV4Codec.decode(encoded)+    }++    /// The URL-rule half, which fails *silently* rather than refusing: nothing+    /// in `requireProjectedTuplesRepresentable` or in the archive's own+    /// reference validator asks a taught Site to hold a current URL rule, so a+    /// group deduped to its retained row exports a hostname whose identity+    /// teaching has quietly become history.+    @Test("A URL-rule group whose current row is the higher version keeps its current flag")+    func currentURLRuleRowAtAHigherVersionKeepsItsFlag() throws {+        let store = try DuplicateStore()+        let site = store.addSite(mode: .taught)+        try store.addPattern(id: UUID(), site: site, version: 1, active: true, createdAt: 0)+        let ruleID = UUID()+        try store.addURLRule(id: ruleID, site: site, version: 1, current: false, createdAt: 0)+        try store.addURLRule(id: ruleID, site: site, version: 3, current: true, createdAt: 0)+        store.addEntry(key: "chapter-1", capturedAt: 10, site: site)+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.urlRules.count == 1)+        #expect(payload.urlRules.first?.isCurrent == true)+    }++    /// The shape is a **fixed point**, which is what makes the refusal above+    /// permanent rather than an arrival window.+    ///+    /// `alignVersions` asks per row whether the target version is free on that+    /// row's Site, reading live values — so for two rows of one group on one+    /// Site row the answer is always no: the target is the representative's+    /// version and the representative is sitting on that very row. Decision 13's+    /// "never within one Site row", derived rather than special-cased. And+    /// `demoteWithinSites` demotes without ever promoting, so the flag stays+    /// where it landed. A pass changes nothing, and the next one changes nothing+    /// again.+    @Test("A same-Site rule group spanning versions is a fixed point, and exports")+    func versionSpanningRuleGroupIsAFixedPointAndExports() throws {+        let store = try DuplicateStore()+        let site = store.addSite(mode: .taught)+        let ruleID = UUID()+        try store.addPattern(id: ruleID, site: site, version: 1, active: false, createdAt: 0)+        try store.addPattern(id: ruleID, site: site, version: 3, active: true, createdAt: 0)+        store.addEntry(key: "chapter-1", capturedAt: 10, site: site)+        try store.commit()++        #expect(try store.reconcileToFixedPoint() == 1)++        let facts = try store.patternFacts()+        #expect(facts.map(\.version).sorted() == [1, 3])+        #expect(facts.first(where: \.isActive)?.version == 3)+        #expect(try store.diagnose().quarantineMap().isEmpty)++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.titlePatterns.count == 1)+        #expect(payload.titlePatterns.first?.isActive == true)+    }++    /// Even in the benign direction — the active row representing on its own —+    /// nothing asserted that the flag reached the archive. The invariant the+    /// archive needs is that the dedup keeps the group's custody, whichever row+    /// holds it.+    @Test("The dedup keeps the active flag when the representative is the marked row")+    func dedupKeepsTheActiveFlagInTheBenignDirection() throws {+        let store = try DuplicateStore()+        let site = store.addSite(mode: .taught)+        let ruleID = UUID()+        try store.addPattern(id: ruleID, site: site, version: 1, active: true, createdAt: 0)+        try store.addPattern(id: ruleID, site: site, version: 3, active: false, createdAt: 0)+        store.addEntry(key: "chapter-1", capturedAt: 10, site: site)+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        #expect(payload.titlePatterns.count == 1)+        #expect(payload.titlePatterns.first?.isActive == true)+    }++    /// Req 6.2's export half. A citation naming the version the *dropped* row+    /// held has nowhere to resolve once the group archives once, so the+    /// projection emits `rewrites[ruleUUID] = representative.version` and the+    /// citing record is archived against the version the file actually holds.+    @Test("A citation naming the dropped row's version is rewritten to the archived one")+    func droppedRuleRowVersionIsRewritten() throws {+        let store = try DuplicateStore()+        let ruleID = UUID()+        let first = store.addSite(displayName: "first", mode: .taught)+        let second = store.addSite(displayName: "second", mode: .taught)+        try store.addPattern(id: ruleID, site: first, version: 1, active: true, createdAt: 0)+        try store.addPattern(id: ruleID, site: second, version: 3, active: false, createdAt: 0)+        let entry = store.addEntry(key: "chapter-1", capturedAt: 10, site: first)+        entry.chapterTitle = "Chapter 1"+        entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue+        entry.chapterPatternID = ruleID+        entry.chapterPatternVersion = 3+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        let pattern = try #require(payload.titlePatterns.first)+        let archived = try #require(payload.entries.first)+        #expect(archived.chapterTitleProvenance.patternID == ruleID)+        #expect(archived.chapterTitleProvenance.patternVersion == pattern.version)+    }++    /// `citerHostnames` walks every row, not the projected records: a rule whose+    /// own Site row has not arrived is placed through *any* row that names it,+    /// including a losing row of a split group whose citation the projection+    /// does not carry. Placing it is what keeps the export from refusing as+    /// references-still-arriving over a rule the library holds.+    @Test("A nil-site rule cited only by a losing row of a split group is still placed")+    func nilSiteRuleCitedByALosingRowIsPlaced() throws {+        let store = try DuplicateStore()+        let site = store.addSite(mode: .taught)+        let ruleID = UUID()+        try store.addPattern(id: ruleID, site: nil, version: 1, active: true, createdAt: 0)+        let shared = UUID()+        // The representative — earlier capture evidence, no citation.+        store.addEntry(id: shared, key: "chapter-1", capturedAt: 10, title: "A", site: site)+        let losing = store.addEntry(+            id: shared, key: "chapter-1", capturedAt: 20, title: "B", site: site)+        losing.chapterTitle = "Chapter 1"+        losing.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue+        losing.chapterPatternID = ruleID+        losing.chapterPatternVersion = 1+        try store.commit()++        let payload = try store.read { try LibraryRepository.projectV4Payload(context: $0) }++        let pattern = try #require(payload.titlePatterns.first)+        #expect(pattern.id == ruleID)+        #expect(pattern.siteHostname == DuplicateStore.hostname)+    }++    // MARK: - Helpers++    private func expectTornRefusal(_ body: () throws -> Void) throws -> TornGroupsPayload {+        do {+            try body()+            Issue.record("expected a torn-groups refusal, but the export proceeded")+            return TornGroupsPayload(count: 0, blockingWorkSet: nil)+        } catch let error as BackupV4ExportError {+            guard case .tornGroups(let payload) = error else {+                Issue.record("expected .tornGroups, got \(error)")+                return TornGroupsPayload(count: 0, blockingWorkSet: nil)+            }+            return payload+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift Added +626 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swiftnew file mode 100644index 0000000..7b5dd86--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift@@ -0,0 +1,626 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// The three orderings duplicate reconciliation runs on (design §Orderings):+/// representative row, authored variant, and survivor member.+///+/// The property that makes them different from `RecordResolutionOrder` is what+/// they *must not* read. That order ends on `PersistentIdentifier`, which two+/// devices assign differently for the same logical rows+/// (`IdentityResolution.swift:91-96`), so it can pick a presentation winner but+/// never a write basis. These orderings end on synced content and stop there:+/// rows equal in every synced field are interchangeable (Q36), which is+/// asserted here as a tie rather than papered over with a tiebreak.+@Suite("Group ordering", .serialized)+struct GroupOrderingTests {++    // MARK: - Representative row: evidence, then authored, then timestamps++    @Test("Immutable capture evidence decides before anything a reader wrote")+    func evidencePrecedesAuthoredContent() throws {+        let store = try OrderingStore()+        // The row with the later capture title carries a note; the earlier one is+        // bare. Evidence is the first tuple, so the bare row still represents.+        let bare = store.addEntry(captureTitle: "aaa chapter")+        let authored = store.addEntry(captureTitle: "zzz chapter")+        authored.note = "something the reader wrote"+        try store.commit()++        let representative = GroupOrdering.representativeEntry([authored, bare])++        #expect(representative === bare)+    }++    @Test("Entry evidence orders through every field of the tuple")+    func entryEvidenceTupleOrder() throws {+        let store = try OrderingStore()+        let base = store.addEntry(captureTitle: "same")+        let laterSource = store.addEntry(captureTitle: "same")+        laterSource.captureTitleSource = .safariDocument  // "manual" < "safariDocument"+        let laterRawURL = store.addEntry(captureTitle: "same")+        laterRawURL.rawURLString = "https://\(OrderingStore.hostname)/read/zzz"+        let laterCanonical = store.addEntry(captureTitle: "same")+        laterCanonical.canonicalURLString = "https://example.test/canonical"+        let laterHost = store.addEntry(captureTitle: "same")+        laterHost.hostname = "zzz.example"+        let laterKey = store.addEntry(captureTitle: "same")+        laterKey.conservativeIdentityKey = "zzz"+        let laterCapture = store.addEntry(captureTitle: "same", offset: 60)+        try store.commit()++        // `base` carries the least value in every slot but the canonical URL,+        // where absence sorts last — the more-specified row represents, so+        // `laterCanonical` leads instead.+        #expect(GroupOrdering.representativeEntry([laterSource, base]) === base)+        #expect(GroupOrdering.representativeEntry([laterRawURL, base]) === base)+        #expect(GroupOrdering.representativeEntry([base, laterCanonical]) === laterCanonical)+        #expect(GroupOrdering.representativeEntry([laterHost, base]) === base)+        #expect(GroupOrdering.representativeEntry([laterKey, base]) === base)+        #expect(GroupOrdering.representativeEntry([laterCapture, base]) === base)+    }++    @Test("Authored content decides between rows whose evidence is identical")+    func authoredContentDecidesAfterEvidence() throws {+        let store = try OrderingStore()+        let alpha = store.addEntry(captureTitle: "same")+        alpha.note = "alpha"+        let beta = store.addEntry(captureTitle: "same")+        beta.note = "beta"+        try store.commit()++        #expect(GroupOrdering.representativeEntry([beta, alpha]) === alpha)+    }++    @Test("Timestamps decide only after evidence and authored content tie")+    func timestampsDecideLast() throws {+        let store = try OrderingStore()+        let early = store.addEntry(captureTitle: "same")+        let late = store.addEntry(captureTitle: "same")+        late.lastSharedAt = OrderingStore.epoch.addingTimeInterval(500)+        let latest = store.addEntry(captureTitle: "same")+        latest.modifiedAt = OrderingStore.epoch.addingTimeInterval(900)+        try store.commit()++        #expect(GroupOrdering.representativeEntry([late, early]) === early)+        #expect(GroupOrdering.representativeEntry([latest, early]) === early)+    }++    /// Q36: the ordering is not total over rows that agree on everything synced,+    /// and does not need to be. What it must not do is reach for the store's+    /// device-local identifier to break the tie.+    @Test("Rows equal in every synced field are interchangeable, with no identifier tiebreak")+    func identicalRowsAreInterchangeable() throws {+        let store = try OrderingStore()+        let first = store.addEntry(captureTitle: "same")+        let second = store.addEntry(captureTitle: "same")+        try store.commit()++        // Saved rows, so both hold permanent — and different —+        // `PersistentIdentifier`s. Neither ordering direction may hold.+        #expect(first.persistentModelID != second.persistentModelID)+        #expect(GroupOrdering.entryRowPrecedes(first, second) == false)+        #expect(GroupOrdering.entryRowPrecedes(second, first) == false)+        #expect(GroupOrdering.entryRowsAreInterchangeable(first, second))+        // A stable sort therefore returns whatever order it was handed.+        #expect(GroupOrdering.sortedEntryRows([second, first]).first === second)+    }++    @Test("Entry representative order is permutation-invariant where rows differ")+    func entryRepresentativeIsPermutationInvariant() throws {+        let store = try OrderingStore()+        let rows = (0..<6).map { index in+            let entry = store.addEntry(captureTitle: "chapter \(index)", offset: TimeInterval(index))+            entry.note = index.isMultiple(of: 2) ? "note \(index)" : ""+            return entry+        }+        try store.commit()++        let expected = GroupOrdering.sortedEntryRows(rows).map(\.captureTitle)+        var generator = SystemRandomNumberGenerator()+        for _ in 0..<20 {+            let shuffled = rows.shuffled(using: &generator)+            #expect(GroupOrdering.sortedEntryRows(shuffled).map(\.captureTitle) == expected)+        }+    }++    // MARK: - Representative row: Works++    @Test("Work representative reads hostname, then createdAt, then authored content")+    func workRepresentativeOrder() throws {+        let store = try OrderingStore()+        let early = store.addWork(title: "A Work", offset: 0)+        let late = store.addWork(title: "A Work", offset: 60)+        let otherHost = store.addWork(title: "A Work", offset: 0, hostname: "zzz.example")+        try store.commit()++        #expect(GroupOrdering.representativeWork([late, early]) === early)+        #expect(GroupOrdering.representativeWork([otherHost, late]) === late)++        let bare = store.addWork(title: "A Work", offset: 120)+        let authored = store.addWork(title: "A Work", offset: 120)+        authored.genericNotes = "reader prose"+        try store.commit()+        // Equal evidence, so the authored tuple decides and an empty note sorts+        // before a written one.+        #expect(GroupOrdering.representativeWork([authored, bare]) === bare)+    }++    // MARK: - Representative row: rule records (the Req 6.1 convergence selector)++    @Test("Title rule representative reads the owning Site's hostname first")+    func ruleRepresentativeReadsOwningHostname() throws {+        let store = try OrderingStore()+        let early = store.addSite(hostname: "aaa.example")+        let late = store.addSite(hostname: "zzz.example")+        let onEarly = try store.addPattern(site: early, version: 9, createdAtOffset: 90)+        let onLate = try store.addPattern(site: late, version: 1, createdAtOffset: 0)+        try store.commit()++        // The hostname outranks both createdAt and version.+        #expect(GroupOrdering.representativePattern([onLate, onEarly]) === onEarly)+    }++    /// Q63: `setImmutableDefinition` writes every decomposed column *except* the+    /// trims, so a canonical form that skipped them would call two rows that+    /// derive different chapter titles "converged".+    @Test("Title rule canonical form separates rows differing only in their trims")+    func canonicalPatternFormIncludesTrims() throws {+        let store = try OrderingStore()+        let site = store.addSite(hostname: "trim.example")+        let untrimmed = try store.addPattern(site: site, version: 1, createdAtOffset: 0)+        let trimmed = try store.addPattern(site: site, version: 1, createdAtOffset: 0)+        trimmed.trimPrefix = "Read: "+        let suffixTrimmed = try store.addPattern(site: site, version: 1, createdAtOffset: 0)+        suffixTrimmed.trimSuffix = " - Free"+        try store.commit()++        #expect(GroupOrdering.canonicalDefinition(untrimmed)+            != GroupOrdering.canonicalDefinition(trimmed))+        #expect(GroupOrdering.canonicalDefinition(untrimmed)+            != GroupOrdering.canonicalDefinition(suffixTrimmed))+        #expect(GroupOrdering.patternRowsAreInterchangeable(untrimmed, trimmed) == false)+        // Absence sorts last in the canonical encoding, the same rule the+        // component order uses, so **the more-specified definition represents**:+        // between two rows differing only in whether a field is set, the row+        // that sets it converges the group (Req 6.1's selector). Applied to+        // trims that means the trimming row wins, which is the reading that+        // keeps Req 6.2 honest — a cited rule replays the definition that+        // actually derives a chapter title, rather than the one that drops the+        // trim (Decision 7 accepts the loss only for the *losing* definition).+        #expect(GroupOrdering.representativePattern([untrimmed, trimmed]) === trimmed)+        #expect(GroupOrdering.representativePattern([untrimmed, suffixTrimmed]) === suffixTrimmed)+    }++    /// Two rows carrying the same rule with different JSON byte layouts are the+    /// same definition; the canonical form re-encodes rather than comparing bytes.+    @Test("URL rule canonical form is byte-layout independent")+    func canonicalURLRuleFormIgnoresByteLayout() throws {+        let store = try OrderingStore()+        let site = store.addSite(hostname: "rule.example")+        let definition = URLRuleDefinition.work(locator: .query(name: ExactScalarString("identity")))+        let compact = try store.addRule(site: site, definition: definition)+        let pretty = try store.addRule(site: site, definition: definition)+        let prettyEncoder = JSONEncoder()+        prettyEncoder.outputFormatting = [.prettyPrinted]+        pretty.definitionData = try prettyEncoder.encode(definition)+        try store.commit()++        #expect(compact.definitionData != pretty.definitionData)+        #expect(GroupOrdering.canonicalDefinition(compact)+            == GroupOrdering.canonicalDefinition(pretty))+        #expect(GroupOrdering.urlRuleRowsAreInterchangeable(compact, pretty))+    }++    @Test("URL rule representative prefers the current row when the rest ties")+    func urlRuleRepresentativePrefersCurrent() throws {+        let store = try OrderingStore()+        let site = store.addSite(hostname: "rule.example")+        let definition = URLRuleDefinition.work(locator: .query(name: ExactScalarString("identity")))+        let historical = try store.addRule(site: site, definition: definition, isCurrent: false)+        let current = try store.addRule(site: site, definition: definition, isCurrent: true)+        try store.commit()++        #expect(GroupOrdering.representativeURLRule([historical, current]) === current)+    }++    // MARK: - Variant order (Q42)++    @Test("Variants order by their earliest carrying row, then by content")+    func variantOrder() {+        let early = AuthoredVariant(+            content: EntryAuthoredContent(note: "zebra"),+            firstCapturedAt: OrderingStore.epoch)+        let lateAlpha = AuthoredVariant(+            content: EntryAuthoredContent(note: "alpha"),+            firstCapturedAt: OrderingStore.epoch.addingTimeInterval(60))+        let lateBeta = AuthoredVariant(+            content: EntryAuthoredContent(note: "beta"),+            firstCapturedAt: OrderingStore.epoch.addingTimeInterval(60))++        let ordered = GroupOrdering.sortedVariants([lateBeta, lateAlpha, early])++        #expect(ordered == [early, lateAlpha, lateBeta])+        #expect(GroupOrdering.leadingVariant([lateBeta, lateAlpha, early]) == early)+    }++    /// Variants are distinct by definition, so ordering them by content is total+    /// even where every carrying row shares one capture date — the double-import+    /// case where the evidence ordering cannot decide.+    @Test("Variants sharing one capture date still order totally by content")+    func variantOrderIsTotalOnIdenticalDates() {+        let ratingOnly = AuthoredVariant(+            content: EntryAuthoredContent(note: "", rating: .up),+            firstCapturedAt: OrderingStore.epoch)+        let noteOnly = AuthoredVariant(+            content: EntryAuthoredContent(note: "a note"),+            firstCapturedAt: OrderingStore.epoch)++        #expect(GroupOrdering.sortedVariants([noteOnly, ratingOnly]) == [ratingOnly, noteOnly])+        #expect(GroupOrdering.sortedVariants([ratingOnly, noteOnly]) == [ratingOnly, noteOnly])+    }++    @Test("Work variants order the same way over Work authored fields")+    func workVariantOrder() {+        let bareTitle = AuthoredVariant(+            content: WorkAuthoredContent(genericNotes: "aaa"),+            firstCapturedAt: OrderingStore.epoch)+        let laterNotes = AuthoredVariant(+            content: WorkAuthoredContent(genericNotes: "bbb"),+            firstCapturedAt: OrderingStore.epoch)++        #expect(GroupOrdering.leadingVariant([laterNotes, bareTitle]) == bareTitle)+    }++    // MARK: - Survivor order++    @Test("The survivor is the member with the earliest timestamp")+    func survivorIsEarliest() {+        let early = SurvivorCandidate(id: UUID(uuidString: "FFFFFFFF-0000-4000-8000-000000000001")!,+                                      timestamp: OrderingStore.epoch)+        let late = SurvivorCandidate(id: UUID(uuidString: "00000000-0000-4000-8000-000000000002")!,+                                     timestamp: OrderingStore.epoch.addingTimeInterval(60))++        #expect(GroupOrdering.survivor([late, early]) == early)+    }++    @Test("Equal timestamps break on the application UUID, not on anything device-local")+    func survivorTiebreakIsTheApplicationUUID() {+        let lower = SurvivorCandidate(id: UUID(uuidString: "00000000-0000-4000-8000-000000000001")!,+                                      timestamp: OrderingStore.epoch)+        let higher = SurvivorCandidate(id: UUID(uuidString: "FFFFFFFF-0000-4000-8000-000000000002")!,+                                       timestamp: OrderingStore.epoch)++        #expect(GroupOrdering.survivor([higher, lower]) == lower)+        #expect(GroupOrdering.sortedSurvivorCandidates([higher, lower]) == [lower, higher])+        #expect(GroupOrdering.sortedSurvivorCandidates([lower, higher]) == [lower, higher])+    }++    // MARK: - Authored content++    @Test("A row carrying nothing manual is bare")+    func bareness() throws {+        let store = try OrderingStore()+        let entry = store.addEntry(captureTitle: "bare")+        try store.commit()++        #expect(GroupOrdering.authoredContent(of: entry).isBare)++        entry.chapterTitle = "Chapter One"+        entry.chapterTitleProvenance = .pattern+        // A derived chapter title is not reader-authored.+        #expect(GroupOrdering.authoredContent(of: entry).isBare)++        entry.chapterTitleProvenance = .manual+        #expect(GroupOrdering.authoredContent(of: entry).isBare == false)+    }++    /// Q34: `titleProvenance` defaults to `manual`, so provenance alone would+    /// make every Work non-bare and render silent Work resolution inert.+    @Test("A Work title counts as authored only when manual and differing from the parsed title")+    func workTitleAuthorship() throws {+        let store = try OrderingStore()+        let work = store.addWork(title: "Parsed Title", offset: 0)+        work.lastParsedTitle = "Parsed Title"+        try store.commit()++        #expect(GroupOrdering.authoredContent(of: work).isBare)++        work.displayTitle = "Reader's Title"+        #expect(GroupOrdering.authoredContent(of: work).manualTitle == "Reader's Title")++        work.titleProvenance = .parsed+        #expect(GroupOrdering.authoredContent(of: work).manualTitle == nil)+    }++    /// Q11: only reader edits and import set a non-default type.+    @Test("A default Work type is not authored content")+    func workTypeAuthorship() throws {+        let store = try OrderingStore()+        let work = store.addWork(title: "A Work", offset: 0)+        try store.commit()++        #expect(GroupOrdering.authoredContent(of: work).type == nil)+        work.type = .novel+        #expect(GroupOrdering.authoredContent(of: work).type == .novel)+        #expect(GroupOrdering.authoredContent(of: work).isBare == false)+    }++    @Test("Genre tag order does not make two Works disagree")+    func genreTagsAreOrderInsensitive() throws {+        let store = try OrderingStore()+        let left = store.addWork(title: "A Work", offset: 0)+        left.genreTags = ["fantasy", "romance"]+        let right = store.addWork(title: "A Work", offset: 0)+        right.genreTags = ["romance", "fantasy"]+        try store.commit()++        #expect(GroupOrdering.authoredContent(of: left) == GroupOrdering.authoredContent(of: right))+    }++    /// Definitions: assignments referring to members of one Work duplicate set+    /// are equal, so the normalisation belongs to the content value rather than+    /// to any one caller.+    @Test("Work assignments normalise onto one Work duplicate set")+    func assignmentNormalisation() {+        let memberA = UUID()+        let memberB = UUID()+        let left = EntryAuthoredContent(note: "", workAssignment: memberA)+        let right = EntryAuthoredContent(note: "", workAssignment: memberB)++        #expect(left != right)+        let normalisation = [memberA: memberA, memberB: memberA]+        #expect(left.normalizingAssignment(using: normalisation)+            == right.normalizingAssignment(using: normalisation))+    }++    /// The normalisation is an *equality* key and nothing else. If it replaced+    /// the physical assignment, outcome content built from a variant would carry+    /// a canonical member the reader never chose — and for a divergent Work set+    /// the canonical member is the survivor rule's candidate, not the reader's+    /// Merge choice, so writing it repoints a manual assignment (Req 2.6).+    @Test("Normalising an assignment leaves the physical assignment alone")+    func normalisationDoesNotRewriteTheWritableAssignment() {+        let survivor = UUID()+        let loser = UUID()+        let content = EntryAuthoredContent(note: "a note", workAssignment: loser)++        let normalized = content.normalizingAssignment(using: [loser: survivor])++        #expect(normalized.workAssignment == loser)+        #expect(normalized.note == "a note")+        // Equal to the survivor-assigned content despite pointing elsewhere:+        // that is the whole and only effect of the normalisation.+        #expect(normalized+            == EntryAuthoredContent(note: "a note", workAssignment: survivor)+            .normalizingAssignment(using: [survivor: survivor]))+    }++    /// A content value that has never been normalised compares on its own+    /// assignment, so the equality key cannot leak in the other direction either.+    @Test("An unnormalised assignment still separates two different Works")+    func unnormalisedAssignmentsStillDiffer() {+        let left = EntryAuthoredContent(workAssignment: UUID())+        let right = EntryAuthoredContent(workAssignment: UUID())++        #expect(left != right)+        #expect(left.normalizingAssignment(using: [:]) == left)+    }++    // MARK: - Write target (B1 scaffolding, removed by tasks 4–8)++    // MARK: - Order algebra++    /// `sorted(by:)` is undefined for anything weaker than a strict weak+    /// ordering, and intransitivity lives in triples rather than in pairs — the+    /// exact trap `SiteResolutionOrder` records for its "absent sorts last"+    /// steps. Asserted over a row set built to contain absences in every+    /// absentable slot.+    @Test("The Entry representative comparator is a strict weak ordering")+    func entryRepresentativeIsAStrictWeakOrder() throws {+        let store = try OrderingStore()+        let rows = try store.makeAdversarialEntryRowSet()++        assertStrictWeakOrder(+            rows, label: "Entry representative", precedes: GroupOrdering.entryRowPrecedes)+    }++    @Test("The Work comparators are strict weak orderings")+    func workComparatorsAreStrictWeakOrders() throws {+        let store = try OrderingStore()+        let rows = [+            store.addWork(title: "A Work", offset: 0),+            store.addWork(title: "A Work", offset: 0, hostname: "zzz.example"),+            store.addWork(title: "A Work", offset: 60),+        ]+        rows[0].genericNotes = "prose"+        rows[1].genreTags = ["fantasy"]+        rows[2].type = .novel+        let untouched = store.addWork(title: "A Work", offset: 60)+        try store.commit()++        assertStrictWeakOrder(+            rows + [untouched], label: "Work representative",+            precedes: GroupOrdering.workRowPrecedes)+    }++    /// Rule rows carry no authored content, so their tuple is evidence plus the+    /// canonical definition — and the definition is where absence appears most+    /// (trims, segment anchors, phrase parts).+    @Test("The rule representative comparator is a strict weak ordering")+    func ruleComparatorIsAStrictWeakOrder() throws {+        let store = try OrderingStore()+        let siteA = store.addSite(hostname: "aaa.example")+        let siteB = store.addSite(hostname: "zzz.example")+        let plain = try store.addPattern(site: siteA, version: 1, createdAtOffset: 0)+        let trimmed = try store.addPattern(site: siteA, version: 1, createdAtOffset: 0)+        trimmed.trimPrefix = "Read: "+        let bothTrims = try store.addPattern(site: siteA, version: 1, createdAtOffset: 0)+        bothTrims.trimPrefix = "Read: "+        bothTrims.trimSuffix = " - Free"+        let later = try store.addPattern(site: siteA, version: 3, createdAtOffset: 60)+        let elsewhere = try store.addPattern(site: siteB, version: 1, createdAtOffset: 0)+        try store.commit()++        assertStrictWeakOrder(+            [plain, trimmed, bothTrims, later, elsewhere], label: "TitlePattern",+            precedes: GroupOrdering.patternRowPrecedes)+    }++    /// The survivor order *is* total — members of a set have distinct+    /// application UUIDs by construction, so the tiebreak always separates them+    /// and no device-local step is needed (Q48).+    @Test("The survivor comparator is a strict total order")+    func survivorComparatorIsAStrictTotalOrder() {+        let candidates = (0..<6).map { index in+            SurvivorCandidate(+                id: UUID(uuidString: "0000000\(index)-0000-4000-8000-00000000000\(index % 3)")!,+                timestamp: OrderingStore.epoch.addingTimeInterval(TimeInterval(index % 3)))+        }++        assertStrictTotalOrder(+            candidates, label: "SurvivorCandidate", precedes: GroupOrdering.survivorPrecedes)+    }++    /// Variants are distinct by definition (Q42), so their order is total too —+    /// including where every carrying row shares one capture date.+    @Test("The variant comparator is a strict total order over distinct variants")+    func variantComparatorIsAStrictTotalOrder() {+        let work = UUID()+        let variants: [AuthoredVariant<EntryAuthoredContent>] = [+            AuthoredVariant(content: EntryAuthoredContent(note: "alpha"),+                            firstCapturedAt: OrderingStore.epoch),+            AuthoredVariant(content: EntryAuthoredContent(note: "alpha", rating: .up),+                            firstCapturedAt: OrderingStore.epoch),+            AuthoredVariant(content: EntryAuthoredContent(note: "", workAssignment: work),+                            firstCapturedAt: OrderingStore.epoch),+            AuthoredVariant(content: EntryAuthoredContent(chapterTitle: "Chapter"),+                            firstCapturedAt: OrderingStore.epoch),+            AuthoredVariant(content: EntryAuthoredContent(intentionallyUnattached: true),+                            firstCapturedAt: OrderingStore.epoch),+            AuthoredVariant(content: EntryAuthoredContent(note: "beta"),+                            firstCapturedAt: OrderingStore.epoch.addingTimeInterval(60)),+        ]++        assertStrictTotalOrder(+            variants, label: "AuthoredVariant", precedes: GroupOrdering.variantPrecedes)+    }+}++// MARK: - Fixture++/// An on-disk store, because "no `PersistentIdentifier` anywhere" is only+/// testable against rows that hold permanent, distinct identifiers.+private final class OrderingStore {+    static let hostname = "ordering.example"+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    let directory: URL+    let container: ModelContainer+    let context: ModelContext++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismGroupOrdering-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)+        let configuration = ModelConfiguration(+            "AsterismV3", schema: schema,+            url: directory.appending(path: "library.store"), cloudKitDatabase: .none)+        container = try ModelContainer(+            for: schema, migrationPlan: AsterismV5MigrationPlan.self,+            configurations: [configuration])+        context = ModelContext(container)+    }++    deinit { try? FileManager.default.removeItem(at: directory) }++    @discardableResult+    func addSite(hostname: String) -> Site {+        let site = Site(hostname: hostname)+        context.insert(site)+        return site+    }++    /// Every field the representative tuple reads is fixed here, so a test that+    /// varies one of them varies exactly one.+    @discardableResult+    func addEntry(id: UUID = UUID(), captureTitle: String, offset: TimeInterval = 0) -> Entry {+        let entry = Entry(+            id: id, captureTitle: captureTitle, captureTitleSource: .manual,+            rawURLString: "https://\(Self.hostname)/read/1", hostname: Self.hostname,+            entryIdentityKey: "aaa", timestamp: Self.epoch.addingTimeInterval(offset))+        entry.conservativeIdentityKey = "aaa"+        entry.lastSharedAt = Self.epoch+        entry.modifiedAt = Self.epoch+        context.insert(entry)+        return entry+    }++    @discardableResult+    func addWork(+        id: UUID = UUID(), title: String, offset: TimeInterval,+        hostname: String = OrderingStore.hostname+    ) -> Work {+        let work = Work(+            id: id, displayTitle: title, siteHostname: hostname,+            timestamp: Self.epoch.addingTimeInterval(offset))+        work.modifiedAt = Self.epoch+        context.insert(work)+        return work+    }++    /// A row set built for the order algebra rather than for one assertion:+    /// every absentable slot of the Entry tuple appears both present and absent,+    /// which is the shape a fall-through "absence is a tie" step cycles on.+    func makeAdversarialEntryRowSet() throws -> [Entry] {+        let base = addEntry(captureTitle: "same")+        let noCanonical = addEntry(captureTitle: "same")+        let withCanonical = addEntry(captureTitle: "same")+        withCanonical.canonicalURLString = "https://\(Self.hostname)/canonical"+        let earlyCanonical = addEntry(captureTitle: "same", offset: -60)+        earlyCanonical.canonicalURLString = "https://\(Self.hostname)/a"+        let rated = addEntry(captureTitle: "same")+        rated.rating = .up+        let noted = addEntry(captureTitle: "same")+        noted.note = "prose"+        let titled = addEntry(captureTitle: "same")+        titled.chapterTitle = "Chapter One"+        titled.chapterTitleProvenance = .manual+        let unattached = addEntry(captureTitle: "same")+        unattached.intentionallyUnattached = true+        let otherHost = addEntry(captureTitle: "same")+        otherHost.hostname = "zzz.example"+        let laterTitle = addEntry(captureTitle: "zzz", offset: 60)+        try commit()+        return [+            base, noCanonical, withCanonical, earlyCanonical, rated, noted, titled, unattached,+            otherHost, laterTitle,+        ]+    }++    @discardableResult+    func addPattern(site: Site, version: Int, createdAtOffset: TimeInterval) throws -> TitlePattern {+        let pattern = try TitlePattern(+            version: version, isActive: false,+            createdAt: Self.epoch.addingTimeInterval(createdAtOffset),+            definition: .wholeTitle, site: site)+        context.insert(pattern)+        return pattern+    }++    @discardableResult+    func addRule(+        site: Site, definition: URLRuleDefinition, isCurrent: Bool = false+    ) throws -> URLRulePattern {+        let rule = try URLRulePattern(+            version: 1, isCurrent: isCurrent, createdAt: Self.epoch,+            origin: .readerTaught, definition: definition, site: site)+        context.insert(rule)+        return rule+    }++    func commit() throws { try context.save() }+}
Asterism/AsterismTests/DuplicateSurfaceTests.swift Added +604 / -0
diff --git a/Asterism/AsterismTests/DuplicateSurfaceTests.swift b/Asterism/AsterismTests/DuplicateSurfaceTests.swiftnew file mode 100644index 0000000..59d5963--- /dev/null+++ b/Asterism/AsterismTests/DuplicateSurfaceTests.swift@@ -0,0 +1,604 @@+import AsterismCore+import Foundation+import Testing++@testable import Asterism++/// Task 18: the surfaces a duplicate reaches the reader through.+///+/// Every assertion here is about a promise the reader can check: a torn record's+/// editors are off and its delete tells them what they are about to lose+/// (Req 2.8); a refused edit keeps their draft and shows up in the count+/// (Req 2.10, 9.1); the resolution sheet re-presents rather than committing what+/// it was not showing (Req 4.6); and Check Library names a route instead of+/// saying no merge exists (Req 9.3).+@Suite("Duplicate reader surfaces")+struct DuplicateSurfaceTests {++    // MARK: - DuplicateResolutionModel (Requirement 4)++    @Test("Loading presents the contract and preselects the leading variant")+    @MainActor func loadPreselectsTheLeadingVariant() async {+        let contract = Self.entryContract()+        let library = MockLibraryProvider()+        library.projectDuplicateResolutionResult = .success(contract)+        let model = DuplicateResolutionModel(+            setKey: contract.setKey, library: library, onMutation: {})++        await model.load()++        #expect(model.state == .choosing)+        #expect(model.selectedVariantID == contract.preselected)+        #expect(model.entryVariants.count == 2)+        #expect(model.canConfirm)+        // Req 4.3's toggle is the reader's on the Entry path.+        #expect(model.appendIsOffered)+    }++    @Test("A set that no longer exists reads as resolved, not as a failure")+    @MainActor func aVanishedSetReadsAsResolved() async {+        let library = MockLibraryProvider()+        library.projectDuplicateResolutionResult = .failure(+            LibraryRepositoryError.recordNotFound(type: "duplicate set", id: UUID()))+        let model = DuplicateResolutionModel(+            setKey: Self.setKey, library: library, onMutation: {})++        await model.load()++        guard case .resolvedElsewhere = model.state else {+            Issue.record("expected .resolvedElsewhere, got \(model.state)")+            return+        }+    }++    @Test("Confirming passes the reader's choice and the append toggle through")+    @MainActor func confirmPassesTheChoice() async {+        let contract = Self.entryContract()+        let library = MockLibraryProvider()+        library.projectDuplicateResolutionResult = .success(contract)+        library.commitDuplicateResolutionResult = .success(.committed(survivorID: UUID()))+        let tracker = CallbackTracker()+        let model = DuplicateResolutionModel(+            setKey: contract.setKey, library: library,+            onMutation: { tracker.mutationCount += 1 })+        await model.load()+        model.select(contract.variantIDs[1])+        model.appendOtherNotes = true++        await model.confirm()++        #expect(model.state == .committed)+        #expect(library.lastResolutionChoice == contract.variantIDs[1])+        #expect(library.lastResolutionAppendedNotes == true)+        #expect(tracker.mutationCount == 1)+    }++    /// Req 4.6 and Decision 17: a refused confirmation re-presents rather than+    /// committing something the reader was not shown — and it asks for the+    /// choice again rather than carrying one forward, so the second half of a+    /// double-tap cannot confirm a variant nobody picked.+    @Test("A refreshed contract re-presents and asks for the choice again")+    @MainActor func refreshedContractRepresents() async {+        let contract = Self.entryContract()+        let fresh = Self.entryContract(noteSuffix: "-fresh", variantCount: 3)+        let library = MockLibraryProvider()+        library.projectDuplicateResolutionResult = .success(contract)+        library.commitDuplicateResolutionResult = .success(.refreshed(fresh))+        let model = DuplicateResolutionModel(+            setKey: contract.setKey, library: library, onMutation: {})+        await model.load()++        await model.confirm()++        #expect(model.state == .choosing)+        #expect(model.requiresReview)+        #expect(model.entryVariants.count == 3)+        #expect(model.selectedVariantID == nil)+        #expect(!model.canConfirm)++        // And the sheet is confirmable again the moment they choose.+        model.select(fresh.variantIDs[1])+        #expect(model.canConfirm)+    }++    /// Decision 17, from the other side: a second tap on Confirm while the+    /// refusal notice is up must commit nothing.+    @Test("Confirming twice through a refusal commits nothing the reader did not choose")+    @MainActor func doubleTapThroughARefusalCommitsNothing() async {+        let contract = Self.entryContract()+        let fresh = Self.entryContract(noteSuffix: "-fresh", variantCount: 3)+        let library = MockLibraryProvider()+        library.projectDuplicateResolutionResult = .success(contract)+        library.commitDuplicateResolutionResult = .success(.refreshed(fresh))+        let model = DuplicateResolutionModel(+            setKey: contract.setKey, library: library, onMutation: {})+        await model.load()++        await model.confirm()+        await model.confirm()++        #expect(library.commitDuplicateResolutionCallCount == 1)+        #expect(model.state == .choosing)+    }++    @Test("Selecting a variant the contract does not hold is ignored")+    @MainActor func unknownSelectionIsIgnored() async {+        let contract = Self.entryContract()+        let library = MockLibraryProvider()+        library.projectDuplicateResolutionResult = .success(contract)+        let model = DuplicateResolutionModel(+            setKey: contract.setKey, library: library, onMutation: {})+        await model.load()++        model.select(VariantID(rawValue: "not-a-variant"))++        #expect(model.selectedVariantID == contract.preselected)+    }++    /// Req 5.4: the Work path appends unconditionally, so there is no toggle to+    /// offer — and offering one would promise a choice the commit ignores.+    @Test("The Work path offers no append toggle")+    @MainActor func workPathOffersNoToggle() async {+        let contract = Self.workContract()+        let library = MockLibraryProvider()+        library.projectDuplicateResolutionResult = .success(contract)+        let model = DuplicateResolutionModel(+            setKey: contract.setKey, library: library, onMutation: {})++        await model.load()++        #expect(!model.appendIsOffered)+        #expect(model.workVariants.count == 2)+    }++    // MARK: - EntryDetailModel (Reqs 2.8, 2.10)++    @Test("A torn record is read-only and its unavailability names the review")+    @MainActor func tornRecordIsReadOnly() async {+        let (model, _, _) = Self.entryDetail(groupState: Self.tornState)++        await model.load()++        #expect(model.isReadOnly)+        #expect(model.rowCount == 2)+        #expect(model.tornVariants.count == 2)+    }++    @Test("An unresolvedDuplicate refusal reads as a review, not as damage")+    @MainActor func unresolvedDuplicateReadsAsReview() async {+        let (model, mock, _) = Self.entryDetail(groupState: .single)+        mock.entryTeachingDetailResult = .failure(+            LibraryRepositoryError.unresolvedDuplicate(type: "Entry", id: UUID()))++        await model.load()++        #expect(model.unavailability == .duplicateReview)+        #expect(model.unavailability.accessibilityIdentifier == "entry-detail-duplicate-review")+    }++    /// Req 2.8's whole point, and the debt Q79 recorded: the reader is told what+    /// differs *before* anything goes, and the delete cannot happen without that+    /// telling.+    @Test("Deleting a torn record discloses first and writes nothing")+    @MainActor func tornDeleteDisclosesFirst() async {+        let (model, mock, _) = Self.entryDetail(groupState: Self.tornState)+        await model.load()++        await model.delete()++        #expect(mock.deleteEntryCallCount == 0)+        let disclosure = try? #require(model.deleteDisclosure)+        #expect(disclosure?.variants.count == 2)+        // The alert lists what each copy holds, which is the disclosure.+        #expect(disclosure?.lines.contains { $0.contains("first note") } == true)+        #expect(disclosure?.lines.contains { $0.contains("second note") } == true)+    }++    @Test("Confirming the disclosure passes exactly the variants it listed")+    @MainActor func confirmedDisclosurePassesWhatItShowed() async throws {+        let (model, mock, _) = Self.entryDetail(groupState: Self.tornState)+        await model.load()+        await model.delete()+        let disclosure = try #require(model.deleteDisclosure)+        let disclosed = Set(disclosure.variants.map(\.id))++        await model.confirmDelete(disclosure)++        #expect(mock.deleteEntryCallCount == 1)+        #expect(mock.lastDeleteDisclosedVariants == .some(disclosed))+        #expect(model.deleteDisclosure == nil)+    }++    /// Cancelling clears the disclosure, and nothing else can produce one: the+    /// alert is the only thing that renders a `DeleteDisclosure`, so a cancelled+    /// alert leaves no route to the commit (Q79).+    @Test("Cancelling the disclosure deletes nothing")+    @MainActor func cancelledDisclosureDeletesNothing() async {+        let (model, mock, _) = Self.entryDetail(groupState: Self.tornState)+        await model.load()+        await model.delete()++        model.cancelDelete()++        #expect(model.deleteDisclosure == nil)+        #expect(mock.deleteEntryCallCount == 0)+    }++    /// A single row has nothing to disclose, so it must not grow an alert for+    /// the sake of consistency.+    @Test("A single row still deletes on the first tap, disclosing nothing")+    @MainActor func singleRowDeletesImmediately() async {+        let (model, mock, _) = Self.entryDetail(groupState: .single)+        await model.load()++        await model.delete()++        #expect(mock.deleteEntryCallCount == 1)+        #expect(mock.lastDeleteDisclosedVariants == .some(nil))+        #expect(model.deleteDisclosure == nil)+    }++    /// Req 2.10: the edit is the only copy of itself, so a vanished record must+    /// not be allowed to overwrite it with the values it held before the reader+    /// typed.+    @Test("The draft survives a record that has gone")+    @MainActor func draftSurvivesRecordNotFound() async {+        let (model, mock, _) = Self.entryDetail(groupState: .single)+        await model.load()+        model.draftNote = "the reader's unsaved words"+        mock.updateEntryResult = .failure(+            LibraryRepositoryError.recordNotFound(type: "Entry", id: UUID()))++        await model.update()++        #expect(model.draftNote == "the reader's unsaved words")+    }++    /// Req 2.10 both ways at once: the draft is the only copy of the edit, and+    /// the conflict has to reach the model that owns the banner and the listing+    /// or nothing ever tells the reader it happened (Q47).+    @Test("The draft survives a conflict, and the conflict is handed on")+    @MainActor func draftSurvivesConflict() async {+        let recordID = UUID()+        let seen = ConflictRecorder()+        let (model, mock, _) = Self.entryDetail(groupState: .single, onConflictInto: seen)+        await model.load()+        model.draftNote = "kept"+        mock.updateEntryResult = .success(+            .conflict(.survivorDiverged(recordID: recordID, survivorID: UUID())))++        await model.update()++        #expect(model.draftNote == "kept")+        #expect(await seen.conflicts.count == 1)+    }++    // MARK: - AppLibraryModel conflict store (Q47, Req 9.1)++    @Test("A recorded conflict counts toward the banner and is listed once per record")+    @MainActor func conflictStoreOverlaysTheBannerCount() {+        let library = MockLibraryProvider()+        let app = AppLibraryModel(readyRepository: library)+        let recordID = UUID()++        app.recordConflict(+            .survivorDiverged(recordID: recordID, survivorID: UUID()), recordType: .entry)+        #expect(app.pendingConflicts.count == 1)+        #expect(app.duplicateBannerCount == 1)++        // A second conflict about the same record replaces the first: the reader+        // has one draft per record, and two rows would be two claims about it.+        app.recordConflict(.torn(recordID: recordID, variants: []), recordType: .entry)+        #expect(app.pendingConflicts.count == 1)++        app.clearConflict(recordID)+        #expect(app.pendingConflicts.isEmpty)+        #expect(app.duplicateBannerCount == 0)+    }++    /// Req 9.4, and the count that could otherwise never reach zero: Check+    /// Library tells the reader to save their edit again once the copies are+    /// resolved, so when that save lands the banner and the row have to go.+    @Test("A committed write clears the conflict its record was holding")+    @MainActor func aCommittedWriteClearsItsConflict() async {+        let library = MockLibraryProvider()+        let entry = TestFixtures.makeEntry(note: "original")+        library.entryResult = .success(entry)+        library.entryTeachingDetailResult = .success(Self.teachingDetail(entry, .single))+        let app = AppLibraryModel(readyRepository: library)+        app.recordConflict(+            .torn(recordID: entry.id, variants: []), recordType: .entry)+        #expect(app.duplicateBannerCount == 1)++        let detail = try? #require(app.entryDetailModel(for: entry.id))+        await detail?.load()+        detail?.draftNote = "saved again"+        await detail?.update()++        #expect(app.pendingConflicts.isEmpty)+        #expect(app.duplicateBannerCount == 0)+    }++    /// The redirect's conflict names a record that has *gone*, so re-saving it+    /// would only refuse again. A write landing on the survivor is the reader's+    /// way through, and it clears the row.+    @Test("A write on the survivor clears a redirected edit's conflict")+    @MainActor func aWriteOnTheSurvivorClearsTheConflict() async {+        let library = MockLibraryProvider()+        let survivor = TestFixtures.makeEntry(note: "the surviving copy")+        library.entryResult = .success(survivor)+        library.entryTeachingDetailResult = .success(Self.teachingDetail(survivor, .single))+        let app = AppLibraryModel(readyRepository: library)+        app.recordConflict(+            .survivorDiverged(recordID: UUID(), survivorID: survivor.id), recordType: .entry)++        let detail = try? #require(app.entryDetailModel(for: survivor.id))+        await detail?.load()+        await detail?.update()++        #expect(app.pendingConflicts.isEmpty)+    }++    /// Req 9.4 without the reader touching anything: their other device+    /// resolved the copies, the workload no longer names the record, and the+    /// sentence "this record holds copies that differ" has stopped being true.+    @Test("A torn conflict drops when no published set covers its record")+    @MainActor func aTornConflictDropsWithItsSet() async {+        let library = MockLibraryProvider()+        let recordID = UUID()+        let app = AppLibraryModel(readyRepository: library)+        app.recordConflict(.torn(recordID: recordID, variants: []), recordType: .entry)+        app.recordConflict(+            .survivorDiverged(recordID: UUID(), survivorID: UUID()), recordType: .entry)+        #expect(app.duplicateBannerCount == 2)++        // The refresh publishes a workload holding no set for that record.+        await app.handleActivation()++        #expect(app.pendingConflicts.count == 1)+        // The preserved edit stays: its record is gone and no set will ever+        // mention it, so sweeping it would drop the only notice of the draft.+        #expect(app.pendingConflicts.first?.conflict.survivorID != nil)+    }++    // MARK: - The banner's count and its filter (Req 9.1)++    /// The reviewer's missing test: everything the banner counts is something+    /// the filter can show. A Work set and a preserved edit have no row in+    /// Recent, so they are named in the section beside the rows rather than+    /// filtered into a blank screen.+    @Test("Every counted duplicate is either a row or a named route")+    @MainActor func theBannerCountAndItsFilterAgree() {+        let entrySet = DuplicateSetKey(recordType: .entry, memberIDs: [UUID(), UUID()])+        let workSet = DuplicateSetKey(recordType: .work, memberIDs: [UUID(), UUID()])+        let workload = DuplicateWorkload(+            reviewItems: [+                DuplicateReviewItem(+                    key: entrySet, route: .sheet, memberIDs: entrySet.memberIDs,+                    variantCount: 2, isTorn: false),+                DuplicateReviewItem(+                    key: workSet, route: .merge, memberIDs: workSet.memberIDs,+                    variantCount: 2, isTorn: false),+            ],+            deferredItems: [])++        let plan = RecentDuplicatePlan(workload: workload, conflictCount: 2)++        // The banner's own arithmetic: sets awaiting the reader plus conflicts.+        #expect(plan.accountedCount == workload.reviewCount + 2)+        #expect(plan.entryItems.map(\.key) == [entrySet])+        #expect(plan.elsewhere.count == 2)+        #expect(plan.elsewhere.first?.route == .resolve(workSet))+        #expect(plan.elsewhere.first?.text.localizedCaseInsensitiveContains("Works tab") == true)+        #expect(plan.elsewhere.last?.route == .checkLibrary)+    }++    /// A library whose only duplicate is an Entry set needs no such section —+    /// the rows are right there.+    @Test("An Entry-only workload names nothing elsewhere")+    @MainActor func anEntryOnlyWorkloadNamesNothingElsewhere() {+        let entrySet = DuplicateSetKey(recordType: .entry, memberIDs: [UUID(), UUID()])+        let workload = DuplicateWorkload(+            reviewItems: [+                DuplicateReviewItem(+                    key: entrySet, route: .sheet, memberIDs: entrySet.memberIDs,+                    variantCount: 2, isTorn: false)+            ],+            deferredItems: [])++        let plan = RecentDuplicatePlan(workload: workload, conflictCount: 0)++        #expect(plan.elsewhere.isEmpty)+        #expect(plan.accountedCount == 1)+    }++    // MARK: - WorkDetailModel (Req 2.8 on the Work half)++    /// Req 2.8 mirrored: a torn Work says why its fields are off and how to turn+    /// them back on. Save being greyed out with no explanation is the silent+    /// dead end this milestone removes.+    @Test("A torn Work is read-only and knows how many copies it has")+    @MainActor func aTornWorkIsReadOnly() async {+        let library = MockLibraryProvider()+        let work = TestFixtures.makeWork(displayTitle: "Serial")+        library.workResult = .success(+            WorkSnapshot(+                id: work.id, displayTitle: work.displayTitle, lastParsedTitle: "Serial",+                siteHostname: work.siteHostname, urlIdentity: nil, workURLString: nil,+                genericNotes: "", type: .other, genreTags: [], titleProvenance: .parsed,+                createdAt: work.createdAt, modifiedAt: work.modifiedAt, entries: [],+                groupState: .torn(variants: [+                    AuthoredVariant(+                        content: WorkAuthoredContent(genericNotes: "this device"),+                        firstCapturedAt: TestFixtures.fixedDate),+                    AuthoredVariant(+                        content: WorkAuthoredContent(genericNotes: "the other one"),+                        firstCapturedAt: TestFixtures.laterDate),+                ])))+        let model = WorkDetailModel(workID: work.id, library: library, onMutation: {})++        await model.load()++        #expect(model.isReadOnly)+        #expect(model.rowCount == 2)+    }++    // MARK: - Library Check rows (Req 9.3)++    @Test("Every duplicate set awaiting the reader is listed with its route")+    @MainActor func checkLibraryListsTheWorkloadWithRoutes() async {+        let library = MockLibraryProvider()+        let blocking = DuplicateSetKey(recordType: .work, memberIDs: [UUID(), UUID()])+        library.duplicateWorkload = DuplicateWorkload(+            reviewItems: [+                DuplicateReviewItem(+                    key: DuplicateSetKey(recordType: .entry, memberIDs: [UUID(), UUID()]),+                    route: .sheet, memberIDs: [UUID(), UUID()], variantCount: 2, isTorn: false),+                DuplicateReviewItem(+                    key: blocking, route: .merge, memberIDs: [UUID(), UUID()],+                    variantCount: 2, isTorn: false),+            ],+            deferredItems: [+                DuplicateReviewItem(+                    key: DuplicateSetKey(recordType: .entry, memberIDs: [UUID()]),+                    route: .blockedByWorkSet(blocking), memberIDs: [UUID()],+                    variantCount: 2, isTorn: true)+            ])+        var routed: [DuplicateSetKey] = []+        let model = LibraryDiagnosticsModel(+            library: library,+            onReteach: { _ in },+            onResolveDuplicate: { routed.append($0) })++        await model.load()++        #expect(model.rows.count == 3)+        // Req 9.3: the route is named, not "Asterism does not merge duplicate+        // records yet".+        #expect(model.rows.allSatisfy { !$0.resolution.localizedCaseInsensitiveContains("does not") })+        #expect(model.rows.compactMap(\.duplicateRoute) == [.sheet, .merge, .blockedByWorkSet(blocking)])++        // A deferred row routes at the set blocking it (Q45), not at its own+        // unreachable sheet.+        model.resolveDuplicate(model.rows[2])+        #expect(routed == [blocking])+    }++    @Test("A preserved edit conflict is listed beside the sets")+    @MainActor func checkLibraryListsPreservedConflicts() async {+        let library = MockLibraryProvider()+        let recordID = UUID()+        let conflict = PendingConflict(+            id: recordID,+            conflict: .survivorDiverged(recordID: recordID, survivorID: UUID()),+            recordType: .entry,+            message: "kept for you")+        let model = LibraryDiagnosticsModel(+            library: library, onReteach: { _ in }, pendingConflicts: { [conflict] })++        await model.load()++        #expect(model.rows.count == 1)+        #expect(model.rows.first?.problem == "kept for you")+        #expect(model.rows.first?.reteachHostname == nil)+        #expect(model.rows.first?.duplicateRoute == nil)+    }++    /// Req 9.6: nothing waiting means nothing listed, and the headline says so+    /// rather than reporting a settled library as unresolved.+    @Test("An empty workload leaves the screen saying nothing is unresolved")+    @MainActor func emptyWorkloadListsNothing() async {+        let library = MockLibraryProvider()+        let model = LibraryDiagnosticsModel(library: library, onReteach: { _ in })++        await model.load()++        #expect(model.rows.isEmpty)+        #expect(model.headline == "Nothing unresolved")+    }++    // MARK: - Fixtures++    private static let setKey = DuplicateSetKey(+        recordType: .entry, memberIDs: [UUID(), UUID()])++    private static let tornState: RecordGroupState<EntryAuthoredContent> = .torn(variants: [+        AuthoredVariant(+            content: EntryAuthoredContent(note: "first note", rating: .up),+            firstCapturedAt: Date(timeIntervalSince1970: 1_800_000_000)),+        AuthoredVariant(+            content: EntryAuthoredContent(note: "second note"),+            firstCapturedAt: Date(timeIntervalSince1970: 1_800_000_100)),+    ])++    private static func entryContract(+        noteSuffix: String = "", variantCount: Int = 2+    ) -> DuplicateResolutionContract {+        let variants = (0..<variantCount).map { index in+            EntryVariantChoice(+                id: VariantID(rawValue: "variant-\(index)\(noteSuffix)"),+                note: "note \(index)\(noteSuffix)", rating: nil, chapterTitle: nil,+                workID: nil, workTitle: nil, intentionallyUnattached: false,+                firstCapturedAt: Date(timeIntervalSince1970: 1_800_000_000 + Double(index)))+        }+        return .entry(+            setKey: setKey, variants: variants, differingFields: [.note, .rating],+            preselected: variants[0].id)+    }++    private static func workContract() -> DuplicateResolutionContract {+        let variants = (0..<2).map { index in+            WorkVariantChoice(+                id: VariantID(rawValue: "work-variant-\(index)"),+                displayTitle: "Serial", manualTitle: nil, genericNotes: "notes \(index)",+                workURLString: nil, genreTags: [], type: .other,+                firstCapturedAt: Date(timeIntervalSince1970: 1_800_000_000 + Double(index)))+        }+        return .work(+            setKey: DuplicateSetKey(recordType: .work, memberIDs: [UUID(), UUID()]),+            variants: variants, differingFields: [.genericNotes], preselected: variants[0].id)+    }++    private static func teachingDetail(+        _ entry: EntrySnapshot, _ groupState: RecordGroupState<EntryAuthoredContent>+    ) -> EntryTeachingDetail {+        EntryTeachingDetail(+            entry: entry,+            siteMode: .untaught,+            activePatternSummary: nil,+            historicalPatternSummaries: [],+            chapterSettlement: .unsettled(reason: "none"),+            assignmentSettlement: .unsettled(reason: "none"),+            availableActions: [],+            unresolvedCandidateTitle: nil,+            groupState: groupState)+    }++    @MainActor+    private static func entryDetail(+        groupState: RecordGroupState<EntryAuthoredContent>,+        onConflictInto recorder: ConflictRecorder? = nil+    ) -> (EntryDetailModel, MockLibraryProvider, CallbackTracker) {+        let mock = MockLibraryProvider()+        let entry = TestFixtures.makeEntry(note: "original", rating: .up)+        mock.entryResult = .success(entry)+        mock.entryTeachingDetailResult = .success(Self.teachingDetail(entry, groupState))+        let tracker = CallbackTracker()+        let model = EntryDetailModel(+            entryID: entry.id, library: mock,+            onMutation: { tracker.mutationCount += 1 },+            onConflict: { conflict in await recorder?.record(conflict) })+        return (model, mock, tracker)+    }++}++/// Collects the conflicts a model hands on, which is what `AppLibraryModel`+/// does in production.+actor ConflictRecorder {+    private(set) var conflicts: [WriteConflict] = []++    func record(_ conflict: WriteConflict) {+        conflicts.append(conflict)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePublicationTests.swift Added +602 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePublicationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePublicationTests.swiftnew file mode 100644index 0000000..dccc4c9--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePublicationTests.swift@@ -0,0 +1,602 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Tasks 14 and 15: what the reader surfaces are handed.+///+/// Two halves of one publication. The **workload** (Req 9.1–9.6) is what awaits+/// a reader decision and nothing else — a silently resolvable set contributes+/// nothing, a deferred one is published without counting, and rows sharing an+/// application UUID no longer produce a diagnosis at all (Q57). The **dedup**+/// (Reqs 3.2, 5.5) is what every list surface shows: one row per logical record,+/// carrying the representative's evidence, the *group's* authored content, and+/// the member timestamps.+@Suite("Duplicate publication and presentation dedup", .serialized)+struct DuplicatePublicationTests {++    // MARK: - Retirement of `.duplicateIdentity` (Q57)++    @Test("The tolerance scan reports no diagnosis for rows sharing an application UUID")+    func toleranceScanNoLongerDiagnosesDuplicateIdentity() throws {+        let library = try PublicationFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: shared, hostname: "dup.example", key: "one", title: "A", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", key: "one", title: "A", offset: 5)+        }++        let scan = try LibraryToleranceScan.scan(context: try library.readContext())++        #expect(scan.diagnoses.isEmpty)+        // The gate still sees them: the phase has work, the reader does not.+        #expect(scan.duplicateCandidateCount > 0)+    }++    @Test("The full validator reports no diagnosis for rows sharing an application UUID")+    func fullValidationNoLongerDiagnosesDuplicateIdentity() throws {+        let library = try PublicationFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: shared, hostname: "dup.example", key: "one", title: "A", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", key: "one", title: "A", offset: 5)+        }++        let diagnostics = try V4LibraryValidator.validate(context: try library.readContext())++        #expect(diagnostics.diagnoses.isEmpty)+        #expect(diagnostics.affectedRecordCount == 0)+    }++    /// Req 9.6, and the reason Q57 gave for the retirement: a converged lone+    /// group is *benign*, and the old diagnosis keyed on rows-per-UUID could+    /// never say so — Decision 4 never deletes the second row.+    @Test("A converged lone group contributes nothing to any count")+    func convergedGroupIsInvisible() async throws {+        let library = try PublicationFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: shared, hostname: "dup.example", key: "one", title: "A", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", key: "one", title: "A", offset: 5)+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)++        #expect(presentation.diagnosisCount == 0)+        #expect(presentation.duplicateWorkload.isEmpty)+        #expect(presentation.duplicateWorkload.reviewCount == 0)+        #expect(presentation.allRows.count == 1)+    }++    // MARK: - The workload (Req 9.1, 9.5)++    @Test("A divergent Entry set publishes one review item routed at the sheet")+    func divergentEntrySetIsReviewWork() async throws {+        let library = try PublicationFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(hostname: "dup.example", key: "one", title: "A", offset: 0, note: "first")+            store.insertEntry(hostname: "dup.example", key: "one", title: "A", offset: 5, note: "second")+        }+        let repository = try await library.openForApp()++        let workload = try await repository.recentPresentation(calendar: .current).duplicateWorkload++        #expect(workload.reviewCount == 1)+        let item = try #require(workload.reviewItems.first)+        #expect(item.recordType == .entry)+        #expect(item.route == .sheet)+        #expect(item.variantCount == 2)+        #expect(item.isTorn == false)+        #expect(item.memberIDs.count == 2)+    }++    /// Req 9.5. The set collapses on its own schedule, so listing it would ask+    /// the reader to watch work that is not theirs.+    @Test("A silently resolvable Entry set publishes nothing")+    func silentlyResolvableSetIsNotWork() async throws {+        let library = try PublicationFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(hostname: "dup.example", key: "one", title: "A", offset: 0, note: "only")+            store.insertEntry(hostname: "dup.example", key: "one", title: "A", offset: 5)+        }+        let repository = try await library.openForApp()++        let workload = try await repository.recentPresentation(calendar: .current).duplicateWorkload++        #expect(workload.isEmpty)+    }++    /// Q30: a torn member has no single authored content for Merge to carry, so+    /// the whole set goes to the sheet; without one it is a manual Merge+    /// (Req 5.3).+    @Test("A divergent Work set routes at Merge, and at the sheet once a member is torn")+    func workSetRoutesByTornness() throws {+        let plainScan = DuplicateScanResult(+            entrySets: [],+            workSets: [Self.workSet(tornMember: false)],+            titleRuleSets: [], urlRuleSets: [])+        #expect(DuplicateWorkload(scan: plainScan).reviewItems.map(\.route) == [.merge])++        let tornScan = DuplicateScanResult(+            entrySets: [],+            workSets: [Self.workSet(tornMember: true)],+            titleRuleSets: [], urlRuleSets: [])+        let torn = DuplicateWorkload(scan: tornScan)+        #expect(torn.reviewItems.map(\.route) == [.sheet])+        #expect(torn.reviewItems.first?.isTorn == true)+    }++    /// Req 1.6/Q32: while deferred, a set does not surface, count, or list as+    /// awaiting action on its own account — but its rows still need an+    /// affordance, routed at the set that is actually blocking them (Q45).+    @Test("A deferred Entry set publishes without counting, routed at the blocking Work set")+    func deferredSetIsPublishedButNotCounted() throws {+        let blocking = DuplicateSetKey(recordType: .work, memberIDs: [UUID(), UUID()])+        let scan = DuplicateScanResult(+            entrySets: [Self.entrySet(classification: .deferred(blockedBy: blocking))],+            workSets: [], titleRuleSets: [], urlRuleSets: [])++        let workload = DuplicateWorkload(scan: scan)++        #expect(workload.reviewCount == 0)+        #expect(workload.deferredItems.count == 1)+        #expect(workload.deferredItems.first?.route == .blockedByWorkSet(blocking))+        #expect(workload.isEmpty == false)+    }++    /// Q39: rule rows carry no reader-authored fields, so a rule group is never+    /// the reader's problem.+    @Test("A rule group is never published as workload")+    func ruleGroupsAreNeverWorkload() throws {+        let ruleSet = DuplicateSet<NoAuthoredContent>(+            key: DuplicateSetKey(recordType: .titleRule, memberIDs: [UUID()]),+            members: [+                DuplicateMember(+                    id: UUID(), rowCount: 2, variants: [],+                    firstCapturedAt: .distantPast, lastActivityAt: .distantPast)+            ],+            variants: [],+            classification: .silentlyResolvable)+        let scan = DuplicateScanResult(+            entrySets: [], workSets: [], titleRuleSets: [ruleSet], urlRuleSets: [ruleSet])++        #expect(DuplicateWorkload(scan: scan).isEmpty)+    }++    /// Req 9.1: the counts are disjoint. Two unparsed rows inside one review set+    /// are three different things the reader can be told about, and each count+    /// reports its own once — two rows needing teaching, one set needing a+    /// decision, no diagnoses at all.+    @Test("Unparsed rows in a review set count once each way")+    func countsAreDisjoint() async throws {+        let library = try PublicationFixture()+        try library.seed { store in+            let site = store.insertSite(hostname: "taught.example")+            site.mode = .taught+            _ = try store.insertTitlePattern(+                site: site, isActive: true,+                definition: .segment(+                    work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+                    ignored: []))+            store.insertEntry(+                hostname: "taught.example", key: "one", title: "Unparsed A", offset: 0,+                note: "first")+            store.insertEntry(+                hostname: "taught.example", key: "one", title: "Unparsed A", offset: 5,+                note: "second")+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)++        // Two logical records, each actionable for its own teach state...+        #expect(presentation.actionableCount == 2)+        // ...and one set awaiting a resolution. Neither number contains the+        // other, and the diagnosis count holds neither.+        #expect(presentation.duplicateWorkload.reviewCount == 1)+        #expect(presentation.diagnosisCount == 0)+        // The teaching action survives the duplicate: the hostname is teachable+        // and withdrawing the pill would take a real action away.+        #expect(presentation.allRows.allSatisfy { $0.actionType == .reteach })+        #expect(presentation.allRows.allSatisfy { $0.duplicateRoute == .sheet })+    }++    /// Req 9.2 for a row with nothing to teach: the resolution is the only+    /// action it has, so `actionType` reports it.+    @Test("A row with no teaching action reports the resolution as its action type")+    func settledRowReportsResolveAction() async throws {+        let library = try PublicationFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let work = store.insertWork(hostname: "dup.example", title: "Serial", offset: 0)+            store.insertEntry(+                hostname: "dup.example", key: "one", title: "A", offset: 0, note: "first",+                work: work, chapterTitle: "One")+            store.insertEntry(+                hostname: "dup.example", key: "one", title: "A", offset: 5, note: "second",+                work: work, chapterTitle: "One")+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)++        #expect(presentation.actionableCount == 0)+        #expect(presentation.allRows.count == 2)+        #expect(presentation.allRows.allSatisfy { $0.actionType == .resolveDuplicate })+        #expect(presentation.allRows.allSatisfy { $0.duplicateRoute == .sheet })+    }++    // MARK: - Presentation dedup (Req 3.2)++    /// The Q41 loss class from the read side: a mixed group whose bare row+    /// represents must still show the note the reader wrote.+    @Test("A split Entry group is one Recent row carrying the group's content and timestamps")+    func splitEntryGroupIsOneRow() async throws {+        let library = try PublicationFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            // The bare row sorts first on evidence (its capture title is less),+            // so it is the representative — and its empty note must not be what+            // the row shows.+            store.insertEntry(+                id: shared, hostname: "dup.example", key: "one", title: "AAA", offset: 0)+            store.insertEntry(+                id: shared, hostname: "dup.example", key: "one", title: "AAA", offset: 40,+                note: "the note")+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)++        #expect(presentation.allRows.count == 1)+        let row = try #require(presentation.allRows.first)+        #expect(row.id == shared)+        #expect(row.note == "the note")+        #expect(row.isTorn == false)+        // Member timestamps: the latest share across the group, which is what+        // the day grouping and the ordering are built from.+        #expect(row.lastSharedAt == PublicationFixture.epoch.addingTimeInterval(40))+        #expect(row.entry.firstCapturedAt == PublicationFixture.epoch)+    }++    /// Req 3.2's second half: a torn group presents its *leading* variant and+    /// says it is torn, so the reader never edits over an unseen one.+    @Test("A torn Entry group presents the leading variant and carries the torn flag")+    func tornEntryGroupPresentsLeadingVariant() async throws {+        let library = try PublicationFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(+                id: shared, hostname: "dup.example", key: "one", title: "AAA", offset: 0,+                note: "earlier")+            store.insertEntry(+                id: shared, hostname: "dup.example", key: "one", title: "AAA", offset: 40,+                note: "later")+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)++        let row = try #require(presentation.allRows.first)+        #expect(presentation.allRows.count == 1)+        #expect(row.isTorn)+        #expect(row.note == "earlier")+        #expect(row.duplicateRoute == .sheet)+        #expect(presentation.duplicateWorkload.reviewItems.first?.isTorn == true)+    }++    /// **Req 3.2's cross-surface agreement, in the shape it was actually+    /// broken.** The Definitions make assignments to two members of one Work+    /// duplicate set equal, so a group whose rows point at two such Works is not+    /// torn. Recent passed that normalisation; Entry detail did not — and Entry+    /// detail's `groupState` is what `EntryDetailModel.isReadOnly` reads, so the+    /// same record rendered ordinarily on one screen and read-only with a+    /// "copies differ" notice on the other.+    @Test("Recent and Entry detail agree about a group split across one Work set")+    func recentAndEntryDetailAgreeAboutAssignmentTornness() async throws {+        let library = try PublicationFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            // One Work duplicate set: same hostname, same parsed title. Left+            // divergent (their notes differ) so it stays unresolved.+            let left = store.insertWork(+                hostname: "dup.example", title: "The Serial", offset: 0, notes: "from A")+            let right = store.insertWork(+                hostname: "dup.example", title: "The Serial", offset: 10, notes: "from B")+            // One Entry, materialised twice, each row pointing at a different+            // member of that set — and nothing else about them differs.+            store.insertEntry(+                id: shared, hostname: "dup.example", key: "one", title: "AAA", offset: 0,+                work: left)+            store.insertEntry(+                id: shared, hostname: "dup.example", key: "one", title: "AAA", offset: 0,+                work: right)+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)+        let row = try #require(presentation.allRows.first { $0.id == shared })+        #expect(row.isTorn == false)++        let detail = try await repository.entryTeachingDetail(id: shared)++        if case .torn = detail.groupState {+            Issue.record("Entry detail reads torn what Recent reads whole (Req 3.2)")+        }+        #expect(detail.groupState == .group(rowCount: 2))+    }++    // MARK: - Presentation dedup (Req 5.5)++    @Test("A split Work group is one Works row holding every row's Entries")+    func splitWorkGroupIsOneWork() async throws {+        let library = try PublicationFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertWork(id: shared, hostname: "dup.example", title: "Serial", offset: 0)+            let second = store.insertWork(+                id: shared, hostname: "dup.example", title: "Serial", offset: 5, notes: "kept")+            store.insertEntry(+                hostname: "dup.example", key: "a", title: "One", offset: 0, work: first)+            store.insertEntry(+                hostname: "dup.example", key: "b", title: "Two", offset: 5, work: second)+        }+        let repository = try await library.openForApp()++        let works = try await repository.works()++        #expect(works.works.count == 1)+        let work = try #require(works.works.first)+        #expect(work.id == shared)+        // Every row's Entries, under the one Work (Req 5.5).+        #expect(work.entries.count == 2)+        // The group's authored content, not the representative row's (Q41).+        #expect(work.genericNotes == "kept")+        #expect(works.unattachedEntries.isEmpty)+    }++    @Test("A split Entry group appears once among the unattached Entries")+    func unattachedEntriesAreDeduped() async throws {+        let library = try PublicationFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: shared, hostname: "dup.example", key: "one", title: "A", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", key: "one", title: "A", offset: 5)+        }+        let repository = try await library.openForApp()++        let works = try await repository.works()++        #expect(works.unattachedEntries.map(\.id) == [shared])+    }++    @Test("The Work pickers show one logical record per group")+    func pickersAreDeduped() async throws {+        let library = try PublicationFixture()+        let shared = UUID()+        var entryID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertWork(id: shared, hostname: "dup.example", title: "Serial", offset: 0)+            store.insertWork(id: shared, hostname: "dup.example", title: "Serial", offset: 5)+            store.insertWork(hostname: "dup.example", title: "Other", offset: 10)+            entryID = store.insertEntry(+                hostname: "dup.example", key: "a", title: "One", offset: 0).id+        }+        let repository = try await library.openForApp()++        let destinations = try await repository.workDestinations(for: entryID)+        #expect(destinations.count == 2)+        #expect(Set(destinations.map(\.id)).count == 2)++        let mergeTargets = try await repository.mergeDestinations(for: shared)+        #expect(mergeTargets.count == 1)+    }++    /// Req 8.2's counterpart on the read side: the archive holds one record per+    /// application UUID, so a row count would report a number the backup could+    /// never match.+    @Test("Record counts count logical records, not rows")+    func recordCountsAreLogical() async throws {+        let library = try PublicationFixture()+        let sharedEntry = UUID()+        let sharedWork = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(+                id: sharedEntry, hostname: "dup.example", key: "one", title: "A", offset: 0)+            store.insertEntry(+                id: sharedEntry, hostname: "dup.example", key: "one", title: "A", offset: 5)+            store.insertWork(id: sharedWork, hostname: "dup.example", title: "Serial", offset: 0)+            store.insertWork(id: sharedWork, hostname: "dup.example", title: "Serial", offset: 5)+        }+        let repository = try await library.openForApp()++        let counts = try await repository.recordCounts()++        #expect(counts.entries == 1)+        #expect(counts.works == 1)+    }++    // MARK: - Scan-shaped helpers++    private static func entrySet(+        classification: DuplicateSetClassification+    ) -> EntryDuplicateSet {+        let members = [DuplicateStore.rankedID(1), DuplicateStore.rankedID(2)].map { id in+            DuplicateMember<EntryAuthoredContent>(+                id: id, rowCount: 1,+                variants: [+                    AuthoredVariant(+                        content: EntryAuthoredContent(note: id.uuidString),+                        firstCapturedAt: .distantPast)+                ],+                firstCapturedAt: .distantPast, lastActivityAt: .distantPast)+        }+        return DuplicateSet(+            key: DuplicateSetKey(recordType: .entry, memberIDs: members.map(\.id)),+            members: members,+            variants: GroupOrdering.mergedVariants(members.flatMap(\.variants)),+            classification: classification)+    }++    private static func workSet(tornMember: Bool) -> WorkDuplicateSet {+        let first = DuplicateMember<WorkAuthoredContent>(+            id: DuplicateStore.rankedID(1), rowCount: tornMember ? 2 : 1,+            variants: tornMember+                ? [+                    AuthoredVariant(content: WorkAuthoredContent(genericNotes: "a"), firstCapturedAt: .distantPast),+                    AuthoredVariant(content: WorkAuthoredContent(genericNotes: "b"), firstCapturedAt: .distantPast),+                ]+                : [AuthoredVariant(content: WorkAuthoredContent(genericNotes: "a"), firstCapturedAt: .distantPast)],+            firstCapturedAt: .distantPast, lastActivityAt: .distantPast)+        let second = DuplicateMember<WorkAuthoredContent>(+            id: DuplicateStore.rankedID(2), rowCount: 1,+            variants: [+                AuthoredVariant(content: WorkAuthoredContent(genericNotes: "c"), firstCapturedAt: .distantPast)+            ],+            firstCapturedAt: .distantPast, lastActivityAt: .distantPast)+        return DuplicateSet(+            key: DuplicateSetKey(recordType: .work, memberIDs: [first.id, second.id]),+            members: [first, second],+            variants: GroupOrdering.mergedVariants(first.variants + second.variants),+            classification: .divergent)+    }+}++// MARK: - Fixture++/// A fixed-path V4 library seeded through plain `insert`/`save` and then opened+/// the way the app opens it — the `RecentToleranceFixture` shape, because none+/// of the shapes here is reachable through the validating commit path either.+private final class PublicationFixture {+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    let directory: URL+    let configuration: LibraryConfiguration+    private var containers: [ModelContainer] = []++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismDuplicatePublication-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+        try FileManager.default.createDirectory(+            at: configuration.v4StoreURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)+    }++    func seed(_ body: (PublicationSeedStore) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let store = PublicationSeedStore(context: ModelContext(container))+        try body(store)+        try store.context.save()+        try V5RelationshipPass.run(context: store.context)+        withExtendedLifetime(container) {}+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)+    }++    func readContext() throws -> ModelContext {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        containers.append(container)+        return ModelContext(container)+    }++    func openForApp() async throws -> LibraryRepository {+        let (_, repository) = try await LibraryRepository.openV4ForApp(+            configuration, capabilities: .m4,+            clock: FixedRepositoryClock(Self.epoch),+            saveStrategy: ModelContextSaveStrategy())+        return repository+    }++    deinit {+        try? FileManager.default.removeItem(at: directory)+    }+}++private final class PublicationSeedStore {+    let context: ModelContext++    init(context: ModelContext) { self.context = context }++    @discardableResult+    func insertSite(hostname: String) -> Site {+        let site = Site(hostname: hostname)+        site.mode = .untaught+        context.insert(site)+        return site+    }++    /// `key` is the conservative identity key, which is the Entry duplicate+    /// relation (Q8) — two rows sharing it are one set.+    @discardableResult+    func insertEntry(+        id: UUID = UUID(), hostname: String, key: String, title: String,+        offset: TimeInterval, note: String = "", work: Work? = nil,+        chapterTitle: String? = nil+    ) -> Entry {+        let rawURL = "https://\(hostname)/read/\(key)"+        let entry = Entry(+            id: id, captureTitle: title, captureTitleSource: .host, rawURLString: rawURL,+            hostname: hostname, entryIdentityKey: rawURL,+            timestamp: PublicationFixture.epoch.addingTimeInterval(offset),+            note: note)+        entry.conservativeIdentityKey = rawURL+        entry.lastSharedAt = PublicationFixture.epoch.addingTimeInterval(offset)+        context.insert(entry)+        if let work {+            entry.work = work+            entry.workAssignmentProvenance = .manual+        }+        if let chapterTitle {+            entry.chapterTitle = chapterTitle+            entry.chapterTitleProvenance = .manual+        }+        return entry+    }++    /// Seeded Works carry a parsed title and parsed provenance, so they read as+    /// the *parsed* Work the fixture means rather than as one carrying an+    /// authored title (Q75).+    @discardableResult+    func insertWork(+        id: UUID = UUID(), hostname: String, title: String, offset: TimeInterval,+        notes: String = ""+    ) -> Work {+        let work = Work(+            id: id, displayTitle: title, siteHostname: hostname,+            timestamp: PublicationFixture.epoch.addingTimeInterval(offset))+        work.lastParsedTitle = title+        work.titleProvenance = .parsed+        work.genericNotes = notes+        context.insert(work)+        return work+    }++    @discardableResult+    func insertTitlePattern(+        id: UUID = UUID(), site: Site, isActive: Bool = false, version: Int = 1,+        offset: TimeInterval = 0, definition: PatternDefinition = .wholeTitle+    ) throws -> TitlePattern {+        let pattern = try TitlePattern(+            id: id, version: version, isActive: isActive,+            createdAt: PublicationFixture.epoch.addingTimeInterval(offset),+            definition: definition, site: site)+        context.insert(pattern)+        site.patterns = site.patternValues + [pattern]+        return pattern+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift Added +588 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swiftnew file mode 100644index 0000000..06689d1--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift@@ -0,0 +1,588 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Req 1.1 and the classification half of Req 1.5/1.6: what the store holds is+/// derived in one enumerate pass per type, and every set it finds lands in+/// exactly one of silently resolvable, divergent, or deferred.+///+/// The relation is the *conservative* identity key, never the derived one (Q8):+/// a mis-taught rule can make two different chapters share a derived key, and+/// the url-identity-re-share spec deliberately preserves that state for+/// re-teaching rather than treating it as a duplicate.+@Suite("Duplicate scan", .serialized)+struct DuplicateScanTests {++    // MARK: - Entry bucketing++    @Test("Two Entries sharing a conservative key on one host are one set of two members")+    func entriesShareAConservativeKey() throws {+        let store = try ScanStore()+        let first = store.addEntry(key: "chapter-1", offset: 0)+        let second = store.addEntry(key: "chapter-1", offset: 60)+        try store.commit()++        let result = try store.scan()++        #expect(result.entrySets.count == 1)+        let set = try #require(result.entrySets.first)+        #expect(set.key.recordType == .entry)+        #expect(Set(set.members.map(\.id)) == Set([first.id, second.id]))+        #expect(set.members.allSatisfy { $0.rowCount == 1 })+    }++    @Test("A lone split group is a set of one member with two rows")+    func loneSplitGroupIsASet() throws {+        let store = try ScanStore()+        let shared = UUID()+        store.addEntry(id: shared, key: "chapter-1", offset: 0)+        store.addEntry(id: shared, key: "chapter-1", offset: 60)+        try store.commit()++        let result = try store.scan()++        let set = try #require(result.entrySets.first)+        #expect(set.members.count == 1)+        #expect(set.members.first?.rowCount == 2)+        #expect(set.members.first?.id == shared)+    }++    @Test("The same conservative key on two hostnames is two different chapters")+    func hostnameSeparatesTheKeyBucket() throws {+        let store = try ScanStore()+        store.addEntry(key: "chapter-1", offset: 0)+        store.addEntry(key: "chapter-1", offset: 60, hostname: "other.example")+        try store.commit()++        #expect(try store.scan().entrySets.isEmpty)+    }++    /// Q64: an Entry with no conservative key must not congeal with every other+    /// keyless Entry on its hostname.+    @Test("An empty conservative key never joins a key bucket")+    func emptyConservativeKeyNeverBuckets() throws {+        let store = try ScanStore()+        store.addEntry(key: "", offset: 0)+        store.addEntry(key: "", offset: 60)+        store.addEntry(key: "", offset: 120)+        try store.commit()++        #expect(try store.scan().entrySets.isEmpty)+    }++    /// A split group whose rows carry different conservative keys links both+    /// buckets — the case that makes a set a transitive closure rather than a+    /// bucket.+    @Test("Sets are connected components, not single buckets")+    func setsAreConnectedComponents() throws {+        let store = try ScanStore()+        let bridge = UUID()+        store.addEntry(id: bridge, key: "left", offset: 0)+        store.addEntry(id: bridge, key: "right", offset: 10)+        let onLeft = store.addEntry(key: "left", offset: 20)+        let onRight = store.addEntry(key: "right", offset: 30)+        try store.commit()++        let result = try store.scan()++        #expect(result.entrySets.count == 1)+        let set = try #require(result.entrySets.first)+        #expect(Set(set.members.map(\.id)) == Set([bridge, onLeft.id, onRight.id]))+    }++    // MARK: - Work bucketing (§2.4)++    @Test("Works bucket by URL identity where taught")+    func worksBucketByURLIdentity() throws {+        let store = try ScanStore()+        let first = store.addWork(title: "First Parse", urlIdentity: "serial-7", offset: 0)+        let second = store.addWork(title: "Second Parse", urlIdentity: "serial-7", offset: 60)+        try store.commit()++        let result = try store.scan()++        let set = try #require(result.workSets.first)+        #expect(set.key.recordType == .work)+        #expect(Set(set.members.map(\.id)) == Set([first.id, second.id]))+    }++    @Test("Works fall back to site plus parsed title where no URL identity is taught")+    func worksBucketByParsedTitle() throws {+        let store = try ScanStore()+        store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+        try store.commit()++        #expect(try store.scan().workSets.count == 1)+    }++    /// Q64: `createWork` never sets a parsed title, so without the guard every+    /// reader-created Work on a hostname would congeal into one spurious set.+    @Test("A blank parsed title never joins a key bucket")+    func blankParsedTitleNeverBuckets() throws {+        let store = try ScanStore()+        store.addWork(title: "One", offset: 0)+        store.addWork(title: "Two", offset: 60)+        store.addWork(title: "Three", parsedTitle: "   ", offset: 120)+        try store.commit()++        #expect(try store.scan().workSets.isEmpty)+    }++    // MARK: - Rule bucketing++    @Test("Rule rows bucket by application UUID alone")+    func ruleRowsBucketByID() throws {+        let store = try ScanStore()+        let site = store.addSite()+        let patternID = UUID()+        try store.addPattern(id: patternID, site: site)+        try store.addPattern(id: patternID, site: site)+        let ruleID = UUID()+        try store.addRule(id: ruleID, site: site)+        try store.addRule(id: ruleID, site: site)+        try store.addRule(site: site)+        try store.commit()++        let result = try store.scan()++        #expect(result.titleRuleSets.map(\.key.memberIDs) == [[patternID]])+        #expect(result.urlRuleSets.map(\.key.memberIDs) == [[ruleID]])+        // Q39: rule records have no reader-authored fields, so a rule set is+        // always silently resolvable and never torn.+        #expect(result.titleRuleSets.allSatisfy { $0.classification == .silentlyResolvable })+        #expect(result.urlRuleSets.allSatisfy { !$0.isTorn })+    }++    // MARK: - Classification++    @Test("A set whose members carry nothing a reader wrote resolves silently")+    func allBareResolvesSilently() throws {+        let store = try ScanStore()+        store.addEntry(key: "chapter-1", offset: 0)+        store.addEntry(key: "chapter-1", offset: 60)+        try store.commit()++        let set = try #require(try store.scan().entrySets.first)+        #expect(set.classification == .silentlyResolvable)+        #expect(set.variants.isEmpty)+    }++    /// Decision 1: a bare member carries nothing a reader could lose, so folding+    /// it in has the same safety property as the identical case.+    @Test("A bare member beside an authored one resolves silently")+    func bareBesideAuthoredResolvesSilently() throws {+        let store = try ScanStore()+        store.addEntry(key: "chapter-1", offset: 0)+        let authored = store.addEntry(key: "chapter-1", offset: 60)+        authored.note = "worth keeping"+        try store.commit()++        let set = try #require(try store.scan().entrySets.first)+        #expect(set.classification == .silentlyResolvable)+        #expect(set.variants.map(\.content.note) == ["worth keeping"])+    }++    @Test("Members carrying the same authored content are one variant")+    func agreeingMembersAreOneVariant() throws {+        let store = try ScanStore()+        let first = store.addEntry(key: "chapter-1", offset: 0)+        first.note = "same"+        let second = store.addEntry(key: "chapter-1", offset: 60)+        second.note = "same"+        try store.commit()++        let set = try #require(try store.scan().entrySets.first)+        #expect(set.variants.count == 1)+        #expect(set.classification == .silentlyResolvable)+        // The variant's displayed date is the earliest among the rows carrying it.+        #expect(set.variants.first?.firstCapturedAt == ScanStore.epoch)+    }++    @Test("Two disagreeing members make the set divergent")+    func disagreementIsDivergent() throws {+        let store = try ScanStore()+        let first = store.addEntry(key: "chapter-1", offset: 0)+        first.note = "one reading"+        let second = store.addEntry(key: "chapter-1", offset: 60)+        second.note = "another reading"+        try store.commit()++        let set = try #require(try store.scan().entrySets.first)+        #expect(set.classification == .divergent)+        #expect(set.variants.count == 2)+        // Q42: the leading variant is the one whose earliest carrying row is+        // earliest.+        #expect(set.variants.first?.content.note == "one reading")+    }++    /// Req 3.3: bare and agreeing members must not enlarge the decision.+    @Test("Bare members do not enlarge a divergent set's variant list")+    func bareMembersDoNotEnlargeTheDecision() throws {+        let store = try ScanStore()+        let first = store.addEntry(key: "chapter-1", offset: 0)+        first.note = "one reading"+        let second = store.addEntry(key: "chapter-1", offset: 60)+        second.note = "another reading"+        store.addEntry(key: "chapter-1", offset: 120)+        store.addEntry(key: "chapter-1", offset: 180)+        try store.commit()++        let set = try #require(try store.scan().entrySets.first)+        #expect(set.members.count == 4)+        #expect(set.variants.count == 2)+        #expect(set.classification == .divergent)+    }++    @Test("A group whose rows disagree is torn and its set divergent")+    func internalDisagreementIsTorn() throws {+        let store = try ScanStore()+        let shared = UUID()+        let first = store.addEntry(id: shared, key: "chapter-1", offset: 0)+        first.note = "device one"+        let second = store.addEntry(id: shared, key: "chapter-1", offset: 60)+        second.note = "device two"+        try store.commit()++        let set = try #require(try store.scan().entrySets.first)+        let member = try #require(set.members.first)+        #expect(member.isTorn)+        #expect(member.authoredContent == nil)+        #expect(member.variants.count == 2)+        #expect(set.classification == .divergent)+        #expect(set.isTorn)+    }++    @Test("A non-torn split group reports its single variant as the group's content")+    func nonTornGroupReportsOneContent() throws {+        let store = try ScanStore()+        let shared = UUID()+        let first = store.addEntry(id: shared, key: "chapter-1", offset: 0)+        first.note = "one note"+        store.addEntry(id: shared, key: "chapter-1", offset: 60)+        try store.commit()++        let member = try #require(try store.scan().entrySets.first?.members.first)+        #expect(member.isTorn == false)+        #expect(member.authoredContent?.note == "one note")+        // Member timestamps are the extremes across the rows.+        #expect(member.firstCapturedAt == ScanStore.epoch)+        #expect(member.lastActivityAt == ScanStore.epoch.addingTimeInterval(60))+    }++    // MARK: - Req 1.6: deferral behind an unresolved Work set++    /// "Unresolved" in Req 1.6 means the Work set has no determined survivor,+    /// which is a *divergent* set: the surviving Work is the reader's Merge+    /// choice, and until they make it the collapsed Entry's assignment has+    /// nothing to point at (Q26).+    @Test("An Entry set spanning a divergent Work set defers to it")+    func entrySetSpanningADivergentWorkSetDefers() throws {+        let store = try ScanStore()+        let leftWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        leftWork.genericNotes = "one reading"+        let rightWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+        rightWork.genericNotes = "another reading"+        let left = store.addEntry(key: "chapter-1", offset: 0)+        left.work = leftWork+        let right = store.addEntry(key: "chapter-1", offset: 60)+        right.work = rightWork+        try store.commit()++        let result = try store.scan()++        let workSet = try #require(result.workSets.first)+        #expect(workSet.classification == .divergent)+        let entrySet = try #require(result.entrySets.first)+        #expect(entrySet.classification == .deferred(blockedBy: workSet.key))+    }++    /// The other half of the same rule, and the one the first cut got wrong: a+    /// *silently resolvable* Work set already names its survivor by the same+    /// deterministic rule its collapse will use, so the assignment target is+    /// defined and the Entry set has nothing to wait for. Deferring here would+    /// cost a whole extra pass for nothing, and Q62 gives such a deferral no+    /// re-arm of its own.+    @Test("An Entry set spanning a silently resolvable Work set does not defer")+    func entrySetSpanningASilentlyResolvableWorkSetDoesNotDefer() throws {+        let store = try ScanStore()+        let leftWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        let rightWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+        let left = store.addEntry(key: "chapter-1", offset: 0)+        left.work = leftWork+        let right = store.addEntry(key: "chapter-1", offset: 60)+        right.work = rightWork+        try store.commit()++        let result = try store.scan()++        #expect(try #require(result.workSets.first).classification == .silentlyResolvable)+        #expect(try #require(result.entrySets.first).classification == .silentlyResolvable)+    }++    @Test("An Entry set whose members all point at one Work does not defer")+    func agreeingAssignmentDoesNotDefer() throws {+        let store = try ScanStore()+        let work = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+        let left = store.addEntry(key: "chapter-1", offset: 0)+        left.work = work+        let right = store.addEntry(key: "chapter-1", offset: 60)+        right.work = work+        try store.commit()++        let entrySet = try #require(try store.scan().entrySets.first)+        #expect(entrySet.classification == .silentlyResolvable)+    }++    /// Definitions: assignments referring to members of one Work duplicate set+    /// are equal, so a group split across those two Works is not torn — and with+    /// the Work set silently resolvable, there is nothing to wait for either.+    /// The normalisation is what keeps the group from reading as torn (Q38); it+    /// applies to every Work set, divergent ones included.+    @Test("Assignments across one Work set agree rather than tearing the group")+    func assignmentNormalisationPreventsFalseTearing() throws {+        let store = try ScanStore()+        let leftWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        let rightWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+        let shared = UUID()+        let first = store.addEntry(id: shared, key: "chapter-1", offset: 0)+        first.work = leftWork+        first.workAssignmentProvenance = .manual+        let second = store.addEntry(id: shared, key: "chapter-1", offset: 60)+        second.work = rightWork+        second.workAssignmentProvenance = .manual+        try store.commit()++        let result = try store.scan()+        let member = try #require(result.entrySets.first?.members.first)+        #expect(member.isTorn == false)+        #expect(result.entrySets.first?.classification == .silentlyResolvable)+    }++    /// The same shape behind a *divergent* Work set: the normalisation still+    /// keeps the group whole, and the set defers rather than surfacing.+    @Test("Assignments across a divergent Work set agree and the set defers")+    func assignmentNormalisationHoldsBehindADivergentWorkSet() throws {+        let store = try ScanStore()+        let leftWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        leftWork.genericNotes = "one reading"+        let rightWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+        rightWork.genericNotes = "another reading"+        let shared = UUID()+        let first = store.addEntry(id: shared, key: "chapter-1", offset: 0)+        first.work = leftWork+        first.workAssignmentProvenance = .manual+        let second = store.addEntry(id: shared, key: "chapter-1", offset: 60)+        second.work = rightWork+        second.workAssignmentProvenance = .manual+        try store.commit()++        let result = try store.scan()+        let member = try #require(result.entrySets.first?.members.first)+        #expect(member.isTorn == false)+        #expect(result.entrySets.first?.classification+            == .deferred(blockedBy: try #require(result.workSets.first).key))+    }++    @Test("Deferral outranks divergence so a deferred set never surfaces on its own account")+    func deferralOutranksDivergence() throws {+        let store = try ScanStore()+        let leftWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        leftWork.genericNotes = "one Work reading"+        let rightWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+        rightWork.genericNotes = "another Work reading"+        let left = store.addEntry(key: "chapter-1", offset: 0)+        left.work = leftWork+        left.note = "one reading"+        let right = store.addEntry(key: "chapter-1", offset: 60)+        right.work = rightWork+        right.note = "another reading"+        try store.commit()++        let entrySet = try #require(try store.scan().entrySets.first)+        #expect(entrySet.variants.count == 2)+        if case .deferred = entrySet.classification {} else {+            Issue.record("expected the set to defer, got \(entrySet.classification)")+        }+    }++    // MARK: - Totality and determinism++    @Test("Every set classifies as exactly one of the three states")+    func partitionIsTotal() throws {+        let store = try ScanStore()+        store.addEntry(key: "silent", offset: 0)+        store.addEntry(key: "silent", offset: 60)+        let divergentLeft = store.addEntry(key: "divergent", offset: 0)+        divergentLeft.note = "left"+        let divergentRight = store.addEntry(key: "divergent", offset: 60)+        divergentRight.note = "right"+        let leftWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        leftWork.genericNotes = "one reading"+        let rightWork = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+        rightWork.genericNotes = "another reading"+        let deferredLeft = store.addEntry(key: "deferred", offset: 0)+        deferredLeft.work = leftWork+        let deferredRight = store.addEntry(key: "deferred", offset: 60)+        deferredRight.work = rightWork+        try store.commit()++        let result = try store.scan()++        #expect(result.entrySets.count == 3)+        var silent = 0, divergent = 0, deferred = 0+        for set in result.entrySets {+            switch set.classification {+            case .silentlyResolvable: silent += 1+            case .divergent: divergent += 1+            case .deferred: deferred += 1+            }+        }+        #expect((silent, divergent, deferred) == (1, 1, 1))+    }++    @Test("A library with no duplicates produces no sets")+    func cleanLibraryProducesNothing() throws {+        let store = try ScanStore()+        store.addEntry(key: "chapter-1", offset: 0)+        store.addEntry(key: "chapter-2", offset: 60)+        store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        try store.commit()++        let result = try store.scan()++        #expect(result.isEmpty)+    }++    /// The scan derives and returns; it writes nothing. Cheap lock on that: a+    /// context with no pending changes still has none afterwards, whatever+    /// relationships the bucketing faulted along the way.+    @Test("The scan leaves the context with nothing to save")+    func scanWritesNothing() throws {+        let store = try ScanStore()+        let work = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+        store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+        let shared = UUID()+        let first = store.addEntry(id: shared, key: "chapter-1", offset: 0)+        first.note = "device one"+        first.work = work+        let second = store.addEntry(id: shared, key: "chapter-1", offset: 60)+        second.note = "device two"+        let site = store.addSite()+        let patternID = UUID()+        try store.addPattern(id: patternID, site: site)+        try store.addPattern(id: patternID, site: site)+        try store.commit()++        #expect(store.context.hasChanges == false)+        _ = try store.scan()+        #expect(store.context.hasChanges == false)+    }++    @Test("Set order and member order do not depend on Dictionary seeding")+    func outputOrderIsImposed() throws {+        let store = try ScanStore()+        for index in 0..<6 {+            store.addEntry(key: "chapter-\(index)", offset: TimeInterval(index))+            store.addEntry(key: "chapter-\(index)", offset: TimeInterval(index) + 100)+        }+        try store.commit()++        let first = try store.scan()+        let second = try store.scan()++        #expect(first.entrySets.count == 6)+        #expect(first.entrySets.map(\.key) == second.entrySets.map(\.key))+        #expect(first.entrySets.map { $0.members.map(\.id) }+            == second.entrySets.map { $0.members.map(\.id) })+        #expect(first.entrySets.map(\.key) == first.entrySets.map(\.key).sorted())+    }+}++// MARK: - Fixture++private final class ScanStore {+    static let hostname = "scan.example"+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    let directory: URL+    let container: ModelContainer+    let context: ModelContext++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismDuplicateScan-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)+        let configuration = ModelConfiguration(+            "AsterismV3", schema: schema,+            url: directory.appending(path: "library.store"), cloudKitDatabase: .none)+        container = try ModelContainer(+            for: schema, migrationPlan: AsterismV5MigrationPlan.self,+            configurations: [configuration])+        context = ModelContext(container)+    }++    deinit { try? FileManager.default.removeItem(at: directory) }++    @discardableResult+    func addSite(hostname: String = ScanStore.hostname) -> Site {+        let site = Site(hostname: hostname)+        context.insert(site)+        return site+    }++    @discardableResult+    func addEntry(+        id: UUID = UUID(), key: String, offset: TimeInterval,+        hostname: String = ScanStore.hostname+    ) -> Entry {+        let entry = Entry(+            id: id, captureTitle: "Chapter", captureTitleSource: .host,+            rawURLString: "https://\(hostname)/read/\(key)", hostname: hostname,+            entryIdentityKey: key, timestamp: Self.epoch.addingTimeInterval(offset))+        entry.conservativeIdentityKey = key+        context.insert(entry)+        return entry+    }++    @discardableResult+    func addWork(+        id: UUID = UUID(), title: String, parsedTitle: String? = nil, urlIdentity: String? = nil,+        offset: TimeInterval, hostname: String = ScanStore.hostname+    ) -> Work {+        let work = Work(+            id: id, displayTitle: title, siteHostname: hostname,+            timestamp: Self.epoch.addingTimeInterval(offset))+        work.lastParsedTitle = parsedTitle+        work.urlIdentity = urlIdentity+        if urlIdentity != nil { work.urlIdentityState = .rule }+        context.insert(work)+        return work+    }++    @discardableResult+    func addPattern(id: UUID = UUID(), site: Site) throws -> TitlePattern {+        let pattern = try TitlePattern(+            id: id, version: 1, isActive: false, createdAt: Self.epoch,+            definition: .wholeTitle, site: site)+        context.insert(pattern)+        return pattern+    }++    @discardableResult+    func addRule(id: UUID = UUID(), site: Site) throws -> URLRulePattern {+        let rule = try URLRulePattern(+            id: id, version: 1, isCurrent: false, createdAt: Self.epoch, origin: .readerTaught,+            definition: .work(locator: .query(name: ExactScalarString("identity"))), site: site)+        context.insert(rule)+        return rule+    }++    func commit() throws { try context.save() }++    func scan() throws -> DuplicateScanResult { try DuplicateScan.run(context: context) }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift Added +568 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swiftnew file mode 100644index 0000000..cee31d4--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift@@ -0,0 +1,568 @@+import Foundation+import OSLog+import SwiftData++private let resolutionLogger = Logger(+    subsystem: "AsterismCore", category: "LibraryRepository+DuplicateResolution")++// Requirement 4, and Req 5.4 for Work sets holding a torn member: the one path+// by which a divergent set stops being divergent.+//+// Both halves derive the set through `DuplicateScan`, not through a second+// spelling of the same classification. The scan is what decides membership, what+// counts as a variant, and which member survives — and a sheet that computed any+// of those for itself could present a set the reconciler does not agree exists.++extension LibraryRepository {++    /// What the reader is being asked (Req 4.2).+    ///+    /// Throws `recordNotFound` where the set no longer exists — it collapsed, or+    /// the reader's other device resolved it — and `invalidInput` where it+    /// exists but is not the reader's to resolve.+    public func projectDuplicateResolution(+        setKey: DuplicateSetKey+    ) async throws -> DuplicateResolutionContract {+        try await withLockedContext(+            mode: .shared, operation: "projecting duplicate resolution"+        ) { context in+            let scan = try DuplicateScan.run(context: context)+            switch try Self.contract(for: setKey, scan: scan, context: context) {+            case .found(let contract):+                return contract+            case .gone:+                throw LibraryRepositoryError.recordNotFound(+                    type: "duplicate set", id: setKey.memberIDs.first ?? UUID())+            case .split:+                // Not "already resolved": the copies are still there, they are+                // two decisions now. The sheet says so rather than reporting a+                // resolution that never happened.+                throw LibraryRepositoryError.invalidInput(+                    operation: "projecting duplicate resolution", reason: Self.setSplitReason)+            }+        }+    }++    /// The reader's confirmation, in one commit (Req 4.4).+    ///+    /// Four things happen together and none of them can happen without the+    /// others: the survivor is recomputed under Req 3.1's rule, every one of its+    /// rows receives the outcome content, the losing members are deleted whole,+    /// and — for a Work set — their Entries move first so no Entry is ever+    /// unattached by the deletion (Req 5.2).+    ///+    /// The confirmation is the settling observation for its set (Q18). Deferring+    /// it to a later pass would leave the losers in place, keep the set+    /// divergent by definition, and re-surface the sheet the instant the reader+    /// confirmed it — a livelock. What settling wants is checked here instead,+    /// with the reader watching: the variants are re-derived from store state+    /// and compared against exactly what the sheet showed (Reqs 2.9, 4.6).+    public func commitDuplicateResolution(+        _ contract: DuplicateResolutionContract,+        choosing chosen: VariantID,+        appendingOtherNotes: Bool = false+    ) async throws -> DuplicateResolutionOutcome {+        let outcome = try await withLockedContext(+            mode: .exclusive, operation: "committing duplicate resolution"+        ) { context -> (DuplicateResolutionOutcome, [UUID], DuplicateSetKey?) in+            let scan = try DuplicateScan.run(context: context)+            let current: DuplicateResolutionContract+            switch try Self.contract(for: contract.setKey, scan: scan, context: context) {+            case .found(let projected): current = projected+            case .gone: return (.invalidated(reason: Self.setGoneReason), [], nil)+            case .split: return (.invalidated(reason: Self.setSplitReason), [], nil)+            }+            // Req 4.6 exactly: the *variants*, in order. A bare or agreeing+            // arrival moves the membership and not the decision, and refusing+            // for it would defeat the purpose of leaving it out of the sheet.+            guard current.variantIDs == contract.variantIDs else {+                return (.refreshed(current), [], nil)+            }+            guard current.variantIDs.contains(chosen) else {+                return (+                    .invalidated(reason: "The chosen copy is not one of this set's."), [], nil)+            }++            switch contract.setKey.recordType {+            case .entry:+                // Unreachable behind the lookup above, which ran over this same+                // scan — kept as the arm rather than a force, because the two+                // lookups agreeing is a property of one derivation, not of the+                // language.+                guard case .found(let set) = Self.matching(contract.setKey, in: scan.entrySets)+                else {+                    return (.invalidated(reason: Self.setGoneReason), [], nil)+                }+                // The same normalisation the scan classified under: two rows+                // pointing at two members of one Work set do not disagree+                // (Definitions, Q38). Without it the carrier lookup below+                // compares an un-normalised row against a normalised variant+                // and finds nothing — refusing a resolution for a set the scan+                // itself said was divergent on its *notes*.+                return try self.resolveEntrySet(+                    set, chosen: chosen, appendingOtherNotes: appendingOtherNotes,+                    canonicalWorkIDs: DuplicateScan.canonicalWorkIDs(scan.workSets),+                    context: context)+            case .work:+                guard case .found(let set) = Self.matching(contract.setKey, in: scan.workSets)+                else {+                    return (.invalidated(reason: Self.setGoneReason), [], nil)+                }+                return try self.resolveWorkSet(set, chosen: chosen, context: context)+            case .titleRule, .urlRule:+                // Rule groups carry nothing reader-authored and converge without+                // the reader (Q39); they can never reach a sheet.+                return (+                    .invalidated(reason: "Teaching records do not need a decision."), [], nil)+            }+        }++        let (result, losers, resolvedKey) = outcome+        if case .committed(let survivorID) = result {+            let type: CollapsedRecordType =+                contract.setKey.recordType == .entry ? .entry : .work+            for loser in losers { recordCollapse(loser: loser, survivor: survivorID, type: type) }+            // Both keys: the one the sheet was opened against and the one the+            // set had at commit, which differ whenever a copy arrived in+            // between. The set is gone either way, and a ledger entry that+            // outlives it would let the next pass compare a fresh set against a+            // fingerprint describing rows this commit deleted.+            duplicateLedger.forget(contract.setKey)+            if let resolvedKey { duplicateLedger.forget(resolvedKey) }+        }+        return result+    }++    // MARK: - Projection++    /// What a lookup of one set key found. Three answers, not two: a set that+    /// **split** since the sheet opened is not a set that stopped existing, and+    /// telling the reader it is would be a false sentence about their copies.+    enum ContractLookup {+        case found(DuplicateResolutionContract)+        /// No set the reader can act on holds any of those members any more.+        case gone+        /// The members now span more than one set (the Q99 nil arm).+        case split+    }++    /// The reader-facing sentences the two absent arms produce, spelled once so+    /// the commit path and the projection cannot word them differently.+    static let setGoneReason = "This set of copies no longer exists."+    static let setSplitReason =+        "These copies are no longer one set — they have separated. "+        + "Open them again to resolve each set."++    /// The contract for `setKey`, or why there is none.+    private static func contract(+        for setKey: DuplicateSetKey, scan: DuplicateScanResult, context: ModelContext+    ) throws -> ContractLookup {+        switch setKey.recordType {+        case .entry:+            switch matching(setKey, in: scan.entrySets) {+            case .gone: return .gone+            case .split: return .split+            case .found(let set):+                guard set.classification == .divergent, let leading = set.variants.first+                else { return .gone }+                let titles = try Self.workTitles(for: set, context: context)+                return .found(+                    .entry(+                        setKey: set.key,+                        variants: set.variants.map { Self.choice($0, workTitles: titles) },+                        differingFields: Self.differingEntryFields(set.variants),+                        preselected: leading.id))+            }+        case .work:+            switch matching(setKey, in: scan.workSets) {+            case .gone: return .gone+            case .split: return .split+            case .found(let set):+                guard set.classification == .divergent, let leading = set.variants.first+                else { return .gone }+                let rows = try Self.workRows(for: set, context: context)+                return .found(+                    .work(+                        setKey: set.key,+                        variants: set.variants.map { Self.choice($0, rows: rows) },+                        differingFields: Self.differingWorkFields(set.variants),+                        preselected: leading.id))+            }+        case .titleRule, .urlRule:+            return .gone+        }+    }++    /// The set that now holds the members `key` named — **not** the set whose+    /// key is equal to it.+    ///+    /// Membership *is* the key (`DuplicateSetKey`), which is exactly right for+    /// the settling ledger: a set that gains a member is a first observation and+    /// must not be deleted on the strength of an earlier one. It is exactly+    /// wrong here. A bare copy arriving between the sheet opening and the reader+    /// confirming produces a new key, so an equality lookup would report "this+    /// set no longer exists" — and Req 4.6 says in as many words that a bare or+    /// agreeing arrival must **not** refuse the confirmation. Matching on+    /// membership instead lets the variant compare, which is the real staleness+    /// test, do its job.+    ///+    /// `.split` where the members now span more than one set: a component that+    /// split is not one decision any more, and re-presenting half of it would be+    /// a guess at which half the reader meant. `.gone` where no set holds any of+    /// them — which includes the ordinary happy ending, a set the reader or the+    /// reconciler has already collapsed.+    ///+    /// A *partial* overlap — some members gone, the rest inside one set — is+    /// `.found`. The set is still one decision, and the variant compare above is+    /// what judges whether it is the decision the reader was shown.+    private static func matching<Content: AuthoredContent>(+        _ key: DuplicateSetKey, in sets: [DuplicateSet<Content>]+    ) -> SetLookup<Content> {+        let wanted = Set(key.memberIDs)+        let matches = sets.filter { !Set($0.key.memberIDs).isDisjoint(with: wanted) }+        switch matches.count {+        case 0: return .gone+        case 1: return .found(matches[0])+        default: return .split+        }+    }++    enum SetLookup<Content: AuthoredContent> {+        case found(DuplicateSet<Content>)+        case gone+        case split+    }++    private static func choice(+        _ variant: AuthoredVariant<EntryAuthoredContent>, workTitles: [UUID: String]+    ) -> EntryVariantChoice {+        EntryVariantChoice(+            id: variant.id,+            note: variant.content.note,+            rating: variant.content.rating,+            chapterTitle: variant.content.chapterTitle,+            workID: variant.content.workAssignment,+            workTitle: variant.content.workAssignment.flatMap { workTitles[$0] },+            intentionallyUnattached: variant.content.intentionallyUnattached,+            firstCapturedAt: variant.firstCapturedAt)+    }++    /// The Work half needs the row the variant came from, because+    /// `WorkAuthoredContent` deliberately holds a *normalised* title (Q34) and+    /// the sheet has to name the Work whatever its title's provenance.+    private static func choice(+        _ variant: AuthoredVariant<WorkAuthoredContent>, rows: [Work]+    ) -> WorkVariantChoice {+        let carrier = rows.first { GroupOrdering.authoredContent(of: $0) == variant.content }+        return WorkVariantChoice(+            id: variant.id,+            displayTitle: carrier?.displayTitle ?? variant.content.manualTitle ?? "",+            manualTitle: variant.content.manualTitle,+            genericNotes: variant.content.genericNotes,+            workURLString: variant.content.workURLString,+            genreTags: carrier?.genreTags ?? variant.content.genreTags,+            type: carrier?.type ?? variant.content.type ?? .other,+            firstCapturedAt: variant.firstCapturedAt)+    }++    /// Req 4.2: "every authored field in which the set differs". A field all the+    /// variants agree on is not a decision and does not belong on the sheet.+    private static func differingEntryFields(+        _ variants: [AuthoredVariant<EntryAuthoredContent>]+    ) -> [DuplicateResolutionField] {+        var fields: [DuplicateResolutionField] = []+        let contents = variants.map(\.content)+        // Note and rating are always shown (Req 4.2 names them explicitly),+        // whether or not they differ.+        fields.append(.note)+        fields.append(.rating)+        if Set(contents.map { $0.chapterTitle ?? "\u{0}" }).count > 1 {+            fields.append(.chapterTitle)+        }+        if Set(contents.map { $0.workAssignment?.uuidString ?? "" }).count > 1 {+            fields.append(.workAssignment)+        }+        if Set(contents.map(\.intentionallyUnattached)).count > 1 {+            fields.append(.intentionallyUnattached)+        }+        return fields+    }++    private static func differingWorkFields(+        _ variants: [AuthoredVariant<WorkAuthoredContent>]+    ) -> [DuplicateResolutionField] {+        var fields: [DuplicateResolutionField] = [.genericNotes]+        let contents = variants.map(\.content)+        if Set(contents.map { $0.manualTitle ?? "\u{0}" }).count > 1 { fields.append(.title) }+        if Set(contents.map { $0.workURLString ?? "\u{0}" }).count > 1 { fields.append(.workURL) }+        if Set(contents.map { $0.genreTags.joined(separator: "\u{1F}") }).count > 1 {+            fields.append(.genreTags)+        }+        if Set(contents.map { $0.type?.rawValue ?? "" }).count > 1 { fields.append(.type) }+        return fields+    }++    private static func workTitles(+        for set: EntryDuplicateSet, context: ModelContext+    ) throws -> [UUID: String] {+        let assigned = Array(Set(set.variants.compactMap { $0.content.workAssignment }))+        guard !assigned.isEmpty else { return [:] }+        // A predicate fetch for the handful of Works these variants name, not+        // the whole table filtered down to them afterwards.+        let rows = try context.fetch(+            FetchDescriptor<Work>(predicate: #Predicate { assigned.contains($0.id) }))+        var titles: [UUID: String] = [:]+        for (id, group) in workGroups(rows) {+            titles[id] = group.carrier.displayTitle+        }+        return titles+    }++    private static func workRows(+        for set: WorkDuplicateSet, context: ModelContext+    ) throws -> [Work] {+        let members = set.key.memberIDs+        return GroupOrdering.sortedWorkRows(+            try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { members.contains($0.id) })))+    }++    // MARK: - Commit: Entry sets++    private func resolveEntrySet(+        _ set: EntryDuplicateSet,+        chosen: VariantID,+        appendingOtherNotes: Bool,+        canonicalWorkIDs: [UUID: UUID],+        context: ModelContext+    ) throws -> (DuplicateResolutionOutcome, [UUID], DuplicateSetKey?) {+        let rowsByID = try DuplicateReconciler.entryRows(+            ids: set.key.memberIDs, context: context)+        guard let survivorID = set.members.first?.id,+              let survivorRows = rowsByID[survivorID], !survivorRows.isEmpty+        else {+            return (.invalidated(reason: "The surviving copy no longer exists."), [], nil)+        }+        guard let chosenVariant = set.variants.first(where: { $0.id == chosen }) else {+            return (.invalidated(reason: "The chosen copy is not one of this set's."), [], nil)+        }+        let allRows = rowsByID.values.flatMap { $0 }+        // Q84: the outcome content is copied off the row that carries it, so a+        // manual chapter title travels with its provenance instead of one being+        // invented for it.+        guard let carrier = GroupOrdering.sortedEntryRows(allRows).first(where: {+            GroupOrdering.authoredContent(of: $0)+                .normalizingAssignment(using: canonicalWorkIDs) == chosenVariant.content+        }) else {+            return (.invalidated(reason: "The chosen copy is no longer in the library."), [], nil)+        }++        // Req 4.3: the reader's option. The non-chosen variants' notes, in+        // variant order, so two devices given the same choice write the same+        // text.+        let note = appendingOtherNotes+            ? DuplicateNoteAppendFormatter.append(+                set.variants.filter { $0.id != chosen }.map(\.content.note),+                to: chosenVariant.content.note)+            : chosenVariant.content.note++        let timestamp = MillisecondInstant.quantize(clock.now())+        // Req 4.5: the resolution is a curation edit, not reading activity. So+        // `modifiedAt` records the edit and `lastSharedAt` inherits the set's+        // existing latest — the record keeps the Recent position the set already+        // had rather than jumping to the top for having been resolved.+        let lastShared = allRows.map(\.lastSharedAt).max() ?? .distantPast++        for row in survivorRows {+            Self.applyEntryOutcome(+                content: chosenVariant.content, note: note, carrier: carrier, to: row)+            row.lastSharedAt = lastShared+            row.modifiedAt = timestamp+        }++        let losers = set.members.dropFirst().map(\.id)+        for id in losers {+            for row in rowsByID[id] ?? [] {+                // The pointer goes before the row does. `context.delete` leaves+                // the Work's `entries` inverse holding the doomed row until the+                // save, and this path validates the prospective graph *before*+                // saving — so the Work would fail its own inverse check on an+                // Entry the resolution is in the middle of removing.+                row.work = nil+                context.delete(row)+            }+        }++        if let refusal = try commitResolution(+            context: context, hostname: carrier.hostname, operation: "resolution") {+            return (refusal, [], nil)+        }+        resolutionLogger.debug(+            "Resolved Entry duplicate set onto \(survivorID.uuidString) (\(losers.count) removed)")+        return (.committed(survivorID: survivorID), losers, set.key)+    }++    /// Writes the chosen variant onto one survivor row.+    ///+    /// Every authored field is written, including the ones the chosen variant+    /// *lacks*: the reader saw a variant carrying a manual chapter title and+    /// chose a different one, so leaving that title in place would keep content+    /// they decided against. Derived values are untouched — clearing a manual+    /// chapter title drops the provenance and the value together, and a re-parse+    /// derives it again.+    private static func applyEntryOutcome(+        content: EntryAuthoredContent,+        note: String,+        carrier: Entry,+        to row: Entry+    ) {+        row.note = note+        row.rating = content.rating++        if content.chapterTitle != nil {+            row.chapterTitle = carrier.chapterTitle+            row.chapterTitleProvenance = carrier.chapterTitleProvenance+        } else if row.chapterTitleProvenance == .manual {+            row.chapterTitle = nil+            row.chapterTitleProvenance = .none+        }++        if content.intentionallyUnattached {+            // Q85: every write path that sets the flag nils the pointer, and a+            // row that is intentionally unattached and still points somewhere is+            // a state nothing else produces.+            row.intentionallyUnattached = true+            row.work = nil+            return+        }+        row.intentionallyUnattached = false+        if content.workAssignment != nil {+            // The carrier's own pointer — never the normalised equality key,+            // which names a survivor rule's candidate rather than anybody's+            // decision (Q67), and never a re-fetch by the content's UUID, which+            // throws when that Work has since gone and would fail the whole+            // resolution over an assignment.+            row.work = carrier.work+            row.workAssignmentProvenance = carrier.workAssignmentProvenance+        } else if row.workAssignmentProvenance == .manual {+            row.workAssignmentProvenance = .none+        }+    }++    // MARK: - Commit: Work sets++    private func resolveWorkSet(+        _ set: WorkDuplicateSet, chosen: VariantID, context: ModelContext+    ) throws -> (DuplicateResolutionOutcome, [UUID], DuplicateSetKey?) {+        let rowsByID = try DuplicateReconciler.workRows(+            ids: set.key.memberIDs, context: context)+        guard let survivorID = set.members.first?.id,+              let survivorRows = rowsByID[survivorID], !survivorRows.isEmpty+        else {+            return (.invalidated(reason: "The surviving copy no longer exists."), [], nil)+        }+        guard let chosenVariant = set.variants.first(where: { $0.id == chosen }) else {+            return (.invalidated(reason: "The chosen copy is not one of this set's."), [], nil)+        }+        let allRows = rowsByID.values.flatMap { $0 }+        guard let carrier = GroupOrdering.sortedWorkRows(allRows).first(+            where: { GroupOrdering.authoredContent(of: $0) == chosenVariant.content })+        else {+            return (.invalidated(reason: "The chosen copy is no longer in the library."), [], nil)+        }++        // Req 5.4: the Work path appends unconditionally, unions the tags,+        // adopts a URL the chosen variant lacks, and records a discarded manual+        // title — all of it through the helper Merge uses, so the two routes+        // cannot give one field opposite outcomes (Q51).+        let others = set.variants.filter { $0.id != chosen }.map { variant -> WorkVariantSide in+            let row = GroupOrdering.sortedWorkRows(allRows).first {+                GroupOrdering.authoredContent(of: $0) == variant.content+            }+            return WorkVariantSide(+                displayTitle: row?.displayTitle ?? variant.content.manualTitle ?? "",+                titleProvenance: row?.titleProvenance ?? .parsed,+                workURLString: variant.content.workURLString,+                genericNotes: variant.content.genericNotes,+                genreTags: row?.genreTags ?? variant.content.genreTags,+                type: row?.type ?? variant.content.type ?? .other)+        }+        let chosenSide = WorkVariantSide(+            displayTitle: carrier.displayTitle, titleProvenance: carrier.titleProvenance,+            workURLString: carrier.workURLString, genericNotes: carrier.genericNotes,+            genreTags: carrier.genreTags, type: carrier.type)+        let union = WorkVariantUnion.fold(into: chosenSide, others: others)++        let timestamp = MillisecondInstant.quantize(clock.now())+        for row in survivorRows {+            // Q34: the title is the chosen variant's, provenance and all, so a+            // parsed title stays parsed and a manual one stays manual.+            row.displayTitle = carrier.displayTitle+            row.titleProvenance = carrier.titleProvenance+            row.genericNotes = union.genericNotes+            row.genreTags = union.genreTags+            row.workURLString = union.workURL+            row.type = carrier.type+            // Q74: every row of the group takes the same stamp, or the group's+            // representative moves for a reason that was never about the record.+            row.modifiedAt = timestamp+        }++        // Req 5.2, and Req 2.1's ordering: the Entries move before anything is+        // deleted, so no interruption can leave an Entry reachable only through+        // a doomed Work. Their own timestamps are untouched — the move is not+        // an edit to them.+        let losers = set.members.dropFirst().map(\.id)+        let losingRows = losers.flatMap { rowsByID[$0] ?? [] }+        DuplicateReconciler.repointEntries(+            from: losingRows, to: GroupOrdering.sortedWorkRows(survivorRows))+        for row in losingRows { context.delete(row) }++        if let refusal = try commitResolution(+            context: context, hostname: carrier.siteHostname, operation: "resolution") {+            return (refusal, [], nil)+        }+        resolutionLogger.debug(+            "Resolved Work duplicate set onto \(survivorID.uuidString) (\(losers.count) removed)")+        return (.committed(survivorID: survivorID), losers, set.key)+    }++    // MARK: - Commit boundary++    /// Validates the prospective graph and saves once, or rolls the whole+    /// resolution back (Req 2.5). Returns the refusal, or nil where it committed.+    ///+    /// Merge's shape, for the same reason Merge has it: a resolution deletes+    /// records and re-points relationships, and an invalid result must leave the+    /// library exactly as it was rather than persisting damage.+    private func commitResolution(+        context: ModelContext, hostname: String, operation: String+    ) throws -> DuplicateResolutionOutcome? {+        let diagnoses: [String: V4ValidationError]+        do {+            diagnoses = try V4LibraryValidator.validate(context: context).quarantineMap()+        } catch {+            context.rollback()+            _ = try? context.fetch(FetchDescriptor<Entry>())+            _ = try? context.fetch(FetchDescriptor<Work>())+            resolutionLogger.error(+                "Duplicate \(operation) failed validation: \(String(describing: error), privacy: .public)")+            return .invalidated(reason: "The resolution could not be validated: \(error)")+        }+        if let reason = diagnoses[hostname] {+            context.rollback()+            _ = try? context.fetch(FetchDescriptor<Entry>())+            _ = try? context.fetch(FetchDescriptor<Work>())+            return .invalidated(+                reason: "The resolution produced an invalid library state: \(reason)")+        }+        do {+            try saveStrategy.save(context)+        } catch {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "atomically saving duplicate \(operation)",+                reason: String(describing: error))+        }+        return nil+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swift Added +524 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swiftnew file mode 100644index 0000000..38f06e6--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swift@@ -0,0 +1,524 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// The properties duplicate reconciliation is *defined* by, over generated set+/// shapes rather than hand-picked ones (Reqs 2.1, 2.4, 2.5, 2.6, and the+/// Definitions' classification).+///+/// Seeded and deterministic, in the `SiteReconcilerTests` pattern: a failing+/// seed is a fixture anybody can reproduce, and the generator is the only thing+/// that has to be read to know what shapes were covered.+///+/// The generator returns a `DuplicatePlan` — what it *intended* to build,+/// derived from the Definitions and not from the code under test. Every+/// expectation below is stated against that plan. A property whose expected+/// value is computed by the implementation is a restatement of the+/// implementation, and passes against any implementation that keeps its own+/// arithmetic consistent.+@Suite("Duplicate reconciliation properties", .serialized)+struct DuplicatePropertyTests {++    // MARK: - Partition totality++    /// Every set classifies as exactly one of silently resolvable, divergent, or+    /// deferred; no logical record is a member of two sets; and the+    /// classification is the one the *fixture* implies.+    @Test("Every set classifies as the fixture implies, and no record is in two sets",+          arguments: DuplicateShapes.seeds)+    func partitionIsTotalAndDisjoint(seed: UInt64) throws {+        let store = try DuplicateStore()+        let plan = try DuplicateShapes.seed(seed, into: store)+        try store.commit()++        let scan = try store.read { try DuplicateScan.run(context: $0) }++        #expect(+            !plan.entrySets.isEmpty || !plan.workSets.isEmpty,+            "the fixture stopped producing sets")++        var seen: Set<UUID> = []+        for set in scan.entrySets {+            for id in set.key.memberIDs {+                #expect(seen.insert(id).inserted, "\(id) belongs to two Entry sets")+            }+        }+        var seenWorks: Set<UUID> = []+        for set in scan.workSets {+            for id in set.key.memberIDs {+                #expect(seenWorks.insert(id).inserted, "\(id) belongs to two Work sets")+            }+        }++        // Totality: the fixture's sets are all there, and nothing else is.+        #expect(+            Set(scan.entrySets.map { Set($0.key.memberIDs) })+                == Set(plan.entrySets.map { Set($0.memberIDs) }),+            "the scan and the fixture disagree about which Entry sets exist")+        #expect(+            Set(scan.workSets.map { Set($0.key.memberIDs) })+                == Set(plan.workSets.map { Set($0.memberIDs) }),+            "the scan and the fixture disagree about which Work sets exist")++        for set in scan.workSets {+            let expected = try #require(plan.workSet(members: set.key.memberIDs))+            #expect(set.classification == expected.expectedClassification(in: plan))+        }+        for set in scan.entrySets {+            let expected = try #require(plan.entrySet(members: set.key.memberIDs))+            #expect(set.classification == expected.expectedClassification(in: plan))+            // The `.deferred` arm says *why*: the blocking key must be the Work+            // set the fixture pointed this Entry set's rows at, not merely some+            // Work set (Q32/Decision 8).+            if case .deferred(let blockedBy) = set.classification {+                let blocking = try #require(plan.blockingWorkSet(for: expected))+                #expect(Set(blockedBy.memberIDs) == Set(blocking.memberIDs))+            }+        }+    }++    // MARK: - Req 2.4: idempotence++    @Test("A second pass over a reconciled library writes nothing",+          arguments: DuplicateShapes.seeds)+    func reconciliationIsIdempotent(seed: UInt64) throws {+        let store = try DuplicateStore()+        _ = try DuplicateShapes.seed(seed, into: store)+        try store.commit()++        try store.reconcileToFixedPoint(limit: 8)+        store.saveRecorder.resetCounts()+        let settled = try store.reconcile()++        #expect(settled.wroteNothing, "a converged library was written to again")+        #expect(store.saveRecorder.attemptCount == 0, "a converged library was saved again")+    }++    // MARK: - Req 2.4: termination++    /// Every generated set reaches its terminal state, and reaches it inside a+    /// bounded number of passes.+    ///+    /// Terminal is *not* "one member" for every set: a divergent or deferred set+    /// waits for the reader and keeps every member it had (Req 4.7). What the+    /// property asserts is that each set arrives where its classification says+    /// it must, and that the loop stopped because a pass wrote nothing rather+    /// than because it ran out of attempts.+    @Test("Every set reaches its terminal state in a bounded number of passes",+          arguments: DuplicateShapes.seeds)+    func everySetTerminates(seed: UInt64) throws {+        // Req 2.5 rides along: every commit boundary this loop crosses is+        // validated, over every generated shape rather than one hand-picked one.+        let store = try DuplicateStore(validatesBoundaries: true)+        let plan = try DuplicateShapes.seed(seed, into: store)+        try store.commit()++        let passes = try store.reconcileToFixedPoint(limit: 8)+        #expect(passes < 8, "the library never reached a fixed point")+        // Req 2.5: every commit boundary the loop crossed left a library the app+        // can open.+        let boundaries = store.boundaryDiagnoses+        #expect(boundaries.allSatisfy { $0.diagnoses.isEmpty })++        let entryIDs = Set(try store.entryFacts().map(\.id))+        let workIDs = Set(try store.workFacts().map(\.id))+        for set in plan.entrySets {+            let surviving = Set(set.memberIDs).intersection(entryIDs)+            switch set.expectedClassification(in: plan) {+            case .silentlyResolvable:+                #expect(surviving.count == 1, "a silently resolvable Entry set did not collapse")+                #expect(surviving.first == set.memberIDs.first, "the wrong member survived")+            case .divergent, .deferred:+                #expect(+                    surviving == Set(set.memberIDs),+                    "a set awaiting the reader lost a member")+            }+        }+        for set in plan.workSets {+            let surviving = Set(set.memberIDs).intersection(workIDs)+            switch set.expectedClassification(in: plan) {+            case .silentlyResolvable:+                #expect(surviving.count == 1, "a silently resolvable Work set did not collapse")+            case .divergent, .deferred:+                #expect(surviving == Set(set.memberIDs), "a divergent Work set lost a member")+            }+        }+    }++    // MARK: - Req 2.4: determinism given synced content++    /// Two devices holding the same synced content reach the same fixed point+    /// without coordination. Insertion order is the stand-in for the difference+    /// between them: it is what changes between two devices receiving the same+    /// records, and it is the one thing the outcome may not depend on.+    @Test("Two insertion orders reach the same fixed point", arguments: DuplicateShapes.seeds)+    func reconciliationIsDeterministic(seed: UInt64) throws {+        let forwards = try DuplicateStore()+        _ = try DuplicateShapes.seed(seed, into: forwards)+        try forwards.commit()+        try forwards.reconcileToFixedPoint(limit: 8)++        let backwards = try DuplicateStore()+        _ = try DuplicateShapes.seed(seed, into: backwards, reversed: true)+        try backwards.commit()+        try backwards.reconcileToFixedPoint(limit: 8)++        #expect(try forwards.entryFacts() == (try backwards.entryFacts()))+        #expect(try forwards.workFacts() == (try backwards.workFacts()))+    }++    /// Nothing on the silent path reads a clock (Q56): every timestamp in the+    /// settled library is one the fixture put there.+    @Test("No value in the fixed point is clock-derived", arguments: DuplicateShapes.seeds)+    func nothingIsClockDerived(seed: UInt64) throws {+        let store = try DuplicateStore()+        _ = try DuplicateShapes.seed(seed, into: store)+        try store.commit()+        try store.reconcileToFixedPoint(limit: 8)++        let ceiling = DuplicateStore.epoch.addingTimeInterval(DuplicateShapes.timestampCeiling)+        for entry in try store.entryFacts() {+            #expect(entry.firstCapturedAt <= ceiling)+            #expect(entry.lastSharedAt <= ceiling)+            #expect(entry.modifiedAt <= ceiling)+        }+        for work in try store.workFacts() {+            #expect(work.createdAt <= ceiling)+            #expect(work.modifiedAt <= ceiling)+        }+    }++    // MARK: - Reqs 2.1/2.6: safety++    /// Every authored variant a set held before the passes is still held by that+    /// **set** afterwards.+    ///+    /// Per set, and over a per-member alphabet, because both matter. A+    /// library-wide comparison of distinct strings passes as long as *some*+    /// Entry anywhere still holds a given note, so a set that destroyed its only+    /// copy while an unrelated set held the same text would go unnoticed — which+    /// is exactly what a three-value alphabet across the whole fixture+    /// guarantees. Here every row's note names its set, its member and its row,+    /// so the only place a note can survive is the set that wrote it.+    @Test("No authored note is lost from its own set", arguments: DuplicateShapes.seeds)+    func noAuthoredTextIsLost(seed: UInt64) throws {+        let store = try DuplicateStore()+        let plan = try DuplicateShapes.seed(seed, into: store)+        try store.commit()++        try store.reconcileToFixedPoint(limit: 8)++        let entriesByID = Dictionary(grouping: try store.entryFacts(), by: \.id)+        for set in plan.entrySets {+            let after = Set(+                set.memberIDs.flatMap { entriesByID[$0] ?? [] }.map(\.note).filter { !$0.isEmpty })+            #expect(+                set.notes.subtracting(after).isEmpty,+                "set \(set.memberIDs) lost \(set.notes.subtracting(after))")+        }+        let worksByID = Dictionary(grouping: try store.workFacts(), by: \.id)+        for set in plan.workSets {+            let after = Set(+                set.memberIDs.flatMap { worksByID[$0] ?? [] }+                    .map(\.genericNotes).filter { !$0.isEmpty })+            #expect(+                set.notes.subtracting(after).isEmpty,+                "Work set \(set.memberIDs) lost \(set.notes.subtracting(after))")+        }+    }++    /// An interrupted pass leaves the library valid and openable, loses no+    /// authored note, and the passes that follow finish the job (Req 2.5).+    ///+    /// Two interruptions per seed: a save that fails from the first attempt+    /// (nothing at all commits) and one that fails part-way through the pass+    /// (some chunks committed, one did not).+    @Test("An interrupted pass loses nothing and a later pass finishes the job",+          arguments: DuplicateShapes.seeds)+    func anInterruptedPassIsRecoverable(seed: UInt64) throws {+        for failFrom in [1, 2] {+            let strategy = ArmableFailingSaveStrategy()+            let store = try DuplicateStore(saveStrategy: strategy)+            let plan = try DuplicateShapes.seed(seed, into: store)+            try store.commit()++            strategy.failFrom = failFrom+            // Whether the pass throws depends on whether it had anything to+            // write at all, which is a property of the seed. Either way the+            // library must survive it.+            _ = try? store.reconcile()+            strategy.failFrom = nil++            // Req 2.5's "valid and openable": no Site row's tuple is illegal.+            // Split groups produce no diagnosis at all since task 14 retired+            // `.duplicateIdentity` (Q57): they are workload, transient, or+            // benign — never damage.+            #expect(+                try store.diagnose().tupleDiagnoses.isEmpty,+                "an interrupted pass (failFrom \(failFrom)) quarantined a hostname")++            try store.reconcileToFixedPoint(limit: 8)++            let entriesByID = Dictionary(grouping: try store.entryFacts(), by: \.id)+            for set in plan.entrySets {+                let after = Set(+                    set.memberIDs.flatMap { entriesByID[$0] ?? [] }+                        .map(\.note).filter { !$0.isEmpty })+                #expect(+                    set.notes.subtracting(after).isEmpty,+                    "an interrupted pass lost \(set.notes.subtracting(after))")+            }+            let entryIDs = Set(try store.entryFacts().map(\.id))+            for set in plan.entrySets+            where set.expectedClassification(in: plan) == .silentlyResolvable {+                #expect(+                    Set(set.memberIDs).intersection(entryIDs).count == 1,+                    "a set interrupted mid-pass never finished collapsing")+            }+        }+    }+}++// MARK: - The plan++/// What the generator *meant* to build, in the Definitions' terms. Every+/// expectation in the suite is stated against this rather than against the+/// scan's own arithmetic.+struct DuplicatePlan {+    var entrySets: [PlannedSet] = []+    var workSets: [PlannedSet] = []++    /// Which Work each planned Entry set's rows point at, by Work UUID.+    func blockingWorkSet(for set: PlannedSet) -> PlannedSet? {+        // Req 1.6 as Decision 8 narrowed it: a set defers when two or more of+        // the Works its rows point at belong to one *divergent* Work set.+        for work in workSets where work.expectedClassification(in: self) == .divergent {+            if Set(work.memberIDs).intersection(set.assignedWorkIDs).count > 1 { return work }+        }+        return nil+    }++    func entrySet(members: [UUID]) -> PlannedSet? {+        entrySets.first { Set($0.memberIDs) == Set(members) }+    }++    func workSet(members: [UUID]) -> PlannedSet? {+        workSets.first { Set($0.memberIDs) == Set(members) }+    }+}++/// One planned duplicate set: its members in survivor order, the distinct+/// authored notes the fixture wrote into it, and the Works its rows point at.+struct PlannedSet {+    /// In the survivor rule's order, so `memberIDs.first` is the member a+    /// collapse must keep.+    let memberIDs: [UUID]+    /// The distinct non-bare notes the fixture wrote. One per authored variant,+    /// because the alphabet is per row and every other authored field is left at+    /// its default.+    let notes: Set<String>+    let assignedWorkIDs: Set<UUID>+    let isWork: Bool++    /// The classification the Definitions give this set, computed from what the+    /// fixture wrote.+    func expectedClassification(in plan: DuplicatePlan) -> DuplicateSetClassification {+        if !isWork, let blocking = plan.blockingWorkSet(for: self) {+            return .deferred(+                blockedBy: DuplicateSetKey(recordType: .work, memberIDs: blocking.memberIDs))+        }+        return notes.count > 1 ? .divergent : .silentlyResolvable+    }+}++// MARK: - The generator++/// Random set shapes from a seed: identity groups, distinct-UUID sets, mixed+/// sets, bare members, agreeing members, and disagreeing ones — plus Entry sets+/// whose rows point at the members of a Work set, which is what makes Req 1.6's+/// deferral reachable.+///+/// Deliberately small and readable. What matters is that it covers the *shapes*+/// the classification distinguishes, not that it covers many rows — the+/// properties under test are per-set.+enum DuplicateShapes {+    static let seeds: [UInt64] = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89]++    /// Every timestamp the generator writes is under this, so a clock read is+    /// visible as a value decades past it.+    static let timestampCeiling: TimeInterval = 100_000++    @discardableResult+    static func seed(+        _ seed: UInt64, into store: DuplicateStore, reversed: Bool = false+    ) throws -> DuplicatePlan {+        var random = SplitMix64(seed: seed)+        // Taught, with one active title rule, because the Entry assignments below+        // are *derived* ones and the validator wants a rule for them to cite. A+        // derived assignment is deliberate: Req 1.6's deferral reads the physical+        // pointer whatever its provenance (Q66), while only a `.manual` one is+        // authored content — so the fixture exercises the deferral without every+        // assigned row becoming non-bare.+        let site = store.addSite(mode: .taught)+        let patternID = DuplicateStore.rankedID(9_000)+        try store.addPattern(id: patternID, site: site, version: 1, active: true)+        var rank = 1+        var plan = DuplicatePlan()++        var entryPlans: [() -> Void] = []+        var workPlans: [() -> Void] = []+        // The Works the Entry plans point at, filled in as the Work plans run —+        // the plans are closures so the seeding order can be reversed, and an+        // Entry cannot capture a model that has not been inserted yet.+        let seededWorks = SeededWorks()++        // One or two Work sets, seeded first: the Entry sets below point at+        // their members, which is how a divergent Work set blocks an Entry set+        // (Req 1.6).+        for setIndex in 0..<(1 + Int(random.next(upperBound: 2))) {+            // Bucketed by parsed title, which is §2.4's relation where no URL+            // rule is taught. Seeding a `urlIdentity` instead would need the+            // whole taught tuple behind it — an active title pattern, a current+            // URL rule, and a citation on every Work — or the fixture starts+            // with an invalid Site tuple, which is what the interruption+            // property has to be able to tell apart from a pass's damage.+            var members: [(id: UUID, createdAt: TimeInterval)] = []+            var notes: Set<String> = []+            for memberIndex in 0...Int(random.next(upperBound: 2)) {+                let id = DuplicateStore.rankedID(rank)+                rank += 1+                let created = TimeInterval(random.next(upperBound: 1_000))+                let note = Self.note("work-\(setIndex)-\(memberIndex)", &random)+                if !note.isEmpty { notes.insert(note) }+                members.append((id, created))+                workPlans.append {+                    seededWorks.rows[id] = store.addWork(+                        id: id, title: "Serial \(setIndex)",+                        createdAt: created, notes: note, site: site)+                }+            }+            guard members.count > 1 else { continue }+            plan.workSets.append(+                PlannedSet(+                    memberIDs: Self.bySurvivorRule(members), notes: notes, assignedWorkIDs: [],+                    isWork: true))+        }++        let assignableWorkIDs = plan.workSets.flatMap(\.memberIDs)++        // Two to four Entry sets, each with one to three members of one or two+        // rows, each row bare or carrying a note that names its own row.+        for setIndex in 0..<(2 + Int(random.next(upperBound: 3))) {+            let key = "chapter-\(setIndex)"+            var members: [(id: UUID, createdAt: TimeInterval)] = []+            var notes: Set<String> = []+            var assigned: Set<UUID> = []+            var rowCountsByMember: [UUID: Int] = [:]+            for memberIndex in 0...Int(random.next(upperBound: 3)) {+                let id = DuplicateStore.rankedID(rank)+                rank += 1+                let rowCount = 1 + Int(random.next(upperBound: 2))+                let captured = TimeInterval(random.next(upperBound: 1_000))+                members.append((id, captured))+                rowCountsByMember[id] = rowCount+                // Each member points at one Work, cycling through the seeded+                // Works so a set's members can span two members of one Work set.+                let work: UUID? =+                    assignableWorkIDs.isEmpty+                        ? nil+                        : assignableWorkIDs[+                            Int(random.next(upperBound: UInt64(assignableWorkIDs.count)))]+                if let work { assigned.insert(work) }+                let memberNote = "note-\(setIndex)-\(memberIndex)"+                // Half the members are bare, which is Decision 1's asymmetric+                // shape and the one that has to resolve silently. An authored+                // member's rows draw from its own note, a second variant of it+                // (a torn group), and bare (a mixed group).+                let isBare = random.next(upperBound: 2) == 0+                for row in 0..<rowCount {+                    let note: String+                    if isBare {+                        note = ""+                    } else {+                        switch random.next(upperBound: 4) {+                        case 0: note = ""+                        case 1: note = "\(memberNote)-alt"+                        default: note = memberNote+                        }+                    }+                    if !note.isEmpty { notes.insert(note) }+                    let shared = captured + TimeInterval(row)+                    entryPlans.append {+                        let entry = store.addEntry(+                            id: id, key: key, capturedAt: captured, sharedAt: shared,+                            note: note, work: work.flatMap { seededWorks.rows[$0] }, site: site)+                        guard entry.work != nil else { return }+                        entry.workAssignmentProvenance = .pattern+                        entry.workPatternID = patternID+                        entry.workPatternVersion = 1+                    }+                }+            }+            guard members.count > 1 || (rowCountsByMember[members[0].id] ?? 1) > 1 else { continue }+            plan.entrySets.append(+                PlannedSet(+                    memberIDs: Self.bySurvivorRule(members), notes: notes,+                    assignedWorkIDs: assigned, isWork: false))+        }++        // Works first: an Entry plan may not run before the Works it points at+        // exist, and the reversal is about *order within a table*, which is what+        // differs between two devices' fetches.+        for planned in reversed ? workPlans.reversed() : workPlans { planned() }+        for planned in reversed ? entryPlans.reversed() : entryPlans { planned() }+        return plan+    }++    /// Req 3.1/5.1's survivor rule, stated here rather than read off the scan:+    /// earliest timestamp, application UUID as tie-break.+    private static func bySurvivorRule(_ members: [(id: UUID, createdAt: TimeInterval)]) -> [UUID] {+        members+            .sorted {+                $0.createdAt == $1.createdAt+                    ? $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased()+                    : $0.createdAt < $1.createdAt+            }+            .map(\.id)+    }++    /// A per-member alphabet: every note names the set and member that wrote it,+    /// so a note can only survive in the set it belongs to — a library-wide+    /// alphabet lets one set's surviving copy stand in for another set's+    /// destroyed one. Half the members are bare, which is Decision 1's+    /// asymmetric shape.+    private static func note(_ name: String, _ random: inout SplitMix64) -> String {+        random.next(upperBound: 2) == 0 ? "" : "note-\(name)"+    }+}++/// The Works the seeding closures have inserted so far, so an Entry closure can+/// point at one by UUID whichever order the two lists run in.+private final class SeededWorks {+    var rows: [UUID: Work] = [:]+}++/// A seeded PRNG, so a failing case is a number anybody can re-run.+/// `SystemRandomNumberGenerator` cannot be seeded and would make these tests+/// report a different library on every run.+struct SplitMix64 {+    private var state: UInt64++    init(seed: UInt64) { state = seed &+ 0x9E37_79B9_7F4A_7C15 }++    mutating func next() -> UInt64 {+        state = state &+ 0x9E37_79B9_7F4A_7C15+        var z = state+        z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9+        z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB+        return z ^ (z >> 31)+    }++    mutating func next(upperBound: UInt64) -> UInt64 { next() % upperBound }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift Modified +518 / -162
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex cbbfe15..920b89d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -76,6 +76,15 @@ public actor LibraryRepository {     /// successful composed re-teach clears it (the repair path).     internal var quarantined: [String: V4ValidationError] = [:] +    /// Which record a collapse sent each losing UUID to, this session (Q54).+    ///+    /// An optimisation over identity resolution, never the mechanism (Q60): a+    /// collapse the peer device performed leaves nothing here, and the redirect+    /// has to work anyway. Never persisted, never synced — after a relaunch the+    /// honest answer for an unmatched record is "deleted", which is what the+    /// empty map produces (Q47).+    internal var collapseSurvivors: [CollapsedRecordType: [UUID: UUID]] = [:]+     /// Everything the app knows to be incoherent, derived at open from the same     /// validation `quarantined` is projected from and never persisted (Q5).     /// Req 4.1's Recent banner count is read from here, so the count and the rows@@ -86,6 +95,17 @@ public actor LibraryRepository {     /// process (Req 1.6).     public internal(set) var diagnostics: LibraryDiagnostics = .empty +    /// Every duplicate set awaiting somebody (Req 9), published by the same+    /// locked observation that builds Recent.+    ///+    /// Deliberately not derived on demand. The scan is a walk of four tables,+    /// and the surfaces that read this — Settings' health line, the Library+    /// Check listing — are not the ones that pay for it: they are asking about+    /// the library Recent is already showing, and a second derivation would let+    /// the two screens disagree about the same store. Empty until the first+    /// publication, exactly as `diagnostics` is.+    public internal(set) var duplicateWorkload: DuplicateWorkload = .empty+     /// Re-derives the three tolerated states from the store as it stands and     /// republishes `quarantined` from the merged map (Req 1.5).     ///@@ -127,6 +147,17 @@ public actor LibraryRepository {             shape: scan.shape)         diagnostics = merged         setQuarantine(merged.quarantineMap())++        // The arrival-tier gate, and the post-refresh latch it needs (Q58).+        // `handleSyncArrivals` reconciles *before* it refreshes, so a gated pass+        // reads the previous refresh's scan and a hydration's final batch misses+        // it. When this scan reports candidates the gated pass did not process,+        // the follow-up is armed — which bounds the lag by the quiescence await+        // rather than by whatever unrelated trigger comes next.+        lastDuplicateCandidateCount = scan.duplicateCandidateCount+        if scan.duplicateCandidateCount > 0, duplicatePhaseSkipped {+            duplicateFollowUpNeeded = true+        }     }      /// The chunk size every bulk pass commits in — the reconciler's re-pin and@@ -157,6 +188,16 @@ public actor LibraryRepository {     /// states an interrupted import can stop in.     internal static let bulkOperationBatchSize = 500 +    /// The batch every read-only `context.enumerate` walk uses — the tolerance+    /// scan and the duplicate scan, which walk the same tables in the same+    /// shape.+    ///+    /// A round-trip/peak-memory tradeoff, not a measured optimum: the design's+    /// 0.070 s figure for `enumerate` over 5,000 rows records no batch size. It+    /// lives here rather than in either walk because it was declared in both,+    /// and two copies of one number are two numbers waiting to disagree.+    internal static let enumerationBatchSize = 1_000+     /// Splits a bulk pass's records into `bulkOperationBatchSize`-sized commit     /// units. One helper for both passes, beside the size they share: the     /// reconciler's re-pin and import's Work/Entry chunks had a byte-identical@@ -183,6 +224,23 @@ public actor LibraryRepository {     internal var bulkOperationInProgress = false     internal var reconcileDeferred = false +    /// Req 2.3's settling state, per session and never persisted (Q19).+    internal var duplicateLedger = DuplicateSettlingLedger()+    /// Req 1.3's latch. Set by a settling deferral, an aborted deleting commit,+    /// or a refresh that finds candidates a gated pass did not process — never+    /// by a Req 1.6 blockage, which waits on the reader and would loop (Q62).+    internal var duplicateFollowUpNeeded = false+    /// Whether the last pass's gate declined the duplicate phase (Q58).+    internal var duplicatePhaseSkipped = false+    /// What the last tolerance scan saw, which is most of both tiers' gate.+    internal var lastDuplicateCandidateCount = 0+    /// Whether any pass this session has run the duplicate phase.+    ///+    /// The full tier's unconditional arm (Decision 30). Nothing has scanned for+    /// candidates when the session's first pass runs, so that one pass runs the+    /// phase whatever the counters say; every pass after it consults them.+    internal var hasRunDuplicatePhaseThisSession = false+     /// Makes the Site graph coherent after records arrive (Req 1.1–1.8).     ///     /// Runs on the remote-change debounce and once per launch *after* the first@@ -234,15 +292,22 @@ public actor LibraryRepository {     /// record against a library the pass had just made coherent — until the next     /// relaunch ran the validation that would have said so.     @discardableResult-    public func reconcileAfterSync() async throws -> SiteReconciliationOutcome {+    public func reconcileAfterSync(+        tier: ReconcilePassTier = .full+    ) async throws -> ReconciliationOutcome {         guard !bulkOperationInProgress else {             reconcileDeferred = true-            return SiteReconciliationOutcome()+            // The latch is deliberately left alone: a pass that never ran+            // neither earned a follow-up nor discharged one (Q62).+            return ReconciliationOutcome()         }         let cachedTuples = diagnostics.tupleDiagnoses+        let runsDuplicatePhase = duplicatePhaseRuns(tier: tier)          bulkOperationInProgress = true         defer { bulkOperationInProgress = false }+        var ledger = duplicateLedger+        var duplicatePass = DuplicateReconciler.PassResult()         let pass = try await withLockedContext(             mode: .exclusive, operation: "reconciling Site rows after sync"         ) { context in@@ -281,14 +346,42 @@ public actor LibraryRepository {                     }                 }             }++            // The duplicate phase, after the Site phases and inside the same+            // lock: rule state is what Work identity and citation replay read,+            // and the Site union has just moved it. Derived here, never+            // remembered — the same rule the Site work list follows.+            if runsDuplicatePhase {+                duplicatePass = try DuplicateReconciler.run(+                    scan: try DuplicateScan.run(context: context, ruleRows: work.ruleRows),+                    ledger: &ledger,+                    batchSize: Self.bulkOperationBatchSize,+                    context: context,+                    saveStrategy: self.saveStrategy)+            }             return pass         }+        duplicateLedger = ledger          for hostname in pass.cleared { recordPostCommitDiagnosis(nil, hostname: hostname) }         for (hostname, reason) in pass.stillDiagnosed {             recordPostCommitDiagnosis(reason, hostname: hostname)         }-        let outcome = pass.outcome++        var outcome = ReconciliationOutcome(site: pass.outcome)+        outcome.duplicates = duplicatePass.outcome+        outcome.duplicatePhaseRan = runsDuplicatePhase+        if runsDuplicatePhase {+            duplicatePhaseSkipped = false+            hasRunDuplicatePhaseThisSession = true+            outcome.duplicates = try await commitCollapses(duplicatePass)+        } else {+            // The gate declined, so whatever the next refresh's scan finds was+            // not processed by this pass — which is what re-arms the follow-up+            // for a hydration's final batch (Q58).+            duplicatePhaseSkipped = true+        }+        if outcome.duplicates.followUpNeeded { duplicateFollowUpNeeded = true }          // Q46's second half, which only `confirmImport` used to honour. A         // reconcile trigger that arrived while this pass held the flag deferred@@ -306,6 +399,122 @@ public actor LibraryRepository {         return outcome     } +    /// Whether this tier's pass runs the duplicate phase at all (Q53/Q58,+    /// Decision 30).+    ///+    /// **Both tiers gate now.** A pass runs the phase where there is reason to+    /// think there is work: the last tolerance scan saw duplicate candidates,+    /// the session ledger is holding sets mid-settling, or a follow-up is owed.+    /// The full tier adds one arm the arrival tier has no use for — the+    /// session's first pass runs unconditionally, because nothing has scanned+    /// for candidates yet and `lastDuplicateCandidateCount` is still 0 from the+    /// open.+    ///+    /// The full tier used to be unconditionally true, which cost+    /// `reconcile-noop-coherent` ~1,450× (T-2092): every launch, import and+    /// reader-action pass walked four tables over a library with no duplicates+    /// in it. Every full-tier caller refreshes immediately before scheduling its+    /// pass, so the count the gate reads is the one that refresh produced.+    ///+    /// **A set arriving between that refresh and the pass is not lost.** The+    /// declined pass sets `duplicatePhaseSkipped`, and the next+    /// `refreshDiagnostics` that reports candidates arms the follow-up — the+    /// same re-arm the arrival tier has always relied on for a hydration's final+    /// batch. It converges one trigger later rather than in this pass.+    ///+    /// Recorded (Q98): the gate is only *cheap* for a library with no split+    /// groups at all. One converged lone group keeps the candidate count and the+    /// ledger non-zero for the session — Decision 4 never deletes it — so every+    /// pass runs a phase that writes nothing. Narrowing it would mean+    /// distinguishing "candidates" from "candidates that could still change",+    /// which is most of the classification the pass exists to perform. Task 22's+    /// Req 10.1 second-pass measurement is exactly this cost.+    private func duplicatePhaseRuns(tier: ReconcilePassTier) -> Bool {+        let hasReasonToRun =+            lastDuplicateCandidateCount > 0 || duplicateLedger.observedSetCount > 0+            || duplicateFollowUpNeeded+        return switch tier {+        case .full: !hasRunDuplicatePhaseThisSession || hasReasonToRun+        case .arrival: hasReasonToRun+        }+    }++    /// Req 2.1's second half: the settled sets' losing members go, in a **fresh**+    /// context and one transaction each (Q61).+    ///+    /// Fresh because the deriving context above would answer the verification+    /// from its own cache, making it a tautology; one transaction each because a+    /// rollback must discard one set's work rather than a chunk's.+    private func commitCollapses(+        _ pass: DuplicateReconciler.PassResult+    ) async throws -> DuplicateReconciliationOutcome {+        var outcome = pass.outcome+        guard !pass.deletions.isEmpty else { return outcome }++        let canonicalWorkIDs = pass.canonicalWorkIDs+        let plans = pass.deletions+        let deleted = try await withLockedContext(+            mode: .exclusive, operation: "deleting collapsed duplicate records"+        ) { context in+            try DuplicateReconciler.commitDeletions(+                plans, canonicalWorkIDs: canonicalWorkIDs, context: context,+                saveStrategy: self.saveStrategy)+        }++        let committed = Set(deleted)+        for plan in plans {+            guard committed.contains(plan.key) else {+                // Req 2.9: the commit aborted. The set stays whole, and the+                // follow-up re-arms on the observed change.+                //+                // Its ledger entry stays too. Forgetting it would make the next+                // pass a *first* observation of a set that has been observed+                // twice already, so the follow-up would defer instead of+                // deleting and the collapse would take three passes rather than+                // one — for a set whose only fault was that something raced it.+                // The abort is safe without forgetting: the fingerprint on+                // record is the one this pass left, so a set that has since+                // changed still fails the compare.+                outcome.settlingSetKeys.append(plan.key)+                continue+            }+            duplicateLedger.forget(plan.key)+            outcome.collapsedMembers += plan.loserIDs.count+            let type: CollapsedRecordType? =+                switch plan.key.recordType {+                case .entry: .entry+                case .work: .work+                case .titleRule, .urlRule: nil+                }+            guard let type else { continue }+            for loser in plan.loserIDs {+                recordCollapse(loser: loser, survivor: plan.survivorID, type: type)+            }+        }+        return outcome+    }++    /// Whether a follow-up pass is owed, clearing the latch as it answers+    /// (Req 1.3).+    ///+    /// Consumed rather than read, so two callers cannot schedule two follow-ups+    /// for one deferral. The `bulkOperationInProgress` early return leaves the+    /// latch untouched, and the guard's caller re-reads after+    /// `refireDeferredReconcile`.+    /// Puts a consumed latch back, for a caller that took it and then found it+    /// could not act on it.+    ///+    /// Idempotent, and deliberately not a stack: two deferrals want the same+    /// thing, which is one more pass over the store as it now stands.+    public func armDuplicateFollowUp() {+        duplicateFollowUpNeeded = true+    }++    public func takeDuplicateFollowUp() -> Bool {+        defer { duplicateFollowUpNeeded = false }+        return duplicateFollowUpNeeded+    }+     /// Runs the pass a trigger deferred while `bulkOperationInProgress` was held,     /// if there was one (Q46's second half).     ///@@ -353,9 +562,13 @@ public actor LibraryRepository {     /// this runs on neither the capture path (Req 9.2) nor the foreground scan.     private static func reconcileWorkLists(         context: ModelContext-    ) throws -> (duplicates: [String], colliding: Set<String>) {-        // Round-trips against peak memory, matching `LibraryToleranceScan`.-        let batchSize = 1_000+    ) throws -> (+        duplicates: [String], colliding: Set<String>, ruleRows: DuplicateScan.RuleRowSnapshot+    ) {+        // Round-trips against peak memory, matching `LibraryToleranceScan` —+        // through the shared constant rather than by hand, which is what the+        // constant exists for.+        let batchSize = Self.enumerationBatchSize         var siteRows: [String: Int] = [:]         var patterns: [String: RuleTally] = [:]         var rules: [String: RuleTally] = [:]@@ -363,12 +576,21 @@ public actor LibraryRepository {         try context.enumerate(FetchDescriptor<Site>(), batchSize: batchSize) { site in             siteRows[site.hostname, default: 0] += 1         }+        // The duplicate scan needs the same two tables' id and creation date,+        // and this pass is already walking them: collected here rather than+        // walked again a statement later, inside one lock.+        var titleRuleRows: [DuplicateScan.RuleRow] = []+        var urlRuleRows: [DuplicateScan.RuleRow] = []+         try context.enumerate(FetchDescriptor<TitlePattern>(), batchSize: batchSize) { pattern in+            titleRuleRows.append(+                DuplicateScan.RuleRow(id: pattern.id, createdAt: pattern.createdAt))             guard let hostname = pattern.site?.hostname else { return }             patterns[hostname, default: RuleTally()]                 .record(version: pattern.version, marked: pattern.isActive)         }         try context.enumerate(FetchDescriptor<URLRulePattern>(), batchSize: batchSize) { rule in+            urlRuleRows.append(DuplicateScan.RuleRow(id: rule.id, createdAt: rule.createdAt))             guard let hostname = rule.site?.hostname else { return }             rules[hostname, default: RuleTally()]                 .record(version: rule.version, marked: rule.isCurrent)@@ -384,7 +606,9 @@ public actor LibraryRepository {         // `Dictionary` iteration is per-process seeded; the reconciler sorts the         // union it is handed, but the list it reports having worked from should         // not depend on the seed either.-        return (siteRows.filter { $0.value > 1 }.keys.sorted(), colliding)+        return (+            siteRows.filter { $0.value > 1 }.keys.sorted(), colliding,+            DuplicateScan.RuleRowSnapshot(titleRules: titleRuleRows, urlRules: urlRuleRows))     }      /// One hostname's rules of one type, counted without holding the rows.@@ -751,38 +975,40 @@ public actor LibraryRepository {         }     } -    public func recentEntries(calendar: Calendar) async throws -> [DatedEntryGroup] {-        try await withLockedContext(mode: .shared, operation: "reading Recent entries") { context in-            let snapshots = try context.fetch(FetchDescriptor<Entry>()).map(Self.snapshot)-            let sorted = snapshots.sorted {-                if $0.lastSharedAt != $1.lastSharedAt { return $0.lastSharedAt > $1.lastSharedAt }-                return $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased()-            }-            var grouped: [(Date, [EntrySnapshot])] = []-            for entry in sorted {-                let day = calendar.startOfDay(for: entry.lastSharedAt)-                if let last = grouped.indices.last, grouped[last].0 == day {-                    grouped[last].1.append(entry)-                } else {-                    grouped.append((day, [entry]))-                }-            }-            return grouped.map { DatedEntryGroup(day: $0.0, entries: $0.1) }-        }-    }-     public func entry(id: UUID) async throws -> EntrySnapshot {         try await withLockedContext(mode: .shared, operation: "reading Entry") { context in-            try Self.snapshot(Self.fetchEntry(id: id, context: context))+            // A read of one record's presented content, which is what the+            // normalisation decides: derived, so this and Recent cannot report+            // one group torn and whole (Req 3.2).+            try Self.snapshot(Self.fetchNormalisedEntryGroup(id: id, context: context))         }     } -    public func updateEntry(id: UUID, note: String, rating: Rating?) async throws {+    /// Writes the reader's note and rating to **every** row of the addressed+    /// logical record (Req 2.7).+    ///+    /// One row of a split group taking the edit is what re-diverges it (Q17):+    /// the twin keeps the old value, and the group the app just presented as one+    /// record is torn — by the app, not by sync. The refusal is derived inside+    /// this transaction rather than at the surface that offered the editor,+    /// because tornness can arrive between the two (Q55).+    public func updateEntry(+        id: UUID, basis: EntryEditBasis, note: String, rating: Rating?+    ) async throws -> LibraryWriteOutcome {         try await withLockedContext(mode: .exclusive, operation: "updating Entry") { context in-            let entry = try Self.fetchEntry(id: id, context: context)-            entry.note = note-            entry.rating = rating-            entry.modifiedAt = clock.now()+            let group: EntryGroup+            switch try self.resolveEntryWriteTarget(id: id, basis: basis, context: context) {+            case .refused(let conflict): return .conflict(conflict)+            case .resolved(let resolved): group = resolved+            }+            if let refusal = Self.tornRefusal(group) { return refusal }++            let timestamp = clock.now()+            for row in group.rows {+                row.note = note+                row.rating = rating+                row.modifiedAt = timestamp+            }             do { try saveStrategy.save(context) }             catch {                 throw LibraryRepositoryError.libraryUnavailable(@@ -790,14 +1016,62 @@ public actor LibraryRepository {                     reason: String(describing: error)                 )             }+            return .committed         }     } -    public func deleteEntry(id: UUID) async throws {+    /// Deletes the addressed logical record whole (Req 2.7): a group is never+    /// split by a deletion, so every row goes in one commit.+    ///+    /// `disclosedVariants` is what the reader was shown when the record was torn+    /// (Req 2.8), and **`nil` says no disclosure was made**. A torn group+    /// refuses an undisclosed delete outright: the requirement is that the+    /// reader is told differing copies exist *before* the group goes, and a+    /// caller that reads the variants off the record and hands them straight+    /// back has disclosed nothing to anybody. Where a disclosure was made, the+    /// commit re-derives the variants and refuses if they have changed, so a+    /// copy that arrived after the alert was raised is not carried away unseen+    /// (Req 2.9). A non-torn group has nothing to disclose and needs no+    /// disclosure.+    public func deleteEntry(+        id: UUID, basis: EntryEditBasis, disclosedVariants: Set<VariantID>?+    ) async throws -> LibraryWriteOutcome {         try await withLockedContext(mode: .exclusive, operation: "deleting Entry") { context in-            let entry = try Self.fetchEntry(id: id, context: context)-            if let work = entry.work { work.modifiedAt = clock.now() }-            context.delete(entry)+            let group: EntryGroup+            switch try self.resolveEntryDeletionTarget(id: id, basis: basis, context: context) {+            case .refused(let conflict): return .conflict(conflict)+            case .resolved(let resolved):+                // Already gone — by this reader's earlier tap, by another+                // device, or by a collapse that found no survivor. A deletion+                // that finds nothing to delete has done its job.+                guard let resolved else { return .committed }+                group = resolved+            }++            if group.isTorn {+                guard let disclosedVariants else {+                    // Nothing was disclosed, so nothing authorises taking the+                    // unseen variant away. The reader is sent to the resolution.+                    return .conflict(+                        .torn(recordID: group.id, variants: group.variants.map(\.id)))+                }+                guard disclosedVariants == group.variantIDs else {+                    return .conflict(+                        .disclosureStale(recordID: group.id, variants: group.variants.map(\.id)))+                }+            }++            let timestamp = clock.now()+            let affectedWorkIDs = Set(group.rows.compactMap { $0.work?.id })+            for row in group.rows { context.delete(row) }+            // The Work's rows are one logical record too, so they are stamped+            // together — a per-row stamp would leave the group's rows differing+            // in `modifiedAt` and move which one represents it.+            for workID in affectedWorkIDs {+                for row in try Self.fetchWorkGroup(id: workID, context: context).rows {+                    row.modifiedAt = timestamp+                }+            }             do { try saveStrategy.save(context) }             catch {                 throw LibraryRepositoryError.libraryUnavailable(@@ -805,6 +1079,7 @@ public actor LibraryRepository {                     reason: String(describing: error)                 )             }+            return .committed         }     } @@ -836,9 +1111,27 @@ public actor LibraryRepository {         }     } +    /// The Works list and the unattached Entries beside it, both bucketed into+    /// logical records (Reqs 3.2, 5.5).+    ///+    /// Both halves read **every** row rather than a filtered subset, and the+    /// unattached half is why. "Unattached" is a property of the logical record,+    /// which is the carrier row's assignment — so a `work == nil` predicate+    /// would hand back a *fragment* of any split group whose rows disagree, and+    /// a group built from a fragment reports the wrong timestamps and the wrong+    /// variants. The whole-table read costs the unattached rows' twins on top of+    /// what the Work snapshots already fault.     public func works() async throws -> WorksSnapshot {         try await withLockedContext(mode: .shared, operation: "reading Works") { context in-            let workSnapshots = try context.fetch(FetchDescriptor<Work>()).map(Self.snapshot)+            let workRows = try context.fetch(FetchDescriptor<Work>())+            // The Definitions' assignment normalisation, from the rows already+            // in hand (Q38). Without it this screen reads a group torn that+            // Recent reads whole, for the same Entries — the disagreement+            // Req 3.2 forbids.+            let canonicalWorkIDs = DuplicateScan.canonicalWorkIDs(ofWorkRows: workRows)+            let workSnapshots = try Self.workGroups(workRows)+                .values+                .map { try Self.snapshot($0, canonicalWorkIDs: canonicalWorkIDs) }             let sortedWorks = workSnapshots.sorted { left, right in                 let leftNewest = left.entries.first?.lastSharedAt                 let rightNewest = right.entries.first?.lastSharedAt@@ -852,11 +1145,17 @@ public actor LibraryRepository {                 }                 return left.id.uuidString.lowercased() < right.id.uuidString.lowercased()             }-            let unattachedDescriptor = FetchDescriptor<Entry>(-                predicate: #Predicate<Entry> { $0.work == nil }-            )-            let unattached = try context.fetch(unattachedDescriptor)-                .map(Self.snapshot)+            // Filtered **before** the snapshot, not after. `workID` is the+            // carrier row's assignment, which is exactly `carrier.work`, so the+            // predicate is the same predicate — and mapping first snapshotted+            // every attached Entry a second time (the Work snapshots already+            // did) to discard ~95% of the result.+            let unattached = try Self.entryGroups(+                try context.fetch(FetchDescriptor<Entry>()),+                canonicalWorkIDs: canonicalWorkIDs)+                .values+                .filter { $0.carrier.work == nil }+                .map { try Self.snapshot($0) }                 .sorted(by: Self.entryActivityOrder)             return WorksSnapshot(works: sortedWorks, unattachedEntries: unattached)         }@@ -864,16 +1163,30 @@ public actor LibraryRepository {      public func work(id: UUID) async throws -> WorkSnapshot {         try await withLockedContext(mode: .shared, operation: "reading Work") { context in-            try Self.snapshot(Self.fetchWork(id: id, context: context))+            // The Entries under one Work group all point at rows of that+            // group, so they already agree about their assignment whatever the+            // map says (the `snapshot(WorkGroup:)` note). Stated, not defaulted.+            try Self.snapshot(+                Self.fetchWorkGroup(id: id, context: context), canonicalWorkIDs: [:])         }     } +    /// The Works an Entry can be moved to: one row per logical record (Req 5.5).+    ///+    /// A picker listing a split group twice offers the reader two rows that are+    /// the same Work, under the same title, with no way to tell them apart — and+    /// a move to either writes the same place.     public func workDestinations(for entryID: UUID) async throws -> [WorkSnapshot] {         try await withLockedContext(mode: .shared, operation: "reading Work destinations") { context in-            let entry = try Self.fetchEntry(id: entryID, context: context)-            return try context.fetch(FetchDescriptor<Work>())-                .filter { $0.siteHostname == entry.hostname }-                .map(Self.snapshot)+            // Only the hostname is read off this group, and no normalisation+            // can change a hostname.+            let entry = try Self.fetchEntryGroup(+                id: entryID, context: context, canonicalWorkIDs: [:]).representative+            return try Self.workGroups(+                try context.fetch(FetchDescriptor<Work>())+                    .filter { $0.siteHostname == entry.hostname })+                .values+                .map { try Self.snapshot($0, canonicalWorkIDs: [:]) }                 .sorted {                     let titleOrder = $0.displayTitle.localizedStandardCompare($1.displayTitle)                     if titleOrder != .orderedSame { return titleOrder == .orderedAscending }@@ -882,19 +1195,30 @@ public actor LibraryRepository {         }     } -    public func updateWork(id: UUID, draft: WorkMetadataDraft) async throws {+    public func updateWork(+        id: UUID, basis: WorkEditBasis, draft: WorkMetadataDraft+    ) async throws -> LibraryWriteOutcome {         guard !draft.displayTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {             throw LibraryRepositoryError.invalidInput(operation: "updating Work", reason: "display title is blank")         }         let normalizedTags = Self.normalizeTags(draft.genreTags)-        try await withLockedContext(mode: .exclusive, operation: "updating Work") { context in-            let work = try Self.fetchWork(id: id, context: context)-            if work.displayTitle != draft.displayTitle { work.titleProvenance = .manual }-            work.displayTitle = draft.displayTitle-            work.type = draft.type-            work.genreTags = normalizedTags-            work.genericNotes = draft.genericNotes-            work.modifiedAt = MillisecondInstant.quantize(clock.now())+        return try await withLockedContext(mode: .exclusive, operation: "updating Work") { context in+            let group: WorkGroup+            switch try self.resolveWorkWriteTarget(id: id, basis: basis, context: context) {+            case .refused(let conflict): return .conflict(conflict)+            case .resolved(let resolved): group = resolved+            }+            if let refusal = Self.tornRefusal(group) { return refusal }++            let timestamp = MillisecondInstant.quantize(clock.now())+            for work in group.rows {+                if work.displayTitle != draft.displayTitle { work.titleProvenance = .manual }+                work.displayTitle = draft.displayTitle+                work.type = draft.type+                work.genreTags = normalizedTags+                work.genericNotes = draft.genericNotes+                work.modifiedAt = timestamp+            }             do { try saveStrategy.save(context) }             catch {                 throw LibraryRepositoryError.libraryUnavailable(@@ -902,35 +1226,70 @@ public actor LibraryRepository {                     reason: String(describing: error)                 )             }+            return .committed         }     } -    public func moveEntry(_ entryID: UUID, to destination: WorkDestination) async throws {+    /// Repoints **every** row of the addressed Entry group (Req 2.7). A manual+    /// assignment is reader-authored, so a torn group refuses.+    ///+    /// The Works on either side of the move are logical records too: their rows+    /// are stamped together, never one of them, or the group's rows would differ+    /// in `modifiedAt` and the representative would move under a write that was+    /// never about the Work at all.+    public func moveEntry(+        _ entryID: UUID, basis: EntryAssignmentBasis, to destination: WorkDestination+    ) async throws -> LibraryWriteOutcome {         if case .newWork(let title) = destination,            title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {             throw LibraryRepositoryError.invalidInput(operation: "moving Entry to new Work", reason: "display title is blank")         }-        try await withLockedContext(mode: .exclusive, operation: "moving Entry") { context in-            let entry = try Self.fetchEntry(id: entryID, context: context)+        return try await withLockedContext(mode: .exclusive, operation: "moving Entry") { context in+            let group: EntryGroup+            switch try self.resolveEntryAssignmentTarget(+                id: entryID, basis: basis, context: context) {+            case .refused(let conflict): return .conflict(conflict)+            case .resolved(let resolved): group = resolved+            }+            if let refusal = Self.tornRefusal(group) { return refusal }+             let timestamp = MillisecondInstant.quantize(clock.now())-            let priorWork = entry.work+            let representative = group.representative+            let priorWorkIDs = Set(group.rows.compactMap { $0.work?.id })              switch destination {             case .existing(let workID):-                let destinationWork = try Self.fetchWork(id: workID, context: context)-                guard destinationWork.siteHostname == entry.hostname else {+                let destinationGroup = try Self.fetchWorkGroup(id: workID, context: context)+                guard destinationGroup.representative.siteHostname == representative.hostname else {                     throw LibraryRepositoryError.invalidInput(                         operation: "moving Entry",                         reason: "destination Work belongs to a different hostname"                     )                 }-                if priorWork?.id == destinationWork.id { return }-                priorWork?.modifiedAt = timestamp-                destinationWork.modifiedAt = timestamp-                entry.work = destinationWork-                entry.intentionallyUnattached = false+                // A no-op only when the write would change nothing: **every**+                // row already points at this Work with a manual provenance.+                // `priorWorkIDs` drops nils, so a group whose twin is+                // unattached reports `[workID]` — an early return there would+                // claim success while leaving that row unattached and its+                // provenance never set to `.manual` (the block below sits after+                // this return). The `.unattached` arm has this shape too.+                if group.rows.allSatisfy({+                    $0.work?.id == workID && !$0.intentionallyUnattached+                        && $0.workAssignmentProvenance == .manual+                }) { return .committed }+                try self.stampWorkGroups(priorWorkIDs, at: timestamp, context: context)+                for row in destinationGroup.rows { row.modifiedAt = timestamp }+                // Every row of the Entry group points at one row of the+                // destination group, which is the whole Work as far as Req 5.5's+                // presentation is concerned.+                for row in group.rows {+                    row.work = destinationGroup.representative+                    row.intentionallyUnattached = false+                }             case .newWork(let displayTitle):-                let work = Work(displayTitle: displayTitle, siteHostname: entry.hostname, timestamp: timestamp)+                let work = Work(+                    displayTitle: displayTitle, siteHostname: representative.hostname,+                    timestamp: timestamp)                 context.insert(work)                 // The Entry's *own* row, not whichever row currently wins the                 // hostname (Req 1.4, Q44). `entry.site` is already pinned; a@@ -939,24 +1298,31 @@ public actor LibraryRepository {                 // and this path would pay for a fetch it never needed. The                 // lookup remains only as the fallback for an Entry whose own                 // relationship never arrived — Req 2.1's tolerated state.-                work.site = try entry.site-                    ?? Self.fetchSites(hostname: entry.hostname, context: context).first-                priorWork?.modifiedAt = timestamp-                entry.work = work-                entry.intentionallyUnattached = false+                work.site = try group.rows.compactMap(\.site).first+                    ?? Self.fetchSites(hostname: representative.hostname, context: context).first+                try self.stampWorkGroups(priorWorkIDs, at: timestamp, context: context)+                for row in group.rows {+                    row.work = work+                    row.intentionallyUnattached = false+                }             case .unattached:-                if priorWork == nil,-                   entry.intentionallyUnattached,-                   entry.workAssignmentProvenance == .manual { return }-                priorWork?.modifiedAt = timestamp-                entry.work = nil-                entry.intentionallyUnattached = true+                if priorWorkIDs.isEmpty,+                   group.rows.allSatisfy({+                       $0.intentionallyUnattached && $0.workAssignmentProvenance == .manual+                   }) { return .committed }+                try self.stampWorkGroups(priorWorkIDs, at: timestamp, context: context)+                for row in group.rows {+                    row.work = nil+                    row.intentionallyUnattached = true+                }             } -            entry.workAssignmentProvenance = .manual-            entry.workPatternID = nil-            entry.workPatternVersion = nil-            entry.modifiedAt = timestamp+            for row in group.rows {+                row.workAssignmentProvenance = .manual+                row.workPatternID = nil+                row.workPatternVersion = nil+                row.modifiedAt = timestamp+            }             do { try saveStrategy.save(context) }             catch {                 throw LibraryRepositoryError.libraryUnavailable(@@ -964,9 +1330,33 @@ public actor LibraryRepository {                     reason: String(describing: error)                 )             }+            return .committed+        }+    }++    /// Stamps every row of each named Work group, so a group's rows never differ+    /// in a field the representative ordering reads.+    private func stampWorkGroups(+        _ workIDs: Set<UUID>, at timestamp: Date, context: ModelContext+    ) throws {+        for workID in workIDs {+            for row in try Self.fetchWorkGroup(id: workID, context: context).rows {+                row.modifiedAt = timestamp+            }         }     } +    /// The refusal a torn group takes, or nil where it takes none (Req 2.8).+    internal static func tornRefusal(_ group: EntryGroup) -> LibraryWriteOutcome? {+        guard group.isTorn else { return nil }+        return .conflict(.torn(recordID: group.id, variants: group.variants.map(\.id)))+    }++    internal static func tornRefusal(_ group: WorkGroup) -> LibraryWriteOutcome? {+        guard group.isTorn else { return nil }+        return .conflict(.torn(recordID: group.id, variants: group.variants.map(\.id)))+    }+       // MARK: - Private Teaching Helpers@@ -1036,18 +1426,43 @@ public actor LibraryRepository {     /// now puts these numbers in front of the reader — "your library holds N     /// entries" beside what the archive holds — so the name had to stop calling     /// them debug output.+    /// Counts of **logical records**, not rows: an identity group counts once+    /// (Reqs 3.2, 5.5).+    ///+    /// The reader is told "your library holds N entries" beside what an archive+    /// holds, and the archive holds one record per application UUID (Req 8.2) —+    /// so a row count would report a number the backup could never match, for a+    /// state the app presents as one record everywhere else.+    ///+    /// Sites are the exception and count rows, because a hostname's Site rows are+    /// the CloudKit Mirroring spec's business (its Decision 4) and are diagnosed+    /// rather than presented as one.     public func recordCounts() async throws -> LibraryRecordCounts {         try await withLockedContext(mode: .shared, operation: "reading coherent library counts") { context in             try LibraryRecordCounts(-                entries: context.fetchCount(FetchDescriptor<Entry>()),-                works: context.fetchCount(FetchDescriptor<Work>()),+                entries: Self.distinctIdentityCount(FetchDescriptor<Entry>(), context: context, id: \.id),+                works: Self.distinctIdentityCount(FetchDescriptor<Work>(), context: context, id: \.id),                 sites: context.fetchCount(FetchDescriptor<Site>()),-                titlePatterns: context.fetchCount(FetchDescriptor<TitlePattern>()),-                urlRulePatterns: context.fetchCount(FetchDescriptor<URLRulePattern>())+                titlePatterns: Self.distinctIdentityCount(+                    FetchDescriptor<TitlePattern>(), context: context, id: \.id),+                urlRulePatterns: Self.distinctIdentityCount(+                    FetchDescriptor<URLRulePattern>(), context: context, id: \.id)             )         }     } +    /// How many distinct application UUIDs a table holds. Scalar-only, on the+    /// enumerate shape the two scans use, so it faults no relationship.+    private static func distinctIdentityCount<Model: PersistentModel>(+        _ descriptor: FetchDescriptor<Model>, context: ModelContext, id: KeyPath<Model, UUID>+    ) throws -> Int {+        var seen: Set<UUID> = []+        try context.enumerate(descriptor, batchSize: enumerationBatchSize) { row in+            seen.insert(row[keyPath: id])+        }+        return seen.count+    }+     /// The former name of `recordCounts()`, kept because the package's suites     /// call it by the hundred and renaming them is not what this change is about.     public func debugCounts() async throws -> LibraryRecordCounts {@@ -1170,6 +1585,18 @@ public actor LibraryRepository {      /// Construct a WorkBasisEntry from a Work model, throwing on invalid raw provenance.     /// Factored to eliminate silent `?? .parsed` coercion across all basis builders.+    /// The same value built from a group-projected snapshot, so a basis reads+    /// the logical record's authored title rather than one row's (Q41). The+    /// provenance validation happened in the projection.+    internal static func workBasisEntry(from snapshot: WorkSnapshot) -> WorkBasisEntry {+        WorkBasisEntry(+            id: snapshot.id, displayTitle: snapshot.displayTitle,+            lastParsedTitle: snapshot.lastParsedTitle, titleProvenance: snapshot.titleProvenance,+            siteHostname: snapshot.siteHostname, createdAt: snapshot.createdAt,+            modifiedAt: snapshot.modifiedAt)+    }++    /// Construct a WorkBasisEntry from a Work model, throwing on invalid raw provenance.     internal static func workBasisEntry(from work: Work, operation: String) throws -> WorkBasisEntry {         guard let prov = TitleProvenance(rawValue: work.titleProvenanceRaw) else {             throw LibraryRepositoryError.corruptLibrary(@@ -1185,64 +1612,6 @@ public actor LibraryRepository {         )     } -    /// Records keyed by application UUID, with the duplicates that were found on-    /// the way. Resolving a duplicate is not an error any more (Req 1.1), so the-    /// finding is returned rather than thrown, and the resolution continues.-    ///-    /// `diagnoses` is offered to callers that want it; no production caller-    /// currently reads it — every one takes `byID` and drops the rest. Nothing-    /// is lost by that: the library's derived set is built independently by-    /// `V4LibraryValidator.validate` and `LibraryToleranceScan`, both of which-    /// derive `.duplicateIdentity` from their own pass over the rows.-    internal struct ResolvedRecords<Record> {-        let byID: [UUID: Record]-        let diagnoses: [LibraryDiagnosis]-    }--    internal static func entriesByID(_ entries: [Entry]) -> ResolvedRecords<Entry> {-        resolveByID(-            entries, type: "Entry", id: \.id, hostname: \.hostname,-            order: RecordResolutionOrder.sortedEntries)-    }--    internal static func worksByID(_ works: [Work]) -> ResolvedRecords<Work> {-        resolveByID(-            works, type: "Work", id: \.id, hostname: \.siteHostname,-            order: RecordResolutionOrder.sortedWorks)-    }--    /// Keeps the winner per application UUID and records the rest. The hostname-    /// is the one the duplicate rows agree on, so every diagnosis can name a site-    /// (Req 1.3); rows that disagree resolve to none rather than to an arbitrary-    /// one of them, which is the same rule `LibraryToleranceScan` follows.-    private static func resolveByID<Record>(-        _ records: [Record],-        type: String,-        id: (Record) -> UUID,-        hostname: (Record) -> String,-        order: ([Record]) -> [Record]-    ) -> ResolvedRecords<Record> {-        var groups: [UUID: [Record]] = [:]-        for record in records { groups[id(record), default: []].append(record) }--        var byID: [UUID: Record] = [:]-        var diagnoses: [LibraryDiagnosis] = []-        for (key, rows) in groups {-            byID[key] = order(rows).first-            guard rows.count > 1 else { continue }-            let hostnames = Set(rows.map(hostname))-            diagnoses.append(-                .duplicateIdentity(-                    type: type, id: key,-                    hostname: hostnames.count == 1 ? hostnames.first : nil,-                    rowCount: rows.count))-        }-        // `Dictionary` iteration is per-process seeded, so the findings are-        // ordered before they leave.-        diagnoses.sort { $0.id < $1.id }-        return ResolvedRecords(byID: byID, diagnoses: diagnoses)-    }-     /// Every Site row for the hostname, winner first (Req 2.3, Decision 5).     ///     /// The `fetchLimit = 2` this carried could not be kept: a limit with no sort@@ -1275,20 +1644,6 @@ public actor LibraryRepository {         return site     } -    internal static func fetchEntry(id: UUID, context: ModelContext) throws -> Entry {-        let descriptor = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id })-        let entries = RecordResolutionOrder.sortedEntries(try context.fetch(descriptor))-        guard let entry = entries.first else { throw LibraryRepositoryError.recordNotFound(type: "Entry", id: id) }-        return entry-    }--    internal static func fetchWork(id: UUID, context: ModelContext) throws -> Work {-        let descriptor = FetchDescriptor<Work>(predicate: #Predicate { $0.id == id })-        let works = RecordResolutionOrder.sortedWorks(try context.fetch(descriptor))-        guard let work = works.first else { throw LibraryRepositoryError.recordNotFound(type: "Work", id: id) }-        return work-    }-     /// Opens the fixed-path V3 container with the canonical schema and     /// migration configuration shared by runtime opening, backup import, and     /// inventory fingerprinting.@@ -1332,7 +1687,7 @@ public actor LibraryRepository {         )     } -    private static func entryActivityOrder(_ left: EntrySnapshot, _ right: EntrySnapshot) -> Bool {+    internal static func entryActivityOrder(_ left: EntrySnapshot, _ right: EntrySnapshot) -> Bool {         if left.lastSharedAt != right.lastSharedAt { return left.lastSharedAt > right.lastSharedAt }         return left.id.uuidString.lowercased() < right.id.uuidString.lowercased()     }@@ -1426,7 +1781,8 @@ public actor LibraryRepository {             workID: entry.work?.id,             workAssignmentProvenance: assignment,             intentionallyUnattached: entry.intentionallyUnattached,-            chapterSequence: entry.chapterSequence+            chapterSequence: entry.chapterSequence,+            conservativeIdentityKey: entry.conservativeIdentityKey         )     } 
specs/duplicate-reconciliation/implementation.md Added +507 / -0
diff --git a/specs/duplicate-reconciliation/implementation.md b/specs/duplicate-reconciliation/implementation.mdnew file mode 100644index 0000000..21d3d90--- /dev/null+++ b/specs/duplicate-reconciliation/implementation.md@@ -0,0 +1,507 @@+# Implementation: Duplicate Reconciliation++Measurements and findings that do not belong in `tasks.md` (which is+`rune`-managed and has to stay parseable), following the shape+`specs/library-integrity-tolerance/implementation.md` and+`specs/cloudkit-mirroring/implementation.md` set.++---++## Task 22 — Fixtures and performance measurements (Req 10.1, 10.2)++**Date:** 2026-08-03+**Status: complete. Req 10.1 is breached and recorded as a known issue+(Decision 27), and an unnamed baseline regressed ~1,450× and is recorded as a+known issue (Decision 28) and tracked as T-2092.**++### The fixture (task 22.1)++`M4ToleratedFixtureState.duplicateSets` seeds Req 10.1's shape over the finished+5,000-Entry composed fixture, in fresh UUID namespaces, underneath the+validating commit path exactly as the other tolerated states are:++| Shape | Count | Rows added |+|---|---|---|+| Silently resolvable Entry sets | 250 | 500 Entries (two rows sharing one raw URL, both bare, both attached to one of the fixture's own Works) |+| Silently resolvable Work sets | 50 | 100 Works (two per set, sharing one parsed title) + 500 Entries (five per Work) |+| Rule identity groups | 10 | 20 `TitlePattern` rows (two per application UUID) |++Three seeding choices are worth stating, because each of them decides what the+measurement is a measurement *of*:++- **Everything seeded is bare.** No note, no rating, no manual title, no genre+  tags, no manual assignment. That is what makes every set classify as silently+  resolvable, so the measured pass is the one that performs resolutions rather+  than one that publishes reader workload.+- **The Entry-set rows carry `.pattern` work-assignment provenance**, citing the+  Site's own active title rule. A Work assignment with `.none` provenance is an+  illegal tuple that `V4LibraryValidator.validateAssignment` refuses, and+  `.manual` would have made every seeded Entry reader-authored — which is the+  one thing this shape must not be.+- **The rule groups are seeded already converged** (Q124). An unconverged group+  is a `.siteTuple` failure until a pass repairs it (task 20, Q91), which would+  quarantine the fixture's hostname and change what every other measurement over+  that fixture measures. Req 10.1 measures the second pass, by which time a+  group seeded either way has converged.++`M4ToleratedFixtureTests` verifies the shape before anything is timed over it:+the row counts, that the library is legal at open (no diagnoses), that pass 1+observes without deleting and reports 300 settling sets, that pass 2 collapses+300 members, and that the rule rows are still there afterwards because+convergence never deletes (Decision 4).++**Samples come from re-seeded generations, not fresh stores** (Q125). A fresh+5,000-Entry store per sample is ~20 s of seeding for ~9 s of measurement, three+runs deep. `reseedM4DuplicateSets(generation:)` deletes every row any generation+ever seeded and writes the shape again under a new UUID namespace. The namespace+has to be new: `DuplicateSetKey` *is* the member UUIDs, so re-seeding identical+rows would hand the next pass a key the settling ledger had already fingerprinted+at exactly that shape — it would delete on *first* observation, and the pass+after it would be measuring an empty library.++### What Req 10.1 actually measures, and what pass 1 does++Req 10.1 names "the second pass, the one that performs the resolutions". As+implemented, the two passes split differently from what that phrase suggests,+and both are measured (Q126):++- **Pass 1 (observation)** writes. Every survivor's outcome content, every+  `lastSharedAt`/`modifiedAt` raise, and every Entry of a collapsing Work moves+  here — because Req 2.1 requires them committed before a deletion can exist.+  What it may not do is delete, which is Req 2.3.+- **Pass 2 (settling)** deletes, and does almost nothing else: by then the moves+  are done and the value guards write nothing.++Quoting only the second would let a pass meet 2 s by having deferred half its+work to the pass before it, so both are timed and both are reported.++### The measurement++Release, M1 Max, `make test-performance-m4`, **three runs**, run separately+rather than through `RUNS=3` — that loop carries `|| exit $?`, and the target is+knowingly red (`cloudkit-mirroring` Q55), so a single `RUNS=3` invocation stops+after the first run.++Run 1 09:33, run 2 10:12, run 3 10:51 on 2026-08-03, same machine, nothing+else running. Each run is the whole `test-performance-m4` target (~39 min), so+the three runs are ~2 h of wall clock. Every median below is the median of one+run's samples; the band is the spread of those medians across the three runs.++#### Req 10.1 — the settling pass over 250 Entry, 50 Work and 10 rule sets++| Measurement | Run 1 | Run 2 | Run 3 | Band | Budget |+|---|---|---|---|---|---|+| `duplicate-settling-pass` | 8.939 s | 9.080 s | 8.861 s | **8.861–9.080 s** | 2 s — **breached, ~4.5×** |+| `duplicate-observation-pass` | 1.340 s | 1.347 s | 1.314 s | 1.314–1.347 s | 2 s — inside |++Within-run spread was 1.03× and 1.04× on runs 1 and 2. Run 3 recorded a single+15.288 s sample against a 8.861 s median (spread 1.75×) — one sample of ten, no+change to the median, and the shape CLAUDE.md's non-reproducibility note+predicts. It is recorded rather than discarded, and it is why the regression+ceiling is asserted on the median and not the p95.++#### Req 10.2 — the added detection over a duplicate-free library++The two baselines Req 10.2 *names*, re-measured under their own fixture+preconditions:++| Measurement | Run 1 | Run 2 | Run 3 | Band | Last recorded |+|---|---|---|---|---|---|+| `diagnosis-refresh-foreground` | 0.298 s | 0.295 s | 0.287 s | 0.287–0.298 s | 0.443–0.444 s (M4b) |+| `diagnosis-refresh-after-write` | 0.296 s | 0.294 s | 0.287 s | 0.287–0.296 s | — |+| `diagnosis-refresh-duplicateSiteRows` | 0.299 s | 0.294 s | 0.287 s | 0.287–0.299 s | — |+| `capture-projection-duplicateSiteRows` | 68.9 ms | 69.7 ms | 67.3 ms | 67.3–69.7 ms | 118.6 ms (M4b) |++**Both named baselines came in better than their last recorded values**, so the+10% median-to-median comparison has no regression to report on either.++> **Correction (task 24).** This paragraph originally continued "Both remain+> above their own requirement budgets — Req 5.5's 250 ms and the 100 ms capture+> budget". That was wrong about the capture half: `capture-projection-duplicateSiteRows`+> measured **67.3–69.7 ms against a 100 ms budget**, which is *inside* it, and+> the budget is asserted outside any `withKnownIssue`. Only the diagnosis-refresh+> half is still above its budget, and that is `library-integrity-tolerance`+> Decision 11's known issue — not `cloudkit-mirroring` Q55's, which is the 400 ms+> regression ceiling and passes.++The three read paths Req 10.2 could not have named, and the export projection:++| Measurement | Run 1 | Run 2 | Run 3 | Band | Last recorded |+|---|---|---|---|---|---|+| `recent-publication-duplicate-free` | 1.098 s | 1.116 s | 1.109 s | **1.098–1.116 s** | 0.686–0.713 s |+| `works-snapshot-duplicate-free` | 1.673 s | 1.728 s | 1.718 s | 1.673–1.728 s | — (new) |+| `record-counts-duplicate-free` | 0.274 s | 0.280 s | 0.271 s | 0.271–0.280 s | — (new) |+| `backup-projection-duplicate-free` | 1.138 s | 1.183 s | 1.144 s | 1.138–1.183 s | — (informational, Q116) |+| `reconcile-noop-coherent` | 0.286 s | 0.298 s | 0.296 s | **0.286–0.298 s** | 0.186–0.200 **ms** |++#### The machine control++| Measurement | Run 1 | Run 2 | Run 3 | Band | Last recorded |+|---|---|---|---|---|---|+| `extension-open-and-validate` | 0.755 s | 0.784 s | 0.790 s | 0.755–0.790 s | 0.745–0.766 s |++The control sits within ~3% of its recorded band (runs 2 and 3 a little above+its top). That is the honest reading: the machine was marginally warmer across+the later runs. It is nowhere near enough to explain a 1,450× movement, so the+regressions below are code.++### Req 10.1 does not hold++The settling pass measures **8.861–9.080 s** against a 2 s budget — ~4.5×,+with a within-run spread of 1.03–1.04× on two runs of three. It is a+measurement, not a hiccup.++The cost is not detection. `DuplicateScan`'s two walks and the reconciler's+write phase are the *observation* pass, and that pass measures 1.314–1.347 s,+inside the same 2 s. What the settling pass does that the observation pass does+not is `commitDeletions`, which runs **one `saveStrategy.save(context)` per+set**. 300 collapses are 300 saves against a context holding ~6,000 rows: about+30 ms each, where the budget allows 6.7 ms per set.++That transaction-per-set is deliberate. Q86 chose it so a Req 2.9 rollback+discards one set's work rather than a chunk's, and rejected per-set *locking*+partly on the grounds that 300 lock acquisitions would not fit inside Req 10.1's+2 s budget. This measurement says the saves do not fit either. **Req 10.1's+number and Q86's per-set transaction are in direct tension, and this is the+first measurement that could say so.** Recorded as Decision 27; the budget is+unchanged and asserted inside `withKnownIssue`, with a 14 s regression floor+outside it.++### Req 10.2 — one measurement or three?++Neither. It is **two named baselines re-measured where they already live, plus+three read paths the requirement could not have named**, and the answer is+Decision 26.++Req 10.2 names diagnosis refresh and capture projection. Both are already owned+by `M4ToleratedScalePerformanceTests`, which measures them under their own+fixture preconditions; the duplicate suite adds no second number for either, so+the 10% comparison has one unambiguous left-hand side. What the requirement does+not name, and what this milestone nevertheless put `DuplicateScan` on, is three+read paths:++- `recentPresentation` runs a full `DuplicateScan.run` on every publication,+  beside the `LibraryToleranceScan` it already ran;+- `works()` reads the whole Entry table rather than a `work == nil` predicate+  (Q104) — "unattached" is a property of the logical record, and a predicate+  fetch hands back a *fragment* of any split group whose rows disagree;+- `recordCounts()` replaced five SQL `fetchCount`s with four `context.enumerate`+  walks, because a count is now a count of logical records.++The export projection (Q116) is a fourth, measured informationally because that+decision already recorded that export is on no budget.++Neither named baseline regressed. Three unnamed read paths did, and one+unnamed baseline regressed hard enough to fail its ceiling:++- **`reconcile-noop-coherent` moved from 0.186–0.200 ms to 0.286–0.298 s** —+  ~1,450×, and ~29× its 10 ms ceiling. `duplicatePhaseRuns(tier: .full)` is+  unconditionally true, so every full-tier pass now walks four tables. The+  ceiling's own comment had named this exact regression in advance ("a pass that+  starts faulting the 5,000 Entries it currently never touches"), and it caught+  it on the first run after the change landed. Decision 28, **T-2092**.+- **`recent-publication-duplicate-free` moved from 0.686–0.713 s to+  1.098–1.116 s.** `recentPresentation` gained a full `DuplicateScan.run` per+  publication; 0.71 s + ~0.29 s lands within noise of the measured number, which+  is the same walk showing up twice.+- `works-snapshot-duplicate-free` (1.673–1.728 s) and+  `record-counts-duplicate-free` (0.271–0.280 s) are new numbers with nothing to+  compare against, recorded as the cost of reading logical records rather than+  rows.++### The arrival gate is the reason an ordinary sync costs nothing++A debounce pass over a duplicate-free library declines the duplicate phase+outright (Q53/Q58) and measures **1.74–1.91 ms**, against the full tier's+0.286–0.298 s on the same fixture — a factor of ~160.++This is the number that bounds the blast radius of Decision 28. The path that+runs on **every remote change** is gated and did not regress. What regressed is+the full tier: the launch pass, the import-completion pass and the reader-action+pass (Req 1.2). Whether the full tier should consult the same candidate count+the arrival tier does is a design decision with a correctness side — the gate+reads the *previous* refresh's scan, which is exactly the staleness Q58 designed+the full tier to avoid — so it is T-2092's to answer, not a measurement task's.++### What this measurement cannot say++- **Nothing about the device.** The `AsterismCore` package test target is in no+  scheme's test action (Decision 10 of `library-integrity-tolerance`), so every+  number here is host-only. The one calibration point that exists —+  `recentPresentation` at ~0.7 s host against 0.305 s on an iPhone 17 Pro+  (that spec's task 37) — is a *read* workload, and inferring a+  delete-and-save number from it is not evidence. **Req 10.1's settling pass is+  unmeasured on device**, and deliberately so: the user has said on-device+  measurement is a separate decision, and no task here authorises a run against+  the phone.+- **Nothing clean about attribution for `recentPresentation`.** Its recorded+  band predates `cloudkit-mirroring`, which is known to have moved a neighbouring+  path (`diagnosis-refresh`) by ~1.6× on the same fixture. Comparing today's+  number to it therefore measures M4b and M4c together. Isolating M4c's share+  needs a run of the same suite on the pre-M4c commit, which is a checkout this+  task was told not to make.+- **Nothing about a divergent set at scale.** The fixture seeds only silently+  resolvable sets, because Req 10.1 names that shape. A library where 300 sets+  all await a reader publishes 300 workload items and deletes nothing; that is a+  publication cost, not a reconciliation one, and nothing measures it.++---++## Task 23 — Design-doc update for the landed milestone++**Date:** 2026-08-02++`docs/asterism-design.md`:++- **§2.3** — the duplicate relation is now stated as the **conservative+  (raw-URL) key**, with the reason (Q8/Q33): a mis-taught rule can make two+  different chapters share a *derived* key, and the url-identity-re-share spec+  deliberately preserves that state for re-teaching, so reconciling on the+  derived key would merge two chapters over a teaching mistake. The auto-collapse+  condition is restated as it shipped — identical reader-authored data *or only+  one side wrote anything at all* (Decision 1) — the review sheet is described as+  presenting every distinct authored variant for N ≥ 2 rather than "both notes"+  of a pair, and the third case that the original paragraph had no word for is+  added: rows sharing one *application UUID* are one logical record materialised+  twice, and they converge in place rather than being resolved by deletion+  (Decision 4).+- **§5.1** — the duplicate banner is described beside the inbox banner, including+  the thing the inbox banner does not have to do: some of what it counts has no+  Recent row (a Work set awaiting Merge, a deferred Entry set, a preserved edit+  conflict), so the filtered state renders an *Elsewhere* section naming each+  with its route (Decision 19). The actionable-state list gains the preserved+  edit conflict (Req 2.10) and states that the counts are disjoint.+- **§14** — M4c marked *Shipped* with the spec pointer, and four things the+  original paragraph did not anticipate written down, because each changed the+  shape of the milestone: a row is not a record (the identity-group seam behind+  ~16 call sites); deletion is two-pass with commit-time re-verification; *torn*+  is the one duplicate state the archive cannot hold, which replaced M4b's+  refusal over every repeated UUID; and rule records converge rather than+  collapse, which needed the store-level validator relaxed.+- **§14** — M4b marked *Shipped* while the section was open, with its spec+  pointer, the fact that mirroring is on for **both** configurations (so a+  `Development` install is no longer device-local), and its two red budgets+  tracked as T-2053. It was still marked *Planned*, which is the same class of+  staleness this task exists to fix.++`specs/duplicate-reconciliation/design.md` — the design was checked against what+landed rather than against the plan, and three sections described something that+did not ship:++- **Export.** `rewrites[ruleUUID]` is the **kept** row's version, and the kept+  row is the group's *marked* row where it has one — the representative only+  where it has none (Decision 24). Deduping to the plain representative silently+  drops the active/current flag whenever the marked row is not the least one,+  which the representative ordering makes ordinary rather than exotic, since it+  compares `version` before the flag. Also corrected: the projected record is the+  `EntryGroup`/`WorkGroup` rather than the planned `BackupProjectedEntry`/`Work`+  structs (Decision 22); the rule dedup is a `SiteUnionProjection.RuleMembership`+  mode rather than a value-based overload (Decision 23); and `TornGroupsPayload`+  carries two fields rather than three, because Decision 14 deleted Req 8.4's+  third message arm as unreachable, with Decision 25 narrowing when the blocking+  Work set is named at all.+- **Detection.** Req 1.6's "unresolved Work set" means **divergent**, and "span"+  means two or more Works of one such set (Decision 8) — a silently resolvable+  Work set already names its survivor by the rule the collapse will use, so+  there is nothing to wait for. The two-walk cost model (Q88) is stated, because+  it is what makes the added detection affordable on the paths that now carry it.+- **The validator relaxation** (task 20, Decisions 15/16) had no place in the+  design at all: it was found during implementation. Membership now reads over+  logical rules, version uniqueness is keyed to identity groups,+  `activePatternCount` counts groups, and the current URL rule's version is its+  group's greatest.++---++## Task 24 — Re-measurement after the pre-push review fixes (Req 10.1, 10.2)++**Date:** 2026-08-03+**Status: complete. Req 10.1 is still breached and stays a known issue+(Decision 27, band improved); Decision 28's regression is fixed and its+`withKnownIssue` is removed (Decision 30); one unnamed path got ~13% slower and+is recorded below.**++The pre-push review produced nine correctness fixes and six performance ones+(Decisions 29, 30, 31 and commit `98b19e5`). Four of the six change what the+numbers in task 22 measured, so every band above is **pre-fix** and is kept for+comparison — it is the evidence the fixes worked.++Same protocol as task 22: release, M1 Max, `make test-performance-m4`, three+runs invoked separately (the target is knowingly red, so `RUNS=3` stops after+the first). Every median below is one run's median; the band is the spread of+the three medians. **The build was byte-identical across the three runs** — only+the first was compiled, the other two reused it — so the runs differ in nothing+but the machine.++### The machine control++| Measurement | Run 1 | Run 2 | Run 3 | Band | Task 22 |+|---|---|---|---|---|---|+| `extension-open-and-validate` | 0.777 s | 0.763 s | 0.787 s | 0.763–0.787 s | 0.755–0.790 s |++The control lands inside its own previous band. Nothing below is the machine.++### Req 10.1 — the settling pass++| Measurement | Run 1 | Run 2 | Run 3 | Band | Task 22 | Budget |+|---|---|---|---|---|---|---|+| `duplicate-settling-pass` | 7.317 s | 7.264 s | 7.365 s | **7.264–7.365 s** | 8.861–9.080 s | 2 s — **still breached, ~3.7×** |+| `duplicate-observation-pass` | 0.944 s | 0.930 s | 0.952 s | 0.930–0.952 s | 1.314–1.347 s | 2 s — inside |++Within-run spread was ≤ 1.04× on the settling pass in all three runs. There was+no repeat of run 3's single 15.29 s outlier from task 22.++A fourth, **confirmatory** run of this test alone — made to check the assertion+changes, not to extend the band — measured 7.461 s, just above the band's top.+It is recorded rather than folded in: the band is the three protocol runs, and a+single-test invocation is a different workload from the whole target. The 11 s+ceiling covers both.++**The cost model in task 22 was wrong, and this measurement is what says so.**+That section attributed the whole breach to `commitDeletions` running one+`save` per set — "300 collapses are 300 saves … about 30 ms each", which+arithmetically accounted for ~9 s. Decision 29 chunked those saves (with a+per-set replay on failure, so Req 2.9's guarantee survives where it is+observable) and the pass fell by **1.6–1.7 s**, not by 7. The transaction count+was worth ~18% of the pass. Whatever the remaining ~7 s is, it is not the number+of transactions.++That is worth stating plainly rather than replacing with a second guess. The+honest position is: the deletion phase is still the expensive half (the+observation pass, which does all the writing, is 0.93–0.95 s), the saves are not+what makes it expensive, and the next attempt should profile. Two hypotheses+were considered and neither is evidence: inverse-array maintenance on+`Site.entries` as 300 Entries are deleted from a ~6,000-element relationship+(the shape `cloudkit-mirroring` Q27 measured as superlinear), and the+`repointEntries` fault over 50 collapsing Works. Recorded as hypotheses, not as+findings.++Decision 27 therefore stands, with its band updated and its cost model+corrected. The 2 s budget is unchanged and still asserted inside+`withKnownIssue`; the regression floor outside it comes down from 14 s to 11 s,+which preserves the ~1.5× margin over the measured band that the old constant+had.++### Decision 28's regression is fixed++| Measurement | Run 1 | Run 2 | Run 3 | Band | Task 22 | Pre-M4c |+|---|---|---|---|---|---|---|+| `reconcile-noop-coherent` | 1.958 ms | 1.823 ms | 1.981 ms | **1.82–2.00 ms** | 0.286–0.298 s | 0.186–0.200 ms |++Decision 30 gates the full tier on the same counters the arrival tier consults,+with the session's first pass unconditional. The pass came back from 0.29 s to+~1.9 ms — a factor of ~150 — and is now **inside** the 10 ms ceiling+`cloudkit-mirroring` recorded, so:++- the `withKnownIssue` wrapper is removed from+  `M4ScalePerformanceTests.reconcileNoOpOverCoherentFixture`;+- the 500 ms regression floor that sat under it is deleted with it — the 10 ms+  ceiling is the assertion again;+- T-2092 is answered.++**It is ~10× the pre-M4c number, not equal to it.** 1.9 ms against 0.186–0.200 ms+is what a *declined* duplicate phase costs a pass: the gate itself is free, but+`reconcileWorkLists` now also materialises the two rule tables' identity columns+for the scan that may follow (the shared walk), and the pass carries the+duplicate-outcome plumbing either way. It is 5× inside the ceiling, so it is+recorded rather than chased.++### Req 10.2 — the added detection over a duplicate-free library++The two baselines Req 10.2 names, re-measured under their own fixture+preconditions:++| Measurement | Run 1 | Run 2 | Run 3 | Band | Task 22 | Budget |+|---|---|---|---|---|---|---|+| `diagnosis-refresh-foreground` | 0.297 s | 0.297 s | 0.296 s | 0.296–0.297 s | 0.287–0.298 s | 250 ms (Req 5.5) |+| `diagnosis-refresh-after-write` | 0.298 s | 0.294 s | 0.294 s | 0.294–0.298 s | 0.287–0.296 s | — |+| `diagnosis-refresh-duplicateSiteRows` | 0.298 s | 0.295 s | 0.294 s | 0.294–0.298 s | 0.287–0.299 s | — |+| `capture-projection-duplicateSiteRows` | 79.1 ms | 76.3 ms | 78.1 ms | **76.3–79.1 ms** | 67.3–69.7 ms | 100 ms |++Neither named baseline regressed against **the value Req 10.2 compares to** —+the M4b recorded numbers of 0.443–0.444 s and 118.6 ms — and both are+comfortably better than those. The 10% median-to-median comparison has no+regression to report on either.++**Capture projection got ~13% slower than task 22 measured, and that is this+push's doing.** `buildCaptureBasis` used to map the hostname's Work rows+straight into the basis; it now buckets them into logical records first, because+a split Work group put its UUID in `composedWorks` twice and `captureWorkMatch`+read the repeat as `.ambiguous` — so a capture against a Work that arrived twice+recorded processing provenance with **no Work at all**. That is the same+dead-end class the teaching and Merge projections had, found on the capture path+by the test written for the fan-out fix. The dedup costs a bucket-and-sort per+Work over a fixture holding ~1,000 of them on one hostname. It is inside the+100 ms budget with ~21 ms to spare, and it is the price of the capture path+resolving a duplicated Work at all.++A cheap follow-up exists and is deliberately not taken here: `workGroup(id:rows:)`+sorts and computes variants even for a one-row bucket, which is every bucket in+a coherent library. A single-row fast path would help every bulk `workGroups`+caller. It needs its own measurement, and this section is already reporting+three.++### Why `diagnosis-refresh` improved between M4b and M4c++Task 22 recorded the improvement from 0.443–0.444 s to ~0.29 s in one sentence+and moved on. It is worth the same interrogation the regressions got, because an+unexplained 1.5× improvement is an unexplained number either way.++The cause is Q57. `.duplicateIdentity` retired from `LibraryToleranceScan`, and+that diagnosis was computed by bucketing every row of five tables by application+UUID — on exactly the path `diagnosis-refresh` measures. What replaced it is a+candidate *count*, which the same walk produces without building the buckets.+The path did less work because it stopped answering a question that had been+retired.++### The read paths this milestone put detection on++| Measurement | Run 1 | Run 2 | Run 3 | Band | Task 22 |+|---|---|---|---|---|---|+| `recent-publication-duplicate-free` | 0.676 s | 0.678 s | 0.697 s | **0.677–0.697 s** | 1.098–1.116 s |+| `works-snapshot-duplicate-free` | 1.470 s | 1.426 s | 1.498 s | 1.426–1.498 s | 1.673–1.728 s |+| `record-counts-duplicate-free` | 0.275 s | 0.272 s | 0.280 s | 0.272–0.280 s | 0.271–0.280 s |+| `backup-projection-duplicate-free` | 1.155 s | 1.151 s | 1.183 s | 1.151–1.183 s | 1.138–1.183 s |+| `duplicate-arrival-pass-gated` | 1.78 ms | 1.76 ms | 2.16 ms | 1.76–2.16 ms | 1.74–1.91 ms |++- **`recentPresentation` is back where it was before M4c.** 0.677–0.697 s+  against the pre-M4c 0.686–0.713 s: the publication ran a whole+  `DuplicateScan.run` — two walks of the Entry table, two of the Work table and+  one of each rule table — and *then* fetched both tables again for its own+  projection. It fetches first and derives the sets from the rows in hand, and+  it walks no rule table at all, because rule groups contribute nothing to the+  workload. The ~0.42 s recovered is the two extra traversals.+- **`works()` is 15% faster** for filtering to unattached groups before+  snapshotting rather than after. It used to snapshot every Entry and discard+  ~95% of the result, on top of the snapshots the Work rows already produced.+- `record-counts` and `backup-projection` are unchanged, as expected: neither+  was touched.+- The **arrival gate** is unchanged, which is the point: the path that runs on+  every remote change was already free and stayed free.++### What this measurement still cannot say++Everything task 22's closing section said, unchanged: nothing about the device+(the package test target is in no scheme's test action), nothing about a+divergent set at scale (the fixture seeds only silently resolvable sets), and+nothing clean about attributing `recentPresentation` across M4b and M4c+together. What it *can* now say is that `recentPresentation`'s M4c share was+~0.42 s and has been given back.++### The known-issue ledger after this push++Three milestones carry a recorded breach in `make test-performance-m4`, not the+four Decision 27 counted:++| Spec | Budget | Measured | Status |+|---|---|---|---|+| `library-integrity-tolerance` Req 5.5 | 250 ms diagnosis refresh | 0.294–0.298 s | breached, Decision 11 |+| `relational-references` Req 2.6 | 10 s migration | 17.44–17.66 s (pass), 17.90–18.23 s (open) | breached, that spec's Decision 6 |+| `duplicate-reconciliation` Req 10.1 | 2 s settling pass | 7.264–7.365 s | breached, Decision 27 |++`cloudkit-mirroring` Q55's two are **retired**: `capture-projection` measures+76.3–79.1 ms against 100 ms and `diagnosis-refresh` 0.294–0.298 s against its+400 ms ceiling. T-2053's red cells are green. Decision 27's sentence counting+"four recorded breaches" was wrong in both directions — it counted two that have+since passed and omitted `relational-references`, which is in the same target and+also wrapped in `withKnownIssue`.
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift Added +502 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swiftnew file mode 100644index 0000000..3d1c0df--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift@@ -0,0 +1,502 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// An on-disk store the duplicate reconciler can be driven over exactly the way+/// `LibraryRepository` drives it.+///+/// Two properties of the production caller are modelled deliberately rather than+/// approximated:+///+/// - **every pass derives in a new `ModelContext`**, because `withLockedContext`+///   makes one per invocation, and a pass that reused the seeding context would+///   read its cache rather than the store;+/// - **the deletion phase runs in a second, fresh context** (Q61), which is the+///   whole of what makes the commit-time re-verification a verification and not+///   a tautology.+///+/// Reads therefore go through `read`, which opens its own context too. Tests+/// assert on values rather than on object identity, which is what survives the+/// three contexts.+final class DuplicateStore {+    static let hostname = "duplicated.example"+    static let otherHostname = "second.example"+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    let directory: URL+    let container: ModelContainer+    /// The seeding context. Passes and reads never use it.+    let seed: ModelContext+    let saveRecorder = InstrumentedSaveStrategy()+    private let saveStrategy: any RepositorySaveStrategy+    private(set) var ledger = DuplicateSettlingLedger()++    /// The diagnoses every commit boundary left, when `validatesBoundaries` is+    /// on. Empty means every boundary was a library the app can open (Req 2.5).+    var boundaryDiagnoses: [LibraryDiagnostics] { boundaryValidator?.boundaries ?? [] }+    private let boundaryValidator: BoundaryValidatingRelay?++    init(+        saveStrategy: (any RepositorySaveStrategy)? = nil, validatesBoundaries: Bool = false+    ) throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismDuplicateReconciler-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)+        let configuration = ModelConfiguration(+            "AsterismV3", schema: schema,+            url: directory.appending(path: "library.store"), cloudKitDatabase: .none)+        container = try ModelContainer(+            for: schema, migrationPlan: AsterismV5MigrationPlan.self,+            configurations: [configuration])+        seed = ModelContext(container)+        if let saveStrategy {+            boundaryValidator = nil+            self.saveStrategy = saveStrategy+        } else if validatesBoundaries {+            // Wraps the recorder rather than replacing it, so a suite gets the+            // save counts *and* Req 2.5's per-boundary validation. The design+            // claims Req 2.5 is "asserted by the validating save strategy", and+            // it was asserted on one hand-picked shape.+            let relay = BoundaryValidatingRelay(inner: saveRecorder)+            boundaryValidator = relay+            self.saveStrategy = relay+        } else {+            boundaryValidator = nil+            self.saveStrategy = saveRecorder+        }+    }++    // MARK: - Seeding++    @discardableResult+    func addSite(+        hostname: String = DuplicateStore.hostname, displayName: String = "site",+        mode: SiteMode = .untaught+    ) -> Site {+        let site = Site(hostname: hostname, displayName: displayName)+        site.mode = mode+        seed.insert(site)+        return site+    }++    /// One Entry row. `key` is the conservative identity key, which is the+    /// duplicate relation (Q8) — two rows sharing it are one set.+    @discardableResult+    func addEntry(+        id: UUID = UUID(),+        key: String,+        hostname: String = DuplicateStore.hostname,+        capturedAt: TimeInterval,+        sharedAt: TimeInterval? = nil,+        modifiedAt: TimeInterval? = nil,+        title: String = "Chapter",+        note: String = "",+        rating: Rating? = nil,+        work: Work? = nil,+        site: Site? = nil+    ) -> Entry {+        let entry = Entry(+            id: id, captureTitle: title, captureTitleSource: .host,+            rawURLString: "https://\(hostname)/\(key)", hostname: hostname,+            entryIdentityKey: key, timestamp: Self.epoch.addingTimeInterval(capturedAt),+            note: note, rating: rating)+        // The conservative alias is always the immutable raw URL (Q21), which+        // `V4LibraryValidator:619` enforces — a fixture that seeded the bare key+        // produced a library the validator refused, so the suites that check a+        // commit boundary could not tell a fixture defect from a pass's damage.+        // The URL is derived from `key`, so two rows sharing a key still share+        // the bucket.+        entry.conservativeIdentityKey = entry.rawURLString+        entry.entryIdentityKey = entry.rawURLString+        entry.lastSharedAt = Self.epoch.addingTimeInterval(sharedAt ?? capturedAt)+        entry.modifiedAt = Self.epoch.addingTimeInterval(modifiedAt ?? capturedAt)+        seed.insert(entry)+        entry.work = work+        entry.site = site+        return entry+    }++    /// One Work row. A seeded Work sets `lastParsedTitle` and parsed provenance+    /// by default, so it reads as the *parsed* Work the fixture means rather than+    /// as one carrying an authored title (Q75).+    @discardableResult+    func addWork(+        id: UUID = UUID(),+        title: String,+        hostname: String = DuplicateStore.hostname,+        urlIdentity: String? = nil,+        parsed: Bool = true,+        createdAt: TimeInterval,+        modifiedAt: TimeInterval? = nil,+        notes: String = "",+        site: Site? = nil+    ) -> Work {+        let work = Work(+            id: id, displayTitle: title, siteHostname: hostname,+            timestamp: Self.epoch.addingTimeInterval(createdAt))+        work.modifiedAt = Self.epoch.addingTimeInterval(modifiedAt ?? createdAt)+        work.urlIdentity = urlIdentity+        work.genericNotes = notes+        if parsed {+            work.lastParsedTitle = title+            work.titleProvenance = .parsed+        }+        seed.insert(work)+        work.site = site+        return work+    }++    @discardableResult+    func addPattern(+        id: UUID,+        site: Site?,+        version: Int = 1,+        active: Bool = false,+        createdAt: TimeInterval = 0,+        definition: PatternDefinition? = nil,+        trimPrefix: String? = nil,+        trimSuffix: String? = nil+    ) throws -> TitlePattern {+        let pattern = try TitlePattern(+            id: id, version: version, isActive: active,+            createdAt: Self.epoch.addingTimeInterval(createdAt),+            definition: definition+                ?? .segment(+                    work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1), ignored: []),+            site: site)+        pattern.trimPrefix = trimPrefix+        pattern.trimSuffix = trimSuffix+        seed.insert(pattern)+        return pattern+    }++    @discardableResult+    func addURLRule(+        id: UUID,+        site: Site?,+        version: Int = 1,+        current: Bool = false,+        createdAt: TimeInterval = 0,+        definition: URLRuleDefinition? = nil+    ) throws -> URLRulePattern {+        let rule = try URLRulePattern(+            id: id, version: version, isCurrent: current,+            createdAt: Self.epoch.addingTimeInterval(createdAt), origin: .readerTaught,+            definition: definition ?? .work(locator: .query(name: ExactScalarString("identity"))),+            site: site)+        seed.insert(rule)+        return rule+    }++    func commit() throws { try seed.save() }++    // MARK: - Running++    /// One whole pass, in the two-context shape the repository uses.+    ///+    /// `afterDerivation` runs between the write phase and the deletion phase, in+    /// a context of its own — the seam a mid-pass arrival lands through.+    @discardableResult+    func reconcile(+        batchSize: Int = LibraryRepository.bulkOperationBatchSize,+        afterDerivation: ((ModelContext) throws -> Void)? = nil+    ) throws -> DuplicateReconciliationOutcome {+        let context = ModelContext(container)+        let scan = try DuplicateScan.run(context: context)+        var result = try DuplicateReconciler.run(+            scan: scan, ledger: &ledger, batchSize: batchSize, context: context,+            saveStrategy: saveStrategy)++        if let afterDerivation {+            let arrival = ModelContext(container)+            try afterDerivation(arrival)+            if arrival.hasChanges { try arrival.save() }+        }++        guard !result.deletions.isEmpty else { return result.outcome }+        let canonicalWorkIDs = scan.canonicalWorkIDs+        let deleting = ModelContext(container)+        let committed = Set(try DuplicateReconciler.commitDeletions(+            result.deletions, canonicalWorkIDs: canonicalWorkIDs, context: deleting,+            saveStrategy: saveStrategy))+        for plan in result.deletions {+            if committed.contains(plan.key) {+                ledger.forget(plan.key)+                result.outcome.collapsedMembers += plan.loserIDs.count+            } else {+                // The ledger entry survives an abort, exactly as+                // `LibraryRepository.commitCollapses` keeps it: a set the pass+                // qualified and then failed to delete has still been observed+                // twice, and forgetting it would make the next pass a first+                // observation all over again.+                result.outcome.settlingSetKeys.append(plan.key)+            }+        }+        return result.outcome+    }++    /// Runs passes until one writes nothing, or `limit` passes have gone by.+    /// Runs passes until one writes nothing, or `limit` passes have gone by.+    ///+    /// The stop condition is `wroteNothing`, not `isEmpty`: a library holding a+    /// divergent set reports that set on every pass, and a loop waiting for the+    /// report to stop would never stop.+    @discardableResult+    func reconcileToFixedPoint(limit: Int = 6) throws -> Int {+        for pass in 1...limit {+            if try reconcile().wroteNothing { return pass }+        }+        return limit+    }++    // MARK: - Reading++    /// Opens a context of its own, so what a test asserts is what the store+    /// holds rather than what some earlier pass left cached.+    func read<Value>(_ body: (ModelContext) throws -> Value) throws -> Value {+        try body(ModelContext(container))+    }++    /// Sorted **totally**, so two stores holding the same rows in two fetch+    /// orders produce the same array. Rows of one identity group share a UUID+    /// and can share a capture date, so an id-only sort would leave their order+    /// to the fetch and make every comparison a coin toss.+    func entryFacts() throws -> [EntryFacts] {+        try read { context in+            try context.fetch(FetchDescriptor<Entry>())+                .map(EntryFacts.init)+                .sorted {+                    ($0.id.uuidString, $0.firstCapturedAt, $0.lastSharedAt, $0.note)+                        < ($1.id.uuidString, $1.firstCapturedAt, $1.lastSharedAt, $1.note)+                }+        }+    }++    func workFacts() throws -> [WorkFacts] {+        try read { context in+            try context.fetch(FetchDescriptor<Work>())+                .map(WorkFacts.init)+                .sorted {+                    ($0.id.uuidString, $0.createdAt, $0.modifiedAt, $0.genericNotes)+                        < ($1.id.uuidString, $1.createdAt, $1.modifiedAt, $1.genericNotes)+                }+        }+    }++    func patternFacts() throws -> [PatternFacts] {+        try read { context in+            try context.fetch(FetchDescriptor<TitlePattern>())+                .map(PatternFacts.init)+                .sorted { ($0.id.uuidString, $0.createdAt) < ($1.id.uuidString, $1.createdAt) }+        }+    }++    func diagnose() throws -> LibraryDiagnostics {+        try read { try V4LibraryValidator.validate(context: $0) }+    }++    /// A UUID whose string order follows `rank`, so which member the survivor+    /// rule's tie-break picks is stated rather than drawn.+    static func rankedID(_ rank: Int) -> UUID {+        UUID(uuidString: String(format: "00000000-0000-4000-8000-%012d", rank))!+    }+}++// MARK: - Value facts++struct EntryFacts: Equatable, Sendable {+    let id: UUID+    let conservativeIdentityKey: String+    let note: String+    let rating: Rating?+    let chapterTitle: String?+    let chapterTitleProvenance: FieldProvenanceKind+    let intentionallyUnattached: Bool+    let workID: UUID?+    let workAssignmentProvenance: FieldProvenanceKind+    let firstCapturedAt: Date+    let lastSharedAt: Date+    let modifiedAt: Date+    let chapterPatternID: UUID?+    let chapterPatternVersion: Int?++    init(_ entry: Entry) {+        id = entry.id+        conservativeIdentityKey = entry.conservativeIdentityKey+        note = entry.note+        rating = entry.rating+        chapterTitle = entry.chapterTitle+        chapterTitleProvenance = entry.chapterTitleProvenance+        intentionallyUnattached = entry.intentionallyUnattached+        workID = entry.work?.id+        workAssignmentProvenance = entry.workAssignmentProvenance+        firstCapturedAt = entry.firstCapturedAt+        lastSharedAt = entry.lastSharedAt+        modifiedAt = entry.modifiedAt+        chapterPatternID = entry.chapterPatternID+        chapterPatternVersion = entry.chapterPatternVersion+    }+}++struct WorkFacts: Equatable, Sendable {+    let id: UUID+    let displayTitle: String+    let genericNotes: String+    let workURLString: String?+    let genreTags: [String]+    let type: WorkType+    let titleProvenance: TitleProvenance+    let createdAt: Date+    let modifiedAt: Date+    let entryIDs: [UUID]++    init(_ work: Work) {+        id = work.id+        displayTitle = work.displayTitle+        genericNotes = work.genericNotes+        workURLString = work.workURLString+        genreTags = work.genreTags+        type = work.type+        titleProvenance = work.titleProvenance+        createdAt = work.createdAt+        modifiedAt = work.modifiedAt+        entryIDs = work.entryValues.map(\.id).sorted { $0.uuidString < $1.uuidString }+    }+}++struct PatternFacts: Equatable, Sendable {+    let id: UUID+    let version: Int+    let isActive: Bool+    let createdAt: Date+    let canonicalDefinition: String+    let trimPrefix: String?+    let trimSuffix: String?+    let siteObjectID: PersistentIdentifier?++    init(_ pattern: TitlePattern) {+        id = pattern.id+        version = pattern.version+        isActive = pattern.isActive+        createdAt = pattern.createdAt+        canonicalDefinition = GroupOrdering.canonicalDefinition(pattern)+        trimPrefix = pattern.trimPrefix+        trimSuffix = pattern.trimSuffix+        siteObjectID = pattern.site?.persistentModelID+    }+}++/// Relays to an inner strategy, then validates what the boundary left.+final class BoundaryValidatingRelay: RepositorySaveStrategy, @unchecked Sendable {+    private let inner: any RepositorySaveStrategy+    private let lock = NSLock()+    private var _boundaries: [LibraryDiagnostics] = []++    var boundaries: [LibraryDiagnostics] { lock.withLock { _boundaries } }++    init(inner: any RepositorySaveStrategy) { self.inner = inner }++    func save(_ context: ModelContext) throws {+        try inner.save(context)+        let validated = try V4LibraryValidator.validate(context: context)+        lock.withLock { _boundaries.append(validated) }+    }+}++// MARK: - A save strategy that validates each boundary++/// Every commit boundary must leave a library the app can open (Req 2.5).+final class DuplicateBoundaryValidatingStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var _boundaries: [LibraryDiagnostics] = []++    var boundaries: [LibraryDiagnostics] { lock.withLock { _boundaries } }++    func save(_ context: ModelContext) throws {+        try context.save()+        let validated = try V4LibraryValidator.validate(context: context)+        lock.withLock { _boundaries.append(validated) }+    }+}++/// A save strategy a test can arm and disarm between passes, so one pass can be+/// interrupted mid-chunk and the next one asked to finish the job (Req 2.5).+///+/// Distinct from `FailFromNthSaveStrategy`, which is armed for the life of the+/// store: what this one is for is the *recovery* half — what a later pass sees+/// after an interrupted one, which is the only place the ledger's "record after+/// the save" property is observable (Decision 12).+final class ArmableFailingSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var _saveCount = 0+    private var _failFrom: Int?++    /// Fail every save from the *n*th (1-based) onwards. `nil` disarms.+    var failFrom: Int? {+        get { lock.withLock { _failFrom } }+        set { lock.withLock { _failFrom = newValue } }+    }++    var saveCount: Int { lock.withLock { _saveCount } }++    func resetCount() { lock.withLock { _saveCount = 0 } }++    func save(_ context: ModelContext) throws {+        let (attempt, threshold) = lock.withLock { () -> (Int, Int?) in+            _saveCount += 1+            return (_saveCount, _failFrom)+        }+        if let threshold, attempt >= threshold { throw CocoaError(.fileWriteUnknown) }+        try context.save()+    }+}++/// Fails the **first save that deletes anything**, and lets every other one+/// through.+///+/// Two things `FailFromNthSaveStrategy` cannot express, both needed by+/// Decision 29's chunked deletion. It replays a failed chunk one set at a time,+/// so a strategy that keeps failing makes the replay fail too and the property+/// is unobservable; and counting saves to reach the deletion phase encodes how+/// many the *write* phase happened to make, which is a fixture detail.+final class FailFirstDeletingSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var armed = true++    var hasFired: Bool { lock.withLock { !armed } }++    func save(_ context: ModelContext) throws {+        let deletes = !context.deletedModelsArray.isEmpty+        let fire = lock.withLock { () -> Bool in+            guard armed, deletes else { return false }+            armed = false+            return true+        }+        if fire { throw CocoaError(.fileWriteUnknown) }+        try context.save()+    }+}++/// Fails the *n*th save and every save after it, so a deleting commit can be made+/// to abort without the writes that preceded it being disturbed.+final class FailFromNthSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var count = 0+    private let failFrom: Int++    init(failFrom: Int) { self.failFrom = failFrom }++    var saveCount: Int { lock.withLock { count } }++    func save(_ context: ModelContext) throws {+        let attempt = lock.withLock { () -> Int in+            count += 1+            return count+        }+        if attempt >= failFrom { throw CocoaError(.fileWriteUnknown) }+        try context.save()+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift Added +450 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swiftnew file mode 100644index 0000000..127192c--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift@@ -0,0 +1,450 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Task 20: `V4LibraryValidator` over a converged rule identity group (Req 6.2,+/// Q91).+///+/// Q91 recorded the hole this closes. A rule group whose rows sit on **one**+/// Site row is `.siteTuple` before convergence and after it — the membership+/// clause (`:479`) requires that Site row's pattern ids to be distinct, and+/// convergence repairs definitions, not diagnoses. A `.siteTuple` quarantines+/// the hostname and takes teaching off the capture path, so Req 6.2's "every+/// taught Site SHALL still hold a usable active rule set" was unsatisfiable for+/// that shape.+///+/// The relaxation is narrow on purpose: a repeated id validates **only** when+/// the rows are a converged group, so every state that is actually damage still+/// fails.+@Suite("Converged rule group validation", .serialized)+struct ConvergedRuleGroupValidationTests {++    // MARK: - Membership (Req 6.2, V4LibraryValidator:479 / :483)++    @Test("A converged title-rule group on one Site row validates")+    func convergedPatternGroupValidates() throws {+        let store = try RuleGroupStore()+        let shared = UUID()+        try store.seedTaughtSite(patternID: shared)+        // The twin: same definition, same trims, not active — which is exactly+        // what `DuplicateReconciler.convergePatternGroup` leaves behind.+        try store.addPattern(id: shared, version: 2, isActive: false)+        try store.save()++        let diagnostics = try V4LibraryValidator.validate(context: store.context)++        #expect(diagnostics.quarantineMap().isEmpty)+        #expect(diagnostics.diagnoses.isEmpty)+    }++    @Test("A same-UUID pair whose definitions differ still fails")+    func tornDefinitionPairStillFails() throws {+        let store = try RuleGroupStore()+        let shared = UUID()+        try store.seedTaughtSite(patternID: shared)+        try store.addPattern(+            id: shared, version: 2, isActive: false,+            definition: .segment(+                work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: []))+        try store.save()++        let diagnostics = try V4LibraryValidator.validate(context: store.context)++        #expect(diagnostics.quarantineMap()[RuleGroupStore.hostname] != nil)+    }++    /// The trims are half the definition surface (Q63) and `canonicalDefinition`+    /// carries them, so a pair differing only in a trim is *not* converged: the+    /// two rows derive different chapter titles.+    @Test("A same-UUID pair differing only in a trim still fails")+    func differingTrimsStillFail() throws {+        let store = try RuleGroupStore()+        let shared = UUID()+        try store.seedTaughtSite(patternID: shared)+        try store.addPattern(id: shared, version: 2, isActive: false, trimSuffix: " - Read")+        try store.save()++        let diagnostics = try V4LibraryValidator.validate(context: store.context)++        #expect(diagnostics.quarantineMap()[RuleGroupStore.hostname] != nil)+    }++    /// Two active rows of one group is the `.siteTuple` state convergence exists+    /// to avoid manufacturing (Q63/Q83). It must keep failing, or the relaxation+    /// would bless it.+    @Test("A same-UUID pair with two active rows still fails")+    func twoActiveRowsStillFail() throws {+        let store = try RuleGroupStore()+        let shared = UUID()+        try store.seedTaughtSite(patternID: shared)+        try store.addPattern(id: shared, version: 2, isActive: true)+        try store.save()++        let diagnostics = try V4LibraryValidator.validate(context: store.context)++        #expect(diagnostics.quarantineMap()[RuleGroupStore.hostname] != nil)+    }++    // MARK: - Site-unique versions, re-keyed to identity groups (:490, :508)++    /// The M4b Q18 origin case: one archive imported twice gives two rows of one+    /// rule at the *same* version, which Decision 13 deliberately never+    /// separates.+    @Test("A converged group whose rows share one version validates")+    func groupAtOneVersionValidates() throws {+        let store = try RuleGroupStore()+        let shared = UUID()+        try store.seedTaughtSite(patternID: shared, version: 1)+        try store.addPattern(id: shared, version: 1, isActive: false)+        try store.save()++        let diagnostics = try V4LibraryValidator.validate(context: store.context)++        #expect(diagnostics.quarantineMap().isEmpty)+    }++    /// Decision 13's guard has to stay meaningful: the uniqueness is over the+    /// Site row's whole rule membership, and an *unrelated* rule colliding on a+    /// version is the collision that guard exists to prevent.+    @Test("A group colliding with an unrelated rule's version still fails")+    func collisionWithAnUnrelatedRuleStillFails() throws {+        let store = try RuleGroupStore()+        let shared = UUID()+        try store.seedTaughtSite(patternID: shared, version: 1)+        try store.addPattern(id: shared, version: 2, isActive: false)+        // A different rule, on the same Site row, at a version the group holds.+        try store.addPattern(id: UUID(), version: 2, isActive: false)+        try store.save()++        let diagnostics = try V4LibraryValidator.validate(context: store.context)++        #expect(diagnostics.quarantineMap()[RuleGroupStore.hostname] != nil)+    }++    // MARK: - The two clauses task 20.2 decides++    /// Decision 15: `activePatternCount` counts **groups holding an active row**,+    /// not rows. Decision 5's invariant is about one active title *rule*, and a+    /// converged group is one rule.+    @Test("A converged group with one active row satisfies the one-active-rule clause")+    func oneActiveRowPerGroupSatisfiesTheTaughtTuple() throws {+        let store = try RuleGroupStore()+        let shared = UUID()+        try store.seedTaughtSite(patternID: shared, version: 1)+        try store.addPattern(id: shared, version: 2, isActive: false)+        try store.save()++        // The Site is `.taught`, so the tuple table requires exactly one active+        // title rule — and finds it, over two rows.+        #expect(try V4LibraryValidator.validate(context: store.context).quarantineMap().isEmpty)+    }++    /// Decision 16: the "current URL rule must have the greatest retained+    /// version" clause compares *rules*, and a rule's version is its group's+    /// greatest. `demoteWithinSites` keeps the flag on the first row in+    /// representative order, which sorts `createdAt` then version ascending —+    /// so a converged group routinely ends with its current row below a retained+    /// twin.+    @Test("A current URL rule below its own retained twin validates")+    func currentURLRuleBelowItsTwinValidates() throws {+        let store = try RuleGroupStore()+        let shared = UUID()+        try store.seedTaughtSite(urlRuleID: shared, urlRuleVersion: 3)+        try store.addURLRule(id: shared, version: 5, isCurrent: false)+        try store.save()++        let diagnostics = try V4LibraryValidator.validate(context: store.context)++        #expect(diagnostics.quarantineMap().isEmpty)+    }++    /// The clause still means something: an *unrelated* rule above the current+    /// one is the state it was written for.+    @Test("A current URL rule below an unrelated retained rule still fails")+    func currentURLRuleBelowAnUnrelatedRuleStillFails() throws {+        let store = try RuleGroupStore()+        let shared = UUID()+        try store.seedTaughtSite(urlRuleID: shared, urlRuleVersion: 3)+        try store.addURLRule(id: shared, version: 5, isCurrent: false)+        try store.addURLRule(id: UUID(), version: 9, isCurrent: false)+        try store.save()++        let diagnostics = try V4LibraryValidator.validate(context: store.context)++        #expect(diagnostics.quarantineMap()[RuleGroupStore.hostname] != nil)+    }++    // MARK: - Req 6.2 end to end++    /// The requirement itself: a taught hostname holding a converged group is+    /// not quarantined, so capture still applies its rules.+    @Test("A taught hostname holding a converged group keeps its rules on the capture path")+    func taughtHostnameKeepsItsRules() async throws {+        let library = try RuleGroupLibrary()+        let shared = UUID()+        try library.seed { store in+            try store.seedTaughtSite(patternID: shared)+            try store.addPattern(id: shared, version: 2, isActive: false)+        }+        let repository = try await library.openForApp()++        let quarantined = await repository.diagnostics.quarantineMap()+        #expect(quarantined[RuleGroupStore.hostname] == nil)++        // The proof that it reaches capture: a share against the hostname is+        // *parsed* by the Site's rules. A quarantine is exactly what would stop+        // that, so a projected chapter is the end-to-end evidence Req 6.2 asks+        // for — "still holds a usable active rule set" means usable on the path+        // that uses it.+        let contract = try await repository.projectCapture(+            hostname: RuleGroupStore.hostname,+            captureTitle: "Twinned Serial :: Chapter Nine",+            captureTitleSource: .host,+            rawURLString: "https://\(RuleGroupStore.hostname)/read/9",+            canonicalURLString: nil,+            note: "",+            rating: nil)+        #expect(contract.outcome.projectedChapter == "Chapter Nine")+        #expect(contract.outcome.projectedWorkTitle == "Twinned Serial")+    }++    // MARK: - The projections read the same membership (task 20.3/20.4)++    /// The half of the relaxation that lives outside the validator. If the store+    /// validates a converged group and the export still refuses it, the dead end+    /// has moved rather than gone: `SiteUnionProjection` sources a hostname's+    /// rule rows from its Site relationships, so both rows reached the archive+    /// and the reference validator refused the file for a duplicate rule ID.+    @Test("A converged title-rule group projects one archive rule, and the archive decodes")+    func convergedPatternGroupProjectsOneArchiveRule() async throws {+        let library = try RuleGroupLibrary()+        let shared = UUID()+        try library.seed { store in+            try store.seedTaughtSite(patternID: shared)+            try store.addPattern(id: shared, version: 2, isActive: false)+        }+        let repository = try await library.openForApp()++        // The premise: the store says this library is fine.+        #expect(await repository.diagnostics.quarantineMap().isEmpty)++        let payload = try await repository.backupV4Snapshot()++        #expect(payload.titlePatterns.count == 1)+        #expect(payload.titlePatterns.first?.id == shared)+        #expect(payload.sites.first?.patternIDs == [shared])+        // The reference validator is what refuses a payload holding one rule+        // UUID twice, so a decode is the assertion that matters here.+        let encoded = try BackupV4Codec.encode(+            payload: payload,+            metadata: BackupV4Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+        let decoded = try BackupV4Codec.decode(encoded)+        #expect(decoded.payload.titlePatterns.count == 1)+    }++    /// The same statement in the direction the shipped tests never seeded: the+    /// group's **active** row is the later-created, higher-versioned one, so the+    /// representative ordering — hostname, `createdAt`, version, then the flag —+    /// picks its bare twin. The store validates the shape either way (the+    /// converged predicate caps a group at one active row and does not ask which+    /// one), so if the dedup dropped the flag the export would refuse a library+    /// the store had just accepted: the sentence task 20.4 exists to prevent.+    ///+    /// Nothing repairs it, either. `demoteWithinSites` demotes and never+    /// promotes, and Decision 13 leaves a group spanning versions wherever+    /// aligning them would break the owning Site row's uniqueness — which is+    /// exactly this shape. The refusal would be permanent, and its message would+    /// tell the reader to wait for a sync that has already finished.+    @Test("A converged group whose active row is the higher version validates and exports")+    func convergedGroupWithTheActiveRowAboveItsTwinExports() async throws {+        let library = try RuleGroupLibrary()+        let shared = UUID()+        try library.seed { store in+            try store.seedTaughtSite(patternID: shared, version: 3)+            try store.addPattern(id: shared, version: 1, isActive: false)+        }+        let repository = try await library.openForApp()++        // The premise, again: the store says this library is fine.+        #expect(await repository.diagnostics.quarantineMap().isEmpty)++        let payload = try await repository.backupV4Snapshot()++        #expect(payload.titlePatterns.count == 1)+        #expect(payload.titlePatterns.first?.isActive == true)+        #expect(payload.sites.first?.mode == .taught)+        let encoded = try BackupV4Codec.encode(+            payload: payload,+            metadata: BackupV4Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+        _ = try BackupV4Codec.decode(encoded)+    }++    /// The URL-rule counterpart, which is the quieter failure: the archive's+    /// tuple table never asks a taught Site for a current URL rule, so a dropped+    /// `isCurrent` produces a file that validates and decodes while the+    /// hostname's identity teaching has become history inside it.+    @Test("A converged URL-rule group whose current row is the higher version keeps it")+    func convergedURLRuleGroupWithTheCurrentRowAboveItsTwinExports() async throws {+        let library = try RuleGroupLibrary()+        let shared = UUID()+        try library.seed { store in+            try store.seedTaughtSite(urlRuleID: shared, urlRuleVersion: 5)+            try store.addURLRule(id: shared, version: 3, isCurrent: false)+        }+        let repository = try await library.openForApp()++        #expect(await repository.diagnostics.quarantineMap().isEmpty)++        let payload = try await repository.backupV4Snapshot()++        #expect(payload.urlRules.count == 1)+        #expect(payload.urlRules.first?.isCurrent == true)+    }++    /// Decision 16's shape, exported: the current row sits *below* its retained+    /// twin, which the store now validates. The archive holds one rule, so the+    /// wire tuple's own greatest-version clause is satisfied by construction.+    @Test("A converged URL-rule group whose current row is the lower version exports once")+    func convergedURLRuleGroupProjectsOneArchiveRule() async throws {+        let library = try RuleGroupLibrary()+        let shared = UUID()+        try library.seed { store in+            try store.seedTaughtSite(urlRuleID: shared, urlRuleVersion: 3)+            try store.addURLRule(id: shared, version: 5, isCurrent: false)+        }+        let repository = try await library.openForApp()++        #expect(await repository.diagnostics.quarantineMap().isEmpty)++        let payload = try await repository.backupV4Snapshot()++        #expect(payload.urlRules.count == 1)+        #expect(payload.urlRules.first?.id == shared)+        #expect(payload.urlRules.first?.isCurrent == true)+        let encoded = try BackupV4Codec.encode(+            payload: payload,+            metadata: BackupV4Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+        _ = try BackupV4Codec.decode(encoded)+    }+}++// MARK: - Fixtures++/// A store holding one taught Site whose rule membership the tests perturb.+private final class RuleGroupStore {+    static let hostname = "converged.example"+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    let context: ModelContext+    private(set) var site: Site!++    init(context: ModelContext) {+        self.context = context+    }++    convenience init() throws {+        let directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismConvergedRules-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)+        let configuration = ModelConfiguration(+            "AsterismV3", schema: schema,+            url: directory.appending(path: "library.store"), cloudKitDatabase: .none)+        let container = try ModelContainer(+            for: schema, migrationPlan: AsterismV5MigrationPlan.self,+            configurations: [configuration])+        self.init(context: ModelContext(container))+        retained = container+    }++    private var retained: ModelContainer?++    /// A taught Site with one active title rule and one current URL rule — the+    /// smallest legal taught tuple.+    func seedTaughtSite(+        patternID: UUID = UUID(), version: Int = 1,+        urlRuleID: UUID = UUID(), urlRuleVersion: Int = 1+    ) throws {+        let site = Site(hostname: Self.hostname, displayName: "converged")+        site.mode = .taught+        context.insert(site)+        self.site = site+        try addPattern(id: patternID, version: version, isActive: true)+        try addURLRule(id: urlRuleID, version: urlRuleVersion, isCurrent: true)+    }++    @discardableResult+    func addPattern(+        id: UUID, version: Int, isActive: Bool, definition: PatternDefinition? = nil,+        trimPrefix: String? = nil, trimSuffix: String? = nil+    ) throws -> TitlePattern {+        let pattern = try TitlePattern(+            id: id, version: version, isActive: isActive,+            createdAt: Self.epoch.addingTimeInterval(TimeInterval(version)),+            definition: definition ?? .phrase(+                prefix: "", separator: " :: ", suffix: "", order: .workThenChapter),+            site: site)+        pattern.trimPrefix = trimPrefix+        pattern.trimSuffix = trimSuffix+        context.insert(pattern)+        site.patterns = site.patternValues + [pattern]+        return pattern+    }++    @discardableResult+    func addURLRule(id: UUID, version: Int, isCurrent: Bool) throws -> URLRulePattern {+        let rule = try URLRulePattern(+            id: id, version: version, isCurrent: isCurrent,+            createdAt: Self.epoch.addingTimeInterval(TimeInterval(version)),+            origin: .readerTaught,+            definition: .work(locator: .query(name: ExactScalarString("identity"))),+            site: site)+        context.insert(rule)+        site.urlRules = site.urlRuleValues + [rule]+        return rule+    }++    func save() throws { try context.save() }+}++/// The same shapes, behind a real `openV4ForApp`, for the Req 6.2 end-to-end+/// check: the quarantine is what takes teaching off the capture path, so the+/// only honest test of "still holds a usable active rule set" is a capture.+private final class RuleGroupLibrary {+    let directory: URL+    let configuration: LibraryConfiguration++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismConvergedRuleLibrary-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+        try FileManager.default.createDirectory(+            at: configuration.v4StoreURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)+    }++    func seed(_ body: (RuleGroupStore) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let store = RuleGroupStore(context: ModelContext(container))+        try body(store)+        try store.save()+        try V5RelationshipPass.run(context: store.context)+        withExtendedLifetime(container) {}+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)+    }++    func openForApp() async throws -> LibraryRepository {+        let (_, repository) = try await LibraryRepository.openV4ForApp(+            configuration, capabilities: .m4,+            clock: FixedRepositoryClock(RuleGroupStore.epoch),+            saveStrategy: ModelContextSaveStrategy())+        return repository+    }++    deinit {+        try? FileManager.default.removeItem(at: directory)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift Added +415 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swiftnew file mode 100644index 0000000..861d6b9--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift@@ -0,0 +1,415 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Req 2.10: a reader edit committed against a record reconciliation has+/// meanwhile removed must not be lost.+///+/// The mechanism is identity resolution from store state, not the session+/// loser→survivor map (Q60). A collapse performed by the *peer* device leaves no+/// local map entry and no relaunch is involved, so a map-only redirect would+/// drop exactly the case the requirement is about. The map is an optimisation+/// over the same answer.+@Suite("Post-collapse identity redirect", .serialized)+struct PostCollapseRedirectTests {++    /// The local device collapsed the set, so the map holds the answer — and the+    /// answer it holds is the one identity resolution gives.+    @Test("A write to a locally collapsed loser lands on the survivor")+    func mapHitRedirects() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        let loser = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: survivor, hostname: "dup.example", title: "Chapter", offset: 0)+        }+        let repository = try await library.openForApp()+        await repository.recordCollapse(loser: loser, survivor: survivor, type: .entry)++        let outcome = try await repository.updateEntry(+            id: loser, basis: library.entryBasis(id: loser),+            note: "the reader's edit", rating: .up)++        #expect(outcome == .committed)+        let rows = try library.entryRows(id: survivor)+        #expect(rows.map(\.note) == ["the reader's edit"])+    }++    /// No map entry at all — the peer device did the collapse. The conservative+    /// key on its hostname still names the survivor.+    @Test("A write to a peer-collapsed loser still lands on the survivor")+    func identityResolutionRedirectsWithoutAMapEntry() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: survivor, hostname: "dup.example", title: "Chapter", offset: 0)+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateEntry(+            id: UUID(), basis: library.entryBasis(id: survivor),+            note: "the reader's edit", rating: nil)++        #expect(outcome == .committed)+        #expect(try library.entryRows(id: survivor).map(\.note) == ["the reader's edit"])+    }++    /// Q43: the redirect applies only where the survivor's values for the+    /// basis's fields match, or the survivor is bare. Otherwise the edit is+    /// preserved and the conflict surfaced — never applied over content the+    /// reader has not seen.+    @Test("A survivor whose content differs refuses rather than taking the edit")+    func divergentSurvivorConflicts() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let row = store.insertEntry(+                id: survivor, hostname: "dup.example", title: "Chapter", offset: 0)+            row.note = "the other device's note"+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateEntry(+            id: UUID(),+            basis: EntryEditBasis(+                note: "what this screen loaded", rating: nil, hostname: "dup.example",+                conservativeIdentityKey: library.identityKey),+            note: "the reader's edit", rating: nil)++        guard case .conflict(.survivorDiverged(_, let survivorID)) = outcome else {+            Issue.record("expected a diverged survivor, got \(outcome)")+            return+        }+        #expect(survivorID == survivor)+        #expect(try library.entryRows(id: survivor).map(\.note) == ["the other device's note"])+    }++    @Test("A torn survivor refuses rather than taking the edit")+    func tornSurvivorConflicts() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertEntry(+                id: survivor, hostname: "dup.example", title: "Chapter", offset: 0)+            first.note = "device one"+            let second = store.insertEntry(+                id: survivor, hostname: "dup.example", title: "Chapter", offset: 30)+            second.note = "device two"+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateEntry(+            id: UUID(), basis: library.entryBasis(id: survivor),+            note: "the reader's edit", rating: nil)++        guard case .conflict = outcome else {+            Issue.record("expected a conflict, got \(outcome)")+            return+        }+    }++    /// Q60: the silent path writes a set-max `modifiedAt`, so a timestamp+    /// compare would refuse every legitimate redirect. The match reads the+    /// basis's own fields and nothing else.+    @Test("modifiedAt does not participate in the match")+    func modifiedAtIsExcludedFromTheMatch() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let row = store.insertEntry(+                id: survivor, hostname: "dup.example", title: "Chapter", offset: 0)+            row.note = "what this screen loaded"+            row.modifiedAt = WriteFixture.epoch.addingTimeInterval(9_000)+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateEntry(+            id: UUID(),+            basis: EntryEditBasis(+                note: "what this screen loaded", rating: nil, hostname: "dup.example",+                conservativeIdentityKey: library.identityKey),+            note: "the reader's edit", rating: nil)++        #expect(outcome == .committed)+    }++    @Test("No identity match at all reports the record as gone")+    func noIdentityMatchReportsNotFound() async throws {+        let library = try WriteFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+        }+        let repository = try await library.openForApp()++        await #expect(throws: LibraryRepositoryError.self) {+            try await repository.updateEntry(+                id: UUID(), basis: library.entryBasis(id: UUID()), note: "lost", rating: nil)+        }+    }++    /// A deletion that finds nothing to delete has done what it was asked.+    @Test("A deletion with no identity match reports success")+    func deletionWithNoMatchSucceeds() async throws {+        let library = try WriteFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.deleteEntry(+            id: UUID(), basis: library.entryBasis(id: UUID()), disclosedVariants: nil)++        #expect(outcome == .committed)+    }++    @Test("A move to a collapsed loser repoints the survivor")+    func moveRedirectsToTheSurvivor() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        let workID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertWork(id: workID, hostname: "dup.example", title: "A Serial", offset: 0)+            store.insertEntry(id: survivor, hostname: "dup.example", title: "Chapter", offset: 0)+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.moveEntry(+            UUID(), basis: library.assignmentBasis(id: survivor), to: .existing(workID))++        #expect(outcome == .committed)+        #expect(try library.entryRows(id: survivor).allSatisfy { $0.work?.id == workID })+    }++    /// A Work has no conservative key: its duplicate relation is site plus URL+    /// identity where taught, otherwise site plus parsed title (§2.4).+    @Test("A Work write to a collapsed loser resolves by work identity")+    func workRedirectResolvesByWorkIdentity() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let row = store.insertWork(+                id: survivor, hostname: "dup.example", title: "A Serial", offset: 0)+            row.lastParsedTitle = "A Serial"+            row.titleProvenanceRaw = TitleProvenance.parsed.rawValue+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateWork(+            id: UUID(),+            basis: WorkEditBasis(+                displayTitle: "A Serial", type: .other, genreTags: [], genericNotes: "",+                siteHostname: "dup.example", urlIdentity: nil, lastParsedTitle: "A Serial",+                titleProvenance: .parsed),+            draft: WorkMetadataDraft(+                displayTitle: "A Serial", type: .other, genreTags: ["fantasy"],+                genericNotes: "reader prose"))++        #expect(outcome == .committed)+        #expect(try library.workRows(id: survivor).map(\.genericNotes) == ["reader prose"])+    }++    /// **The redirect and the reconciler must spell the duplicate relation the+    /// same way.** They did not: the redirect guarded its URL-identity arm with+    /// `!identity.isEmpty` while `DuplicateScan.workBucketKey` guards it with+    /// `M2Unicode.isBlank`. A blank-but-non-empty `urlIdentity` therefore+    /// buckets by *parsed title* for the reconciler — so it collapses into a+    /// title-keyed twin — and by URL identity for the redirect, which then looks+    /// for a row holding that blank identity, finds none, and reports the+    /// record gone while the survivor sits in the store.+    @Test("A blank URL identity redirects by the same relation the collapse used")+    func workRedirectUsesTheScansBucketKey() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let row = store.insertWork(+                id: survivor, hostname: "dup.example", title: "A Serial", offset: 0)+            row.lastParsedTitle = "A Serial"+            row.titleProvenanceRaw = TitleProvenance.parsed.rawValue+            // The survivor holds no URL identity at all; the collapsed loser's+            // basis carries a blank one. Both are the *title* bucket.+            row.urlIdentity = nil+        }+        let repository = try await library.openForApp()++        #expect(+            DuplicateScan.workBucketKey(+                siteHostname: "dup.example", urlIdentity: " ", lastParsedTitle: "A Serial")+                == DuplicateScan.workBucketKey(+                    siteHostname: "dup.example", urlIdentity: nil, lastParsedTitle: "A Serial"),+            "the scan reads a blank URL identity as no identity")++        let outcome = try await repository.updateWork(+            id: UUID(),+            basis: WorkEditBasis(+                displayTitle: "A Serial", type: .other, genreTags: [], genericNotes: "",+                siteHostname: "dup.example", urlIdentity: " ", lastParsedTitle: "A Serial",+                titleProvenance: .parsed),+            draft: WorkMetadataDraft(+                displayTitle: "A Serial", type: .other, genreTags: [],+                genericNotes: "reader prose"))++        #expect(outcome == .committed)+        #expect(try library.workRows(id: survivor).map(\.genericNotes) == ["reader prose"])+    }++    /// `updateWork` edits the display title, and a manually set title differing+    /// from the last parsed one is reader-authored (Q34) — so it is one of the+    /// basis's edited fields and belongs in Q43's match. Left out, the match+    /// passed on notes/tags/type alone (commonly all empty on both sides), the+    /// redirect fired, and the draft title was written over the survivor's own+    /// manual title on every row: the Req 2.6 silent overwrite Q43 exists to+    /// prevent.+    @Test("A survivor whose manual title differs refuses the redirected Work edit")+    func workRedirectRefusesADifferingManualTitle() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let row = store.insertWork(+                id: survivor, hostname: "dup.example", title: "A Serial", offset: 0)+            // The reader renamed the survivor on the other device; everything+            // else about it is still empty.+            row.displayTitle = "The Survivor's Own Title"+            row.titleProvenanceRaw = TitleProvenance.manual.rawValue+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateWork(+            id: UUID(),+            basis: WorkEditBasis(+                displayTitle: "What This Screen Loaded", type: .other, genreTags: [],+                genericNotes: "", siteHostname: "dup.example", urlIdentity: nil,+                lastParsedTitle: "A Serial", titleProvenance: .manual),+            draft: WorkMetadataDraft(+                displayTitle: "The Reader's Rename", type: .other, genreTags: [],+                genericNotes: ""))++        guard case .conflict(.survivorDiverged(_, let survivorID)) = outcome else {+            Issue.record("expected a diverged survivor, got \(outcome)")+            return+        }+        #expect(survivorID == survivor)+        #expect(try library.workRows(id: survivor).map(\.displayTitle)+            == ["The Survivor's Own Title"])+    }++    /// A bare survivor still takes the edit — the title match must not turn the+    /// ordinary redirect into a refusal.+    @Test("A bare survivor takes a redirected Work edit including the title")+    func workRedirectAppliesToABareSurvivor() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertWork(id: survivor, hostname: "dup.example", title: "A Serial", offset: 0)+        }+        let repository = try await library.openForApp()++        let outcome = try await repository.updateWork(+            id: UUID(),+            basis: WorkEditBasis(+                displayTitle: "A Serial", type: .other, genreTags: [], genericNotes: "",+                siteHostname: "dup.example", urlIdentity: nil, lastParsedTitle: "A Serial",+                titleProvenance: .parsed),+            draft: WorkMetadataDraft(+                displayTitle: "The Reader's Rename", type: .other, genreTags: [],+                genericNotes: ""))++        #expect(outcome == .committed)+        #expect(try library.workRows(id: survivor).map(\.displayTitle)+            == ["The Reader's Rename"])+    }++    // MARK: - The session collapse map++    /// A set that collapsed twice in one session leaves a chain, and a write+    /// still addressed to the first loser has to reach the end of it.+    @Test("recordedSurvivor follows a chain of collapses to its end")+    func recordedSurvivorFollowsAChain() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        let middle = UUID()+        let first = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: survivor, hostname: "dup.example", title: "Chapter", offset: 0)+        }+        let repository = try await library.openForApp()+        await repository.recordCollapse(loser: first, survivor: middle, type: .entry)+        await repository.recordCollapse(loser: middle, survivor: survivor, type: .entry)++        #expect(await repository.recordedSurvivor(of: first, type: .entry) == survivor)+        // A record nothing collapsed reports no survivor rather than itself.+        #expect(await repository.recordedSurvivor(of: survivor, type: .entry) == nil)+        // The map is per record type: an Entry collapse says nothing about Works.+        #expect(await repository.recordedSurvivor(of: first, type: .work) == nil)++        let outcome = try await repository.updateEntry(+            id: first, basis: library.entryBasis(id: first), note: "the reader's edit",+            rating: nil)++        #expect(outcome == .committed)+        #expect(try library.entryRows(id: survivor).map(\.note) == ["the reader's edit"])+    }++    /// A cycle cannot arise from a real collapse sequence, but a lookup that+    /// walks a map must terminate whatever the map holds — an infinite loop+    /// inside a locked context would hang the app, not fail a write.+    @Test("recordedSurvivor refuses to loop on a cyclic map")+    func recordedSurvivorGuardsAgainstCycles() async throws {+        let library = try WriteFixture()+        try library.seed { store in store.insertSite(hostname: "dup.example") }+        let repository = try await library.openForApp()+        let a = UUID()+        let b = UUID()+        await repository.recordCollapse(loser: a, survivor: b, type: .entry)+        await repository.recordCollapse(loser: b, survivor: a, type: .entry)++        #expect(await repository.recordedSurvivor(of: a, type: .entry) == nil)+    }++    // MARK: - Idempotence (Req 2.4)++    /// The redirect is a read of store state, so running it twice over the same+    /// content has to give the same answer and write nothing the second time+    /// beyond the edit itself. A redirect that consumed something — a map entry,+    /// a candidate — would make the second pass behave differently from the+    /// first.+    @Test("A repeated redirected write lands on the same survivor")+    func repeatedRedirectIsStable() async throws {+        let library = try WriteFixture()+        let survivor = UUID()+        let loser = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: survivor, hostname: "dup.example", title: "Chapter", offset: 0)+        }+        let repository = try await library.openForApp()+        await repository.recordCollapse(loser: loser, survivor: survivor, type: .entry)++        let first = try await repository.updateEntry(+            id: loser, basis: library.entryBasis(id: loser), note: "the reader's edit",+            rating: nil)+        let second = try await repository.updateEntry(+            id: loser,+            basis: EntryEditBasis(+                note: "the reader's edit", rating: nil, hostname: "dup.example",+                conservativeIdentityKey: library.identityKey),+            note: "the reader's edit", rating: nil)++        #expect(first == .committed)+        #expect(second == .committed)+        #expect(try library.entryRows(id: survivor).map(\.note) == ["the reader's edit"])+        #expect(try library.entryRows(id: loser).isEmpty)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/LogicalRecordCaptureTests.swift Added +370 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LogicalRecordCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LogicalRecordCaptureTests.swiftnew file mode 100644index 0000000..378827b--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LogicalRecordCaptureTests.swift@@ -0,0 +1,370 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Req 7: a share whose identity lookup lands on an unresolved duplicate set+/// must just save.+///+/// The lookup used to count *rows*, so two rows of one identity group — one+/// Entry materialised twice — read as an ambiguous match and dead-ended the+/// sheet: Save disabled, the reader's note refused. It counts logical records+/// now, and there is no ambiguous state left to reach.+@Suite("Capture and re-share over logical records", .serialized)+struct LogicalRecordCaptureTests {++    // MARK: - Lookup disposition (Req 7.1, 7.3)++    @Test("A split group that is not torn is one logical record and offers editing")+    func splitGroupResolvesToEdit() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            first.note = "one note"+            let second = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            second.note = "one note"+        }+        let repository = try await library.openForApp()++        let disposition = try await repository.captureLookup(rawURL: library.identityKey)++        guard case .edit(let basis) = disposition else {+            Issue.record("expected an edit disposition, got \(disposition)")+            return+        }+        #expect(basis.entryID == shared)+        // The basis reports the group's content and its member timestamps, not+        // one row's (Q41): a bare row's empty note must never stand in for the+        // variant the reader would be editing.+        #expect(basis.persistedNote == "one note")+        #expect(basis.firstCapturedAt == WriteFixture.epoch)+    }++    @Test("A mixed bare-and-authored group offers the authored variant, not the bare row")+    func mixedGroupOffersTheVariant() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            let authored = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            authored.note = "the only note"+        }+        let repository = try await library.openForApp()++        guard case .edit(let basis) = try await repository.captureLookup(+            rawURL: library.identityKey)+        else {+            Issue.record("expected an edit disposition")+            return+        }+        #expect(basis.persistedNote == "the only note")+    }++    @Test("A torn group behaves as a new capture")+    func tornGroupBehavesAsNew() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            first.note = "device one"+            let second = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            second.note = "device two"+        }+        let repository = try await library.openForApp()++        guard case .new = try await repository.captureLookup(rawURL: library.identityKey) else {+            Issue.record("expected a new disposition for a torn group")+            return+        }+    }++    @Test("Two distinct logical records behave as a new capture")+    func twoLogicalRecordsBehaveAsNew() async throws {+        let library = try WriteFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(hostname: "dup.example", title: "Chapter A", offset: 0)+            store.insertEntry(hostname: "dup.example", title: "Chapter B", offset: 30)+        }+        let repository = try await library.openForApp()++        guard case .new = try await repository.captureLookup(rawURL: library.identityKey) else {+            Issue.record("expected a new disposition for two logical records")+            return+        }+    }++    // MARK: - Saving from a new capture (Req 7.1's write half)++    /// The sheet behaving as a new capture is only half of Req 7.1; the other+    /// half is that saving from it **creates an Entry**. The share extension+    /// commits with the lookup-to-commit race guard set (Q27), and that guard+    /// fires on any Entry sharing the key — including the very duplicate set+    /// that sent the sheet to `.new`. Refusing there refuses the reader's note,+    /// which is the M1 guarantee this milestone exists to restore.+    @Test("Saving from a torn group's new capture creates an Entry")+    func newCaptureOverATornGroupSaves() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let first = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            first.note = "device one"+            let second = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            second.note = "device two"+        }+        let repository = try await library.openForApp()++        guard case .new(let lookup) = try await repository.captureLookup(+            rawURL: library.identityKey)+        else {+            Issue.record("expected a new disposition for a torn group")+            return+        }++        let outcome = try await repository.commitCapture(+            try await library.guardedCaptureContract(+                repository: repository, lookup: lookup, note: "the reader's note"))++        guard case .committed(let entry) = outcome else {+            Issue.record("expected a committed capture, got \(outcome)")+            return+        }+        #expect(entry.note == "the reader's note")+        #expect(entry.id != shared)+        // Req 7.2: the new Entry shares the conservative key, so the next pass+        // evaluates it as a member of the same set.+        #expect(entry.conservativeIdentityKey == library.identityKey)+    }++    /// Two distinct logical records take the same route: more than one match is+    /// not a race the guard should refuse, it is the unresolved set the lookup+    /// already saw.+    @Test("Saving from a multi-record new capture creates an Entry")+    func newCaptureOverTwoLogicalRecordsSaves() async throws {+        let library = try WriteFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(hostname: "dup.example", title: "Chapter A", offset: 0)+            store.insertEntry(hostname: "dup.example", title: "Chapter B", offset: 30)+        }+        let repository = try await library.openForApp()++        guard case .new(let lookup) = try await repository.captureLookup(+            rawURL: library.identityKey)+        else {+            Issue.record("expected a new disposition for two logical records")+            return+        }++        let outcome = try await repository.commitCapture(+            try await library.guardedCaptureContract(+                repository: repository, lookup: lookup, note: "the reader's note"))++        guard case .committed = outcome else {+            Issue.record("expected a committed capture, got \(outcome)")+            return+        }+        #expect(try library.allEntryRows().count == 3)+    }++    // MARK: - Req 2.7: the capture path's Work assignment++    /// `applyCaptureAssignment` wrote **one** Work row while every sibling+    /// derived path fanned out (Q80's fix, one path over). Two of the values it+    /// writes are the Work duplicate-set bucket key itself — `urlIdentity` and+    /// `lastParsedTitle` (§2.4) — so rows left disagreeing connect unrelated+    /// Works into one set for the union–find, and `modifiedAt` is the last slot+    /// of the representative ordering (Q74). Q97 accepts a group that *arrived*+    /// in that state; this path created it.+    @Test("A capture assigning to a split Work group writes every row")+    func captureAssignmentFansOutAcrossTheWorkGroup() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            let site = store.insertSite(hostname: "dup.example")+            site.mode = .taught+            try store.insertTitlePattern(site: site, isActive: true)+            // One Work materialised twice. Their `modifiedAt` differ, which is+            // what a fan-out has to level.+            let first = store.insertWork(+                id: shared, hostname: "dup.example", title: "The Serial", offset: 0)+            let second = store.insertWork(+                id: shared, hostname: "dup.example", title: "The Serial", offset: 30)+            first.site = site+            second.site = site+        }+        let repository = try await library.openForApp()++        let projected = try await repository.projectCapture(+            hostname: "dup.example", captureTitle: "The Serial - Chapter 1",+            captureTitleSource: .safariDocument, rawURLString: library.identityKey,+            canonicalURLString: nil, note: "the reader's note", rating: nil)+        let outcome = try await repository.commitCapture(+            CaptureContract(+                basis: projected.basis,+                request: CaptureRequest(+                    captureTitle: "The Serial - Chapter 1", captureTitleSource: .safariDocument,+                    rawURLString: library.identityKey, canonicalURLString: nil,+                    note: "the reader's note", rating: nil, raceGuardKeys: nil),+                outcome: projected.outcome))++        guard case .committed(let entry) = outcome else {+            Issue.record("expected a committed capture, got \(outcome)")+            return+        }+        #expect(entry.workID == shared)++        let rows = try library.workRows(id: shared)+        #expect(rows.count == 2)+        #expect(Set(rows.map(\.lastParsedTitle)) == ["The Serial"])+        #expect(+            Set(rows.map(\.modifiedAt)).count == 1,+            "Q74: a group's rows must not be left differing in modifiedAt")+    }++    /// The guard still does its own job: a capture that genuinely raced the+    /// lookup into a single editable record resolves to that record rather than+    /// inserting a duplicate (Q27).+    @Test("A capture that raced the lookup into one record still resolves to edit")+    func raceGuardStillCatchesASingleMatch() async throws {+        let library = try WriteFixture()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+        }+        let repository = try await library.openForApp()++        guard case .new(let lookup) = try await repository.captureLookup(+            rawURL: library.identityKey)+        else {+            Issue.record("expected a new disposition for an empty library")+            return+        }+        let contract = try await library.guardedCaptureContract(+            repository: repository, lookup: lookup, note: "the reader's note")++        // The racing share lands first.+        try library.seedAdditional { store in+            store.insertEntry(hostname: "dup.example", title: "Chapter", offset: 0)+        }+        let reopened = try await library.openForApp()++        let outcome = try await reopened.commitCapture(contract)++        guard case .raced(.edit) = outcome else {+            Issue.record("expected a raced edit disposition, got \(outcome)")+            return+        }+        #expect(try library.allEntryRows().count == 1)+    }++    // MARK: - Re-share commit (Req 7.3, 2.7, 2.8)++    @Test("A re-share update writes every row of the group")+    func reShareFansOut() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+        }+        let repository = try await library.openForApp()++        guard case .edit(let basis) = try await repository.captureLookup(+            rawURL: library.identityKey)+        else {+            Issue.record("expected an edit disposition")+            return+        }+        let outcome = try await repository.commitReShareUpdate(+            basis: basis, note: "re-shared", rating: .up)++        #expect(outcome == .committed)+        let rows = try library.entryRows(id: shared)+        #expect(rows.count == 2)+        #expect(rows.allSatisfy { $0.note == "re-shared" })+        #expect(rows.allSatisfy { $0.rating == .up })+    }++    @Test("A re-share update against a torn group is invalidated, writing nothing")+    func reShareRefusesTornGroup() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            let held = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+            held.note = "written here"+        }+        let repository = try await library.openForApp()+        guard case .edit(let basis) = try await repository.captureLookup(+            rawURL: library.identityKey)+        else {+            Issue.record("expected an edit disposition")+            return+        }++        // A second copy carrying different prose arrives before the commit. The+        // reopened repository is the offline stand-in for the arrival landing in+        // a context the commit will read.+        try library.seedAdditional { store in+            let arrived = store.insertEntry(+                id: shared, hostname: "dup.example", title: "Chapter", offset: 30)+            arrived.note = "arrived from the phone"+        }+        let reopened = try await library.openForApp()++        let outcome = try await reopened.commitReShareUpdate(+            basis: basis, note: "re-shared", rating: nil)++        guard case .invalidated = outcome else {+            Issue.record("expected an invalidated outcome, got \(outcome)")+            return+        }+        #expect(try library.entryRows(id: shared).allSatisfy { $0.note != "re-shared" })+    }++    /// Req 4.8's staleness compare now reads the group's variants, not one row's+    /// note: a copy carrying different prose changes what the reader would be+    /// overwriting even when the row the basis named is untouched.+    @Test("A baseline that no longer matches the group's content goes stale")+    func reShareGoesStaleOnChangedContent() async throws {+        let library = try WriteFixture()+        let shared = UUID()+        try library.seed { store in+            store.insertSite(hostname: "dup.example")+            store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 0)+        }+        let repository = try await library.openForApp()+        guard case .edit(let basis) = try await repository.captureLookup(+            rawURL: library.identityKey)+        else {+            Issue.record("expected an edit disposition")+            return+        }+        try await repository.updateEntry(+            id: shared, note: "a curation edit landed first", rating: nil)++        let outcome = try await repository.commitReShareUpdate(+            basis: basis, note: "re-shared", rating: nil)++        guard case .stale(let refreshed) = outcome else {+            Issue.record("expected a stale outcome, got \(outcome)")+            return+        }+        #expect(refreshed.persistedNote == "a curation edit landed first")+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift Added +362 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swiftnew file mode 100644index 0000000..e646b0a--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift@@ -0,0 +1,362 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// MARK: - Req 10.1 / 10.2 — what duplicate reconciliation costs at M4 scale++/// The duplicate milestone's own scale measurements, in the house style of+/// `M4ToleratedScalePerformanceTests` next door: `median` asserted every run,+/// `p95` only under `CONTROLLED=1`, the whole distribution reported either way.+/// Run with `make test-performance-m4`.+///+/// **Two requirements, two fixtures, and the fixtures are the point.**+///+/// - Req 10.1 measures the pass over a library that *does* hold duplicates —+///   250 silently resolvable Entry sets, 50 Work sets of two Works with five+///   Entries each, and 10 rule identity groups, over the 5,000-Entry composed+///   fixture. The measured pass is the **second** one, the one Req 2.3's+///   settling rule finally lets delete.+/// - Req 10.2 measures the paths the added detection joined, over a library that+///   holds **no** duplicates at all, which is the condition the requirement+///   states. Those paths are not one measurement (see the suite's own note on+///   `readPathsOverDuplicateFreeLibrary`).+///+/// **Nothing here is comparable to a device.** The `AsterismCore` package test+/// target is in no scheme's test action (Decision 10 of+/// `library-integrity-tolerance`), so every number below is host-only and+/// comparable to a later run of the same command on the same machine and to+/// nothing else. Bands and medians are recorded in+/// `specs/duplicate-reconciliation/implementation.md`.+@Suite(+    "M4 duplicate-reconciliation scale budgets", .serialized,+    .enabled(if: ProcessInfo.processInfo.environment["ASTERISM_RUN_PHYSICAL_PERFORMANCE"] == "1"))+struct M4DuplicateScalePerformanceTests {+    /// Req 10.1's own budget. **Breached, and asserted inside `withKnownIssue`+    /// for it** — see `requirement101KnownIssue`.+    private let settlingPassBudget = Duration.seconds(2)+    /// The regression floor under the breach, asserted *outside* the known-issue+    /// block, in the shape `M4ToleratedScalePerformanceTests` established for+    /// Req 5.5. `withKnownIssue` swallows 9.1 s and 91 s alike, which is exactly+    /// the "assert nothing and record the number" property the house discipline+    /// rejects; this refuses a run that has drifted far enough to be a *new*+    /// problem rather than the recorded one. It sits above the measured+    /// **7.264–7.365 s** (medians over three host release runs after+    /// Decision 29's chunking; it was 8.861–9.080 s before) with room for noise+    /// and well under a doubling. Moving it up to make a run pass would give the+    /// test back the property it exists to remove.+    private let settlingPassCeiling = Duration.seconds(11)+    /// The observation pass is inside its budget and asserted plainly.+    private let observationPassBudget = Duration.seconds(2)+    /// The publication budget the Recent path already carries+    /// (`library-integrity-tolerance` Req 5.2/5.3).+    private let recentPublishBudget = Duration.seconds(2)+    /// The read paths with no recorded baseline of their own: a floor, not a+    /// bound. **Widened from a pre-measurement guess of 2 s** once the first run+    /// measured `works()` at 1.73 s — 2 s would have been 1.16× a measured+    /// median, which is not the "generous hard ceiling" Q65 asks for but a+    /// second flaky assertion. `works()` now measures 1.426–1.498 s, and the+    /// ceiling is left where it is: it bounds a class of read path, not this+    /// one path's current number. The recorded band in `implementation.md` is the+    /// number a later run compares against; this only refuses a different order+    /// of magnitude.+    private let readPathCeiling = Duration.seconds(3)++    /// **Req 10.1 does not hold, and this suite records that rather than hiding+    /// it or deleting the budget.**+    ///+    /// Measured on an M1 Max in release over the 5,000-Entry fixture seeded with+    /// 250 Entry sets, 50 Work sets and 10 rule groups: the settling pass —+    /// Req 10.1's second pass, the one that performs the deletions — measures+    /// **7.264–7.365 s against a 2 s budget** (medians over three runs), with a+    /// min-to-max spread of ≤ 1.04×. It is a measurement, not a hiccup. The+    /// *observation* pass beside it, which does all the writing, is 0.93–0.95 s+    /// and inside budget.+    ///+    /// **The first cost model was wrong, and the measurement that replaced it+    /// says so.** Task 22 attributed the breach to `commitDeletions` running one+    /// `saveStrategy.save(context)` per set — 300 collapses, 300 saves, "roughly+    /// 30 ms each". Decision 29 chunked those saves, and the pass came down from+    /// 8.861–9.080 s to 7.264–7.365 s: the transaction count was worth ~1.6 s,+    /// not ~7 s. Whatever the remaining ~7 s is, it is **not** the number of+    /// transactions, and the next attempt should profile rather than reason from+    /// the shape of the code — which is what the first attempt did.+    ///+    /// Req 10.1's 2 s over 300 sets allows 6.7 ms per set; the measurement is+    /// ~24 ms. Closing that is a design decision — raise the budget, bound the+    /// sets per pass, or find the real cost — and a breach recorded inside a+    /// measurement task does not authorise taking it. Routed the way+    /// `cloudkit-mirroring` routed its two (Q55): recorded, loud, and left for+    /// the design owner. See Decision 27.+    ///+    /// `isIntermittent` is deliberately **not** set: this is 3.7× its budget,+    /// not 11% over it, and no quiet run is going to dip under 2 s.+    private static let requirement101KnownIssue: Comment = """+        Req 10.1 (2 s) is exceeded on the host at 7.26-7.37 s, down from \+        8.86-9.08 s once Decision 29 chunked the deletion saves. The remaining \+        cost is not the transaction count and is unattributed. Host-only \+        measurement. See the comment above this test, Decision 27, and \+        implementation.md.+        """++    /// Ten samples, not twenty. Every sample of the settling pass needs its own+    /// generation of duplicate rows — 1,350 rows deleted and re-seeded — plus a+    /// full untimed observation pass in front of the timed one, so a sample+    /// costs roughly three times what it measures. Twenty would add minutes to a+    /// target that already takes about half an hour and buys a percentile the+    /// suite does not assert off-`CONTROLLED`.+    private let settlingSampleCount = 10+    private let readSamples = 20++    private var seededSetCount: Int {+        LibraryRepository.m4FixtureDuplicateEntrySetCount+            + LibraryRepository.m4FixtureDuplicateWorkSetCount+    }++    // MARK: - Req 10.1 — the settling pass++    @Test("The settling pass over 250 Entry, 50 Work and 10 rule sets ≤ 2 s (Req 10.1)")+    func settlingPassOverSeededDuplicates() async throws {+        let store = try await M4DuplicatePerformanceStore(state: .duplicateSets)+        let repository = try await store.openApp()++        var settlingSamples: [Duration] = []+        var observationSamples: [Duration] = []+        let clock = ContinuousClock()+        // One extra generation as the warm-up, matching `measureDistributionAsync`.+        for generation in 0...settlingSampleCount {+            if generation > 0 {+                try await repository.reseedM4DuplicateSets(generation: generation)+            }++            // **The first pass is timed too, and it is not the requirement's+            // number.** Req 2.3 forbids a first observation from deleting+            // anything, but it does not forbid it from writing: the survivor's+            // outcome content and every Entry of a losing Work move here, so+            // that Req 2.1 has them committed before a deletion can exist. Pass 1+            // is therefore the write-heavy half and pass 2 the deletion-heavy+            // one, and quoting only one of them would understate what resolving+            // a set costs end to end.+            let observationStart = clock.now+            let observation = try await repository.reconcileAfterSync()+            let observationElapsed = clock.now - observationStart+            #expect(+                observation.duplicates.collapsedMembers == 0,+                "generation \(generation): the first pass must not delete (Req 2.3)")+            #expect(observation.duplicates.settlingSetKeys.count == seededSetCount)+            #expect(+                observation.duplicates.movedEntries+                    == LibraryRepository.m4FixtureDuplicateWorkSetCount+                    * LibraryRepository.m4FixtureDuplicateEntriesPerWork,+                "generation \(generation): every Entry of a collapsing Work moves here (Req 5.2)")++            let start = clock.now+            let settling = try await repository.reconcileAfterSync()+            let elapsed = clock.now - start++            // The measurement is only Req 10.1's if the pass actually resolved+            // the sets. A pass that silently collapsed nothing would be a fast+            // number about the wrong thing.+            #expect(+                settling.duplicates.collapsedMembers == seededSetCount,+                "generation \(generation): the settling pass must collapse every seeded set")++            if generation > 0 {+                observationSamples.append(observationElapsed)+                settlingSamples.append(elapsed)+            }+        }++        let measured = PerformanceDistribution(settlingSamples)+        withKnownIssue(Self.requirement101KnownIssue) {+            expectWithinBudget("duplicate-settling-pass", measured, settlingPassBudget)+        }+        expectWithinCeiling("duplicate-settling-pass", measured, settlingPassCeiling)++        // Recorded beside it, and asserted plainly: a pass that met 2 s only by+        // having deferred half its work to the pass before it would satisfy the+        // letter of Req 10.1 and nothing else. This one is inside its budget.+        let observed = PerformanceDistribution(observationSamples)+        expectWithinBudget("duplicate-observation-pass", observed, observationPassBudget)++        // Q98, pinned rather than argued: the arrival gate stays open on this+        // library for the rest of the session, because the 10 converged rule+        // groups keep the candidate count non-zero and Decision 4 never deletes+        // them. The pass they run writes nothing, and what that costs is the+        // number just measured.+        try await repository.refreshDiagnostics()+        let arrival = try await repository.reconcileAfterSync(tier: .arrival)+        #expect(+            arrival.duplicatePhaseRan,+            "a library holding converged rule groups keeps the arrival gate open (Q98)")+    }++    // MARK: - Req 10.2 — the added detection over a duplicate-free library++    /// The arrival-gate seam counter (Q53/Q58), which is the reason the added+    /// detection costs an ordinary sync arrival nothing at all.+    ///+    /// This is the assertion Req 10.2 rests on for the debounce path: not that+    /// the phase is fast, but that it does not run.+    @Test("A debounce pass over a duplicate-free library runs no duplicate phase (Req 10.2)")+    func arrivalGateOverDuplicateFreeLibrary() async throws {+        let store = try await M4DuplicatePerformanceStore(state: nil)+        let repository = try await store.openApp()++        // The gate reads the last tolerance scan, which is what every arrival+        // caller runs immediately after reconciling.+        try await repository.refreshDiagnostics()++        let outcome = try await repository.reconcileAfterSync(tier: .arrival)+        #expect(+            !outcome.duplicatePhaseRan,+            "the arrival tier must decline the duplicate phase on a library with no candidates")++        // Informational: what a debounce pass costs with the gate closed. No+        // budget — `reconcileAfterSync` is on no interactive path (Q45 of+        // `cloudkit-mirroring`) — but a number here is what would show a future+        // change putting the whole-library walk back on every arrival.+        let measured = try await measureDistributionAsync(iterations: readSamples) {+            _ = try await repository.reconcileAfterSync(tier: .arrival)+        }+        reportPerformance("duplicate-arrival-pass-gated", measured)+        expectWithinCeiling("duplicate-arrival-pass-gated", measured, readPathCeiling)++        // Still declined after 20 more passes: the gate is a function of the+        // library, not of how many times it has been asked.+        #expect(await repository.duplicatePhaseSkipped)+    }++    /// **Req 10.2 is not one measurement, and it is not three either — it is two+    /// named baselines plus the paths this milestone put detection on.**+    ///+    /// The requirement names two: *diagnosis refresh* and *capture projection*.+    /// Both are re-measured under their own fixture preconditions by+    /// `M4ToleratedScalePerformanceTests`, which already owns them; duplicating+    /// them here would produce a second number for one baseline and invite the+    /// two to disagree. What it does **not** name, and what this milestone+    /// nevertheless changed, is three read paths:+    ///+    /// - `recentPresentation` now runs a full `DuplicateScan.run` on every+    ///   publication, beside the `LibraryToleranceScan` it already ran;+    /// - `works()` reads the whole Entry table rather than a `work == nil`+    ///   predicate (Q104), because "unattached" is a property of the logical+    ///   record;+    /// - `recordCounts()` replaced five SQL `fetchCount`s with four+    ///   `context.enumerate` walks, because a count is now a count of logical+    ///   records.+    ///+    /// Only the first of those has a recorded M4-family band to compare against+    /// (0.686–0.713 s host). The other two are new baselines, recorded here so a+    /// later run has something to be compared with, and asserted against a+    /// ceiling rather than a bound they never had.+    @Test("Recent, works and record counts over a duplicate-free library (Req 10.2)")+    func readPathsOverDuplicateFreeLibrary() async throws {+        let store = try await M4DuplicatePerformanceStore(state: nil)+        let repository = try await store.openApp()+        let calendar = Calendar.current++        let recent = try await measureDistributionAsync(iterations: readSamples) {+            _ = try await repository.recentPresentation(calendar: calendar)+        }+        expectWithinBudget("recent-publication-duplicate-free", recent, recentPublishBudget)++        let works = try await measureDistributionAsync(iterations: readSamples) {+            _ = try await repository.works()+        }+        reportPerformance("works-snapshot-duplicate-free", works)+        expectWithinCeiling("works-snapshot-duplicate-free", works, readPathCeiling)++        let counts = try await measureDistributionAsync(iterations: readSamples) {+            _ = try await repository.recordCounts()+        }+        reportPerformance("record-counts-duplicate-free", counts)+        expectWithinCeiling("record-counts-duplicate-free", counts, readPathCeiling)+    }++    /// Q116, measured rather than asserted. The export projection now buckets+    /// both whole tables and builds a logical record — and therefore a variant+    /// digest per non-bare row — for *every* Entry and Work rather than only for+    /// duplicated ones. Q116 accepted that on the grounds that export is on no+    /// recorded budget and already encodes the whole library to JSON; this+    /// records what the projection alone costs so the claim is a reading rather+    /// than an argument.+    ///+    /// The *projection* is timed, not `BackupV4Exporter.export`: the encode,+    /// the decode-validation and the file write dominate and none of them+    /// changed.+    @Test("Backup projection over a duplicate-free library (Q116, informational)")+    func backupProjectionOverDuplicateFreeLibrary() async throws {+        let store = try await M4DuplicatePerformanceStore(state: nil)+        let repository = try await store.openApp()++        let measured = try await measureDistributionAsync(iterations: 5) {+            _ = try await repository.backupV4Snapshot()+        }+        reportPerformance("backup-projection-duplicate-free", measured)+    }++    // MARK: - Helpers++    /// The regression floor beside a budget, in the shape+    /// `M4ToleratedScalePerformanceTests.expectWithinCeiling` established: a+    /// bound generous enough that measurement noise cannot fire it, so that a+    /// failure here is a statement about the code.+    private func expectWithinCeiling(+        _ label: String,+        _ measured: PerformanceDistribution,+        _ ceiling: Duration,+        sourceLocation: SourceLocation = #_sourceLocation+    ) {+        #expect(+            measured.median <= ceiling,+            """+            \(label) median \(measured.median) exceeded the \(ceiling) regression \+            ceiling (p95 \(measured.p95), spread \(measured.spread)x) — this is a \+            floor, not a budget; something has made the path materially slower+            """,+            sourceLocation: sourceLocation)+    }+}++// MARK: - Fixture++/// The 5,000-Entry composed fixture on disk, optionally perturbed into+/// `.duplicateSets`, certified ready and reopened the way the app opens it.+///+/// A copy of `M4ToleratedScalePerformanceTests`'s private store rather than a+/// shared one: the two suites seed different shapes and the seeding repository+/// has to be released before anything is measured in both, which is the only+/// property either of them shares.+private final class M4DuplicatePerformanceStore {+    let root: URL+    let configuration: LibraryConfiguration++    init(state: M4ToleratedFixtureState?) async throws {+        root = FileManager.default.temporaryDirectory+            .appending(+                path: "asterism-m4-duplicate-perf-\(UUID().uuidString)", directoryHint: .isDirectory)+        configuration = LibraryConfiguration(rootDirectory: root)+        try FileManager.default.createDirectory(+            at: configuration.v4StoreURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)++        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let seeder = LibraryRepository.makeRepository(+            configuration, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy())+        try await seeder.seedM4PerformanceFixture(toleratedState: state)+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)+        withExtendedLifetime(container) {}+    }++    func openApp() async throws -> LibraryRepository {+        let (_, repository) = try await LibraryRepository.openV4ForApp(+            configuration, capabilities: .m4)+        return repository+    }++    deinit {+        try? FileManager.default.removeItem(at: root)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcileAfterSyncTests.swift Added +338 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcileAfterSyncTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcileAfterSyncTests.swiftnew file mode 100644index 0000000..35e0449--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcileAfterSyncTests.swift@@ -0,0 +1,338 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// `reconcileAfterSync()` as the *scheduler* uses it: Reqs 1.2–1.4 and the+/// tiers, gate and latch of Q53/Q58/Q62.+///+/// `DuplicateReconcilerTests` hands the phase a store and asserts what it+/// writes. What is under test here is when the phase runs at all, what it does+/// with the deletions it earned, and who is told to run it again.+@Suite("Duplicate reconciliation after arrivals", .serialized)+struct DuplicateReconcileAfterSyncTests {+    private static let hostname = "duplicating.example"+    private static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    // MARK: - Req 1.2: the phase runs inside the pass, after the Site phases++    @Test("A duplicate set that arrived after the last refresh is resolved anyway")+    func theWorkListIsDerivedNotRemembered() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)+        try await repository.refreshDiagnostics()++        // Sync delivers the twin. Nothing has refreshed since, which is exactly+        // the position `handleSyncArrivals` is in when it calls this.+        try await repository.seedDuplicateEntryPair(hostname: Self.hostname)++        let first = try await repository.reconcileAfterSync()+        #expect(first.duplicatePhaseRan)+        // Req 2.3: the first observation writes, and does not delete.+        #expect(first.duplicates.collapsedMembers == 0)+        #expect(first.duplicates.followUpNeeded)+        #expect(try await repository.entryCountForTesting() == 2)++        let second = try await repository.reconcileAfterSync()+        #expect(second.duplicates.collapsedMembers == 1)+        #expect(try await repository.entryCountForTesting() == 1)+        #expect(try await repository.entryNoteForTesting() == "the note")+    }++    @Test("A library with no duplicates reconciles to nothing")+    func aCleanLibraryIsANoOp() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)++        let outcome = try await repository.reconcileAfterSync()++        #expect(outcome.isEmpty)+        #expect(outcome.duplicates.isEmpty)+    }++    // MARK: - Q53/Q58: the arrival-tier gate++    @Test("An arrival pass over a duplicate-free library does not run the duplicate phase")+    func theArrivalTierGatesOnTheLastScan() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)+        try await repository.seedPlainEntry(hostname: Self.hostname)+        try await repository.refreshDiagnostics()++        let outcome = try await repository.reconcileAfterSync(tier: .arrival)++        #expect(!outcome.duplicatePhaseRan, "an arrival paid for a whole-library walk")+    }++    @Test("An arrival pass runs the phase once the refresh's scan has seen candidates")+    func theGateOpensOnTheScan() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)+        try await repository.seedDuplicateEntryPair(hostname: Self.hostname)+        try await repository.refreshDiagnostics()++        let outcome = try await repository.reconcileAfterSync(tier: .arrival)++        #expect(outcome.duplicatePhaseRan)+    }++    /// Q58's post-refresh latch. `handleSyncArrivals` reconciles *before* it+    /// refreshes, so the gate reads the previous refresh's scan — and a set+    /// landing in a hydration's final batch misses the pass that was supposed to+    /// see it. The refresh that follows is what notices and re-arms.+    @Test("A set arriving in a hydration's final batch re-arms the follow-up at the next refresh")+    func theFinalBatchIsCaughtByThePostRefreshLatch() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)+        try await repository.refreshDiagnostics()++        // The gated pass runs against a library the scan called clean, and the+        // twin lands during it.+        let outcome = try await repository.reconcileAfterSync(tier: .arrival)+        #expect(!outcome.duplicatePhaseRan)+        try await repository.seedDuplicateEntryPair(hostname: Self.hostname)++        try await repository.refreshDiagnostics()++        #expect(await repository.takeDuplicateFollowUp())+        // Consumed, not read: one deferral schedules one follow-up.+        #expect(await repository.takeDuplicateFollowUp() == false)+    }++    // MARK: - Decision 30: the full tier gates too++    /// The session's first pass is unconditional — nothing has scanned yet — and+    /// every pass after it consults the same counters the arrival tier does. The+    /// full tier used to be unconditionally true, which walked four tables on+    /// every launch, import and reader-action pass over a library with no+    /// duplicates in it (T-2092).+    @Test("A second full pass over a coherent library does not run the duplicate phase")+    func theFullTierGatesAfterTheSessionsFirstPass() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)+        try await repository.seedPlainEntry(hostname: Self.hostname)++        let first = try await repository.reconcileAfterSync()+        #expect(first.duplicatePhaseRan, "the session's first pass must not be gated")++        try await repository.refreshDiagnostics()+        let second = try await repository.reconcileAfterSync()++        #expect(!second.duplicatePhaseRan, "a full pass paid for a whole-library walk")+    }++    /// The re-arm the gate relies on. A set that lands between the refresh a+    /// full-tier caller ran and the pass it then scheduled is missed by that+    /// pass — and the next refresh that reports candidates arms the follow-up,+    /// so it converges one trigger later rather than being lost.+    @Test("A set arriving after a gated full pass still converges")+    func aSetArrivingAfterAGatedFullPassConverges() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)+        // A different hostname, so the twins seeded below are a set of two.+        try await repository.seedPlainEntry(hostname: "coherent.example")+        _ = try await repository.reconcileAfterSync()+        try await repository.refreshDiagnostics()++        // Gated: the scan before it saw nothing. The twin lands afterwards.+        let gated = try await repository.reconcileAfterSync()+        #expect(!gated.duplicatePhaseRan)+        try await repository.seedDuplicateEntryPair(hostname: Self.hostname)++        // The refresh every trigger runs notices, and arms the follow-up.+        try await repository.refreshDiagnostics()+        #expect(await repository.takeDuplicateFollowUp())++        // And the passes that follow resolve it, exactly as an ungated pair would.+        let observing = try await repository.reconcileAfterSync()+        #expect(observing.duplicatePhaseRan)+        #expect(observing.duplicates.collapsedMembers == 0)+        let settling = try await repository.reconcileAfterSync()+        #expect(settling.duplicates.collapsedMembers == 1)+        // The unrelated Entry, plus the set's survivor.+        #expect(try await repository.entryCountForTesting() == 2)+    }++    // MARK: - Req 1.3/Q62: what latches the follow-up, and what never does++    @Test("A Req 1.6 blockage never latches the follow-up")+    func blockagesNeverLatch() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)+        try await repository.seedDeferredEntrySet(hostname: Self.hostname)++        let outcome = try await repository.reconcileAfterSync()++        #expect(outcome.duplicates.blockedSetKeys.count == 1)+        #expect(outcome.duplicates.settlingSetKeys.isEmpty)+        #expect(await repository.takeDuplicateFollowUp() == false)+    }++    @Test("A pass deferred by a bulk operation leaves the latch untouched")+    func theBulkGuardLeavesTheLatchAlone() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)+        try await repository.seedDuplicateEntryPair(hostname: Self.hostname)+        _ = try await repository.reconcileAfterSync()+        #expect(await repository.duplicateFollowUpNeededForTesting)++        await repository.setBulkOperationInProgressForTesting(true)+        let deferred = try await repository.reconcileAfterSync()+        await repository.setBulkOperationInProgressForTesting(false)++        #expect(deferred.isEmpty)+        #expect(await repository.duplicateFollowUpNeededForTesting, "a deferred pass ate the latch")+    }++    // MARK: - Req 1.4: never on the capture path++    /// The extension's whole lifecycle is open-then-write, so "reconciliation+    /// never runs during capture" is a statement about its open. Nothing on that+    /// path may reconcile: the set is still there afterwards and the session+    /// ledger has never observed it.+    @Test("Opening for the share extension never reconciles duplicates")+    func theExtensionNeverReconciles() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, app) = try await LibraryRepository.openV4ForApp(env.configuration)+        try await app.seedDuplicateEntryPair(hostname: Self.hostname)+        await app.shutdown()++        let (_, extensionRepository) = try await LibraryRepository.openV4ForExtension(+            env.configuration)++        #expect(await extensionRepository.duplicateLedgerCountForTesting == 0)+        #expect(try await extensionRepository.entryCountForTesting() == 2)+    }++    // MARK: - Req 2.10: the collapse is recorded for the redirect++    @Test("A collapse records the loser-to-survivor mapping the redirect reads")+    func collapsesAreRecordedForTheRedirect() async throws {+        let env = try DuplicateReconcileEnvironment()+        let (_, repository) = try await LibraryRepository.openV4ForApp(env.configuration)+        let ids = try await repository.seedDuplicateEntryPair(hostname: Self.hostname)++        _ = try await repository.reconcileAfterSync()+        _ = try await repository.reconcileAfterSync()++        #expect(await repository.recordedSurvivor(of: ids.loser, type: .entry) == ids.survivor)+    }+}++// MARK: - Environment++private struct DuplicateReconcileEnvironment {+    let directory: URL+    let configuration: LibraryConfiguration++    init() throws {+        directory = FileManager.default.temporaryDirectory.appending(+            path: "DuplicateReconcileAfterSyncTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+    }+}++// MARK: - Repository probes++extension LibraryRepository {+    private static var probeEpoch: Date { Date(timeIntervalSince1970: 1_800_000_000) }++    /// Two Entries of one chapter with distinct UUIDs — a silently resolvable+    /// set, one member bare and one carrying a note (Decision 1's common shape).+    @discardableResult+    fileprivate func seedDuplicateEntryPair(+        hostname: String+    ) async throws -> (survivor: UUID, loser: UUID) {+        let survivor = UUID(uuidString: "00000000-0000-4000-8000-000000000001")!+        let loser = UUID(uuidString: "00000000-0000-4000-8000-000000000002")!+        try await withLockedContext(mode: .exclusive, operation: "seeding a duplicate pair") {+            context in+            let site = Site(hostname: hostname, displayName: hostname)+            context.insert(site)+            for (id, offset, note) in [(survivor, 0.0, ""), (loser, 100.0, "the note")] {+                let entry = Entry(+                    id: id, captureTitle: "Chapter 1", captureTitleSource: .host,+                    rawURLString: "https://\(hostname)/chapter-1", hostname: hostname,+                    entryIdentityKey: "chapter-1",+                    timestamp: Self.probeEpoch.addingTimeInterval(offset), note: note)+                entry.conservativeIdentityKey = "chapter-1"+                context.insert(entry)+                entry.site = site+            }+            try context.save()+        }+        return (survivor, loser)+    }++    fileprivate func seedPlainEntry(hostname: String) async throws {+        try await withLockedContext(mode: .exclusive, operation: "seeding an Entry") { context in+            let site = Site(hostname: hostname, displayName: hostname)+            context.insert(site)+            let entry = Entry(+                captureTitle: "Chapter 1", captureTitleSource: .host,+                rawURLString: "https://\(hostname)/chapter-1", hostname: hostname,+                entryIdentityKey: "chapter-1", timestamp: Self.probeEpoch)+            entry.conservativeIdentityKey = "chapter-1"+            context.insert(entry)+            entry.site = site+            try context.save()+        }+    }++    /// An Entry set whose members' assignments span a **divergent** Work set —+    /// the only kind that blocks under Req 1.6 (Decision 8).+    fileprivate func seedDeferredEntrySet(hostname: String) async throws {+        try await withLockedContext(mode: .exclusive, operation: "seeding a deferred set") {+            context in+            let site = Site(hostname: hostname, displayName: hostname)+            context.insert(site)+            var works: [Work] = []+            for (index, notes) in ["notes from A", "notes from B"].enumerated() {+                let work = Work(+                    displayTitle: "The Serial", siteHostname: hostname,+                    timestamp: Self.probeEpoch.addingTimeInterval(Double(index)))+                work.urlIdentity = "series-a"+                work.lastParsedTitle = "The Serial"+                work.titleProvenance = .parsed+                work.genericNotes = notes+                context.insert(work)+                work.site = site+                works.append(work)+            }+            for (index, work) in works.enumerated() {+                let entry = Entry(+                    captureTitle: "Chapter 1", captureTitleSource: .host,+                    rawURLString: "https://\(hostname)/chapter-1", hostname: hostname,+                    entryIdentityKey: "chapter-1",+                    timestamp: Self.probeEpoch.addingTimeInterval(Double(index)))+                entry.conservativeIdentityKey = "chapter-1"+                context.insert(entry)+                entry.site = site+                entry.work = work+            }+            try context.save()+        }+    }++    fileprivate func entryCountForTesting() async throws -> Int {+        try await withLockedContext(mode: .shared, operation: "counting Entries") { context in+            try context.fetchCount(FetchDescriptor<Entry>())+        }+    }++    fileprivate func entryNoteForTesting() async throws -> String? {+        try await withLockedContext(mode: .shared, operation: "reading a note") { context in+            try context.fetch(FetchDescriptor<Entry>()).first?.note+        }+    }++    fileprivate var duplicateFollowUpNeededForTesting: Bool { duplicateFollowUpNeeded }++    fileprivate var duplicateLedgerCountForTesting: Int { duplicateLedger.observedSetCount }++    fileprivate func setBulkOperationInProgressForTesting(_ value: Bool) {+        bulkOperationInProgress = value+    }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift Added +337 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swiftnew file mode 100644index 0000000..b4ca0c0--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift@@ -0,0 +1,337 @@+import Foundation+import SwiftData++// The logical-record seam (Q59).+//+// `fetchEntry`/`fetchWork` answered "which of these rows wins" and handed back+// one row, which is the wrong question once an identity group is one record: a+// winner-only write re-diverges the group on the next edit (Q17), and a+// winner-only *read* can present a bare row's empty note over a variant the+// reader has never seen (Q41). These replace them outright rather than sitting+// beside them — deleting the old helpers is what makes the compiler enumerate+// every caller, which a hand-maintained audit table demonstrably did not.++/// Every row sharing one Entry application UUID, as one logical record.+public struct EntryGroup {+    public let id: UUID+    /// Every row, in representative order — `rows.first` is `representative`.+    public let rows: [Entry]+    /// The row supplying capture evidence wherever one row-level value is+    /// needed. Evidence only: never the group's authored content (Q41), and+    /// never the target of a write — a write addresses every row (Req 2.7), so+    /// there is no row for it to choose.+    public let representative: Entry+    /// The row holding the content the group presents (Q41).+    ///+    /// Separate from the representative because the authored fields do not+    /// travel alone: a note comes with its provenance, an assignment with the+    /// row that actually points at the Work, a tag list with the order the+    /// reader entered it. Reading those off the row that carries them keeps all+    /// of it consistent, where rebuilding them from the authored tuple alone+    /// would fabricate a provenance the store never held.+    public let carrier: Entry+    /// The group's distinct authored values, in variant order.+    public let variants: [AuthoredVariant<EntryAuthoredContent>]++    public var isSplit: Bool { rows.count > 1 }++    /// Rows disagreeing about something the reader wrote. A torn group is+    /// read-only outside its resolution (Req 2.8, Q35).+    public var isTorn: Bool { variants.count > 1 }++    /// The group's single authored variant if any, else bare — and nil when+    /// torn, where no single value stands for the group.+    public var authoredContent: EntryAuthoredContent? {+        isTorn ? nil : (variants.first?.content ?? .bare)+    }++    /// What the group presents to a surface that has to gate on it (Req 2.8).+    public var state: RecordGroupState<EntryAuthoredContent> {+        if isTorn { return .torn(variants: variants) }+        return isSplit ? .group(rowCount: rows.count) : .single+    }++    /// The stable names of the group's variants, for a disclosure the commit+    /// re-verifies (Req 2.9).+    public var variantIDs: Set<VariantID> { Set(variants.map(\.id)) }++    /// The content a presentation shows: the single variant when the group has+    /// one, the leading variant when it is torn, bare when nothing is authored+    /// (Req 3.2, Q41). Never a possibly-bare representative row's.+    public var presentedContent: EntryAuthoredContent {+        authoredContent ?? variants.first?.content ?? .bare+    }++    /// Member timestamps (Definitions): the earliest first capture, the latest+    /// share, the latest modification across the group's rows.+    public var firstCapturedAt: Date { rows.map(\.firstCapturedAt).min() ?? .distantPast }+    public var lastSharedAt: Date { rows.map(\.lastSharedAt).max() ?? .distantPast }+    public var modifiedAt: Date { rows.map(\.modifiedAt).max() ?? .distantPast }+}++/// The Work counterpart, with the same rules.+public struct WorkGroup {+    public let id: UUID+    public let rows: [Work]+    public let representative: Work+    /// The Work counterpart of `EntryGroup.carrier`, with the same rule.+    public let carrier: Work+    public let variants: [AuthoredVariant<WorkAuthoredContent>]++    public var isSplit: Bool { rows.count > 1 }+    public var isTorn: Bool { variants.count > 1 }++    public var authoredContent: WorkAuthoredContent? {+        isTorn ? nil : (variants.first?.content ?? .bare)+    }++    public var state: RecordGroupState<WorkAuthoredContent> {+        if isTorn { return .torn(variants: variants) }+        return isSplit ? .group(rowCount: rows.count) : .single+    }++    public var variantIDs: Set<VariantID> { Set(variants.map(\.id)) }++    public var presentedContent: WorkAuthoredContent {+        authoredContent ?? variants.first?.content ?? .bare+    }++    public var createdAt: Date { rows.map(\.createdAt).min() ?? .distantPast }+    public var modifiedAt: Date { rows.map(\.modifiedAt).max() ?? .distantPast }+}++extension LibraryRepository {++    /// The Definitions' assignment normalisation for rows the caller is about to+    /// bucket, derived from the store — and only where it can matter.+    ///+    /// **Every Entry-bucketing surface has to state its normalisation**, because+    /// two surfaces answering differently read the same group as torn on one+    /// screen and whole on the other, which is exactly what Req 3.2 forbids.+    /// That used to be a defaulted parameter, and the default is what let Entry+    /// detail open a group read-only with a "copies differ" notice that Recent+    /// did not show for the same record.+    ///+    /// Cheap by construction. Only a *split* group whose rows carry two+    /// different **manual** assignments can be read torn by an assignment, so+    /// the guard is two scalar reads per row and a relationship fault only for+    /// the manual rows of a split group. A library in which no group disagrees —+    /// which is nearly all of them — never fetches a Work at all.+    internal static func canonicalWorkIDs(+        normalising rows: [Entry], context: ModelContext+    ) throws -> [UUID: UUID] {+        var byID: [UUID: [Entry]] = [:]+        for row in rows { byID[row.id, default: []].append(row) }+        let disagrees = byID.values.contains { group in+            guard group.count > 1 else { return false }+            var assigned: Set<UUID> = []+            for row in group where row.workAssignmentProvenance == .manual {+                guard let workID = row.work?.id else { continue }+                assigned.insert(workID)+                if assigned.count > 1 { return true }+            }+            return false+        }+        guard disagrees else { return [:] }+        return DuplicateScan.canonicalWorkIDs(+            ofWorkRows: try context.fetch(FetchDescriptor<Work>()))+    }++    /// Every Entry row for `id`, as one logical record, normalised as the caller+    /// says.+    ///+    /// `canonicalWorkIDs` carries the assignment normalisation from the+    /// Definitions: assignments referring to members of one Work duplicate set+    /// are equal, so a group split across two members of an unresolved Work set+    /// is *not* torn. **No default** — see `canonicalWorkIDs(normalising:)`.+    internal static func fetchEntryGroup(+        id: UUID, context: ModelContext, canonicalWorkIDs: [UUID: UUID]+    ) throws -> EntryGroup {+        let descriptor = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id })+        guard let group = entryGroup(+            id: id, rows: try context.fetch(descriptor), canonicalWorkIDs: canonicalWorkIDs)+        else {+            throw LibraryRepositoryError.recordNotFound(type: "Entry", id: id)+        }+        return group+    }++    /// The same, deriving the normalisation from the store.+    ///+    /// For the surfaces that read one record and hold no Work-set knowledge of+    /// their own — Entry detail, the single-Entry snapshot, the write redirect.+    /// A named entry point rather than a default, so choosing it is visible at+    /// the call site and choosing *not* to normalise stays visible too.+    internal static func fetchNormalisedEntryGroup(+        id: UUID, context: ModelContext+    ) throws -> EntryGroup {+        let rows = try context.fetch(+            FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id }))+        guard let group = entryGroup(+            id: id, rows: rows,+            canonicalWorkIDs: try canonicalWorkIDs(normalising: rows, context: context))+        else {+            throw LibraryRepositoryError.recordNotFound(type: "Entry", id: id)+        }+        return group+    }++    internal static func fetchWorkGroup(id: UUID, context: ModelContext) throws -> WorkGroup {+        let descriptor = FetchDescriptor<Work>(predicate: #Predicate { $0.id == id })+        guard let group = workGroup(id: id, rows: try context.fetch(descriptor)) else {+            throw LibraryRepositoryError.recordNotFound(type: "Work", id: id)+        }+        return group+    }++    /// The logical record `rows` amount to, or nil where there are none. Pure:+    /// the bulk paths already hold their rows and must not re-fetch per record.+    internal static func entryGroup(+        id: UUID, rows: [Entry], canonicalWorkIDs: [UUID: UUID]+    ) -> EntryGroup? {+        let sorted = GroupOrdering.sortedEntryRows(rows)+        guard let representative = sorted.first else { return nil }+        let contents = sorted.map {+            GroupOrdering.authoredContent(of: $0).normalizingAssignment(using: canonicalWorkIDs)+        }+        let variants = GroupOrdering.variants(+            contents: contents, dates: sorted.map(\.firstCapturedAt))+        // The first row, in representative order, holding the presented content.+        // Falls back to the representative for an all-bare group, where every+        // row carries the same nothing.+        let presented = variants.first?.content+        let carrier = presented.flatMap { content in+            zip(sorted, contents).first { $0.1 == content }?.0+        } ?? representative+        return EntryGroup(+            id: id, rows: sorted, representative: representative, carrier: carrier,+            variants: variants)+    }++    internal static func workGroup(id: UUID, rows: [Work]) -> WorkGroup? {+        let sorted = GroupOrdering.sortedWorkRows(rows)+        guard let representative = sorted.first else { return nil }+        let contents = sorted.map(GroupOrdering.authoredContent(of:))+        let variants = GroupOrdering.variants(+            contents: contents, dates: sorted.map(\.createdAt))+        let presented = variants.first?.content+        let carrier = presented.flatMap { content in+            zip(sorted, contents).first { $0.1 == content }?.0+        } ?? representative+        return WorkGroup(+            id: id, rows: sorted, representative: representative, carrier: carrier,+            variants: variants)+    }++    /// Rows bucketed into logical records by application UUID.+    ///+    /// This is what replaced the `entriesByID`/`worksByID` winner maps on the+    /// bulk write paths (Q69, Decision 9): a map to one row per UUID cannot+    /// express "write every row", and a derived-field write has to (Req 2.7).+    internal static func entryGroups(+        _ rows: [Entry], canonicalWorkIDs: [UUID: UUID]+    ) -> [UUID: EntryGroup] {+        var buckets: [UUID: [Entry]] = [:]+        for row in rows { buckets[row.id, default: []].append(row) }+        return buckets.compactMapValues { rows in+            entryGroup(id: rows[0].id, rows: rows, canonicalWorkIDs: canonicalWorkIDs)+        }+    }++    internal static func workGroups(_ rows: [Work]) -> [UUID: WorkGroup] {+        var buckets: [UUID: [Work]] = [:]+        for row in rows { buckets[row.id, default: []].append(row) }+        return buckets.compactMapValues { rows in workGroup(id: rows[0].id, rows: rows) }+    }++    /// The least torn group of a set of groups, or nil where none is torn.+    ///+    /// Least by application UUID rather than by dictionary order: a refusal that+    /// names a different record on each run is a refusal a reader cannot follow.+    internal static func firstTorn<Group>(+        _ groups: [UUID: Group], isTorn: (Group) -> Bool+    ) -> UUID? {+        groups.filter { isTorn($0.value) }.keys.min { $0.uuidString < $1.uuidString }+    }++    // MARK: - Projection to snapshots++    /// The logical record as one `EntrySnapshot`: the representative's capture+    /// evidence, the **group's** authored content, and the member timestamps+    /// (Req 3.2).+    ///+    /// Never a winner row's content (Q41). A mixed group whose bare row happens+    /// to represent would otherwise present an empty note over the variant the+    /// reader actually wrote, and an ordinary edit would fan out across it.+    internal static func snapshot(_ group: EntryGroup) throws -> EntrySnapshot {+        let base = try snapshot(group.representative)+        guard group.isSplit else { return base }+        let carried = try snapshot(group.carrier)+        return EntrySnapshot(+            id: base.id,+            captureTitle: base.captureTitle,+            captureTitleSource: base.captureTitleSource,+            rawURLString: base.rawURLString,+            canonicalURLString: base.canonicalURLString,+            hostname: base.hostname,+            entryIdentityKey: base.entryIdentityKey,+            identityKeyVersion: base.identityKeyVersion,+            chapterTitle: carried.chapterTitle,+            chapterTitleProvenance: carried.chapterTitleProvenance,+            note: carried.note,+            rating: carried.rating,+            firstCapturedAt: group.firstCapturedAt,+            lastSharedAt: group.lastSharedAt,+            modifiedAt: group.modifiedAt,+            workID: carried.workID,+            workAssignmentProvenance: carried.workAssignmentProvenance,+            intentionallyUnattached: carried.intentionallyUnattached,+            chapterSequence: base.chapterSequence,+            conservativeIdentityKey: base.conservativeIdentityKey)+    }++    /// The Work counterpart. Its Entries are the union across **every** row,+    /// deduped into logical records (Req 5.5): Entries assigned to any row of+    /// the group count and display under that one Work.+    ///+    /// `canonicalWorkIDs` is the Definitions' assignment normalisation (Q38),+    /// passed by the callers that hold it. It is empty for a caller that does+    /// not, which is safe here and nowhere near as safe elsewhere: the Entries+    /// under one Work group all point at rows of that group, so they already+    /// agree about their assignment whatever the map says.+    internal static func snapshot(+        _ group: WorkGroup, canonicalWorkIDs: [UUID: UUID]+    ) throws -> WorkSnapshot {+        let base = try snapshot(group.representative)+        let entries = try entryGroups(+            group.rows.flatMap { $0.entryValues }, canonicalWorkIDs: canonicalWorkIDs)+            .values+            .map { try snapshot($0) }+            .sorted(by: entryActivityOrder)+        guard group.isSplit else {+            return WorkSnapshot(+                id: base.id, displayTitle: base.displayTitle,+                lastParsedTitle: base.lastParsedTitle, siteHostname: base.siteHostname,+                urlIdentity: base.urlIdentity, workURLString: base.workURLString,+                genericNotes: base.genericNotes, type: base.type, genreTags: base.genreTags,+                titleProvenance: base.titleProvenance, createdAt: base.createdAt,+                modifiedAt: base.modifiedAt, entries: entries, groupState: group.state)+        }+        let carried = try snapshot(group.carrier)+        return WorkSnapshot(+            id: base.id,+            displayTitle: carried.displayTitle,+            lastParsedTitle: base.lastParsedTitle,+            siteHostname: base.siteHostname,+            urlIdentity: base.urlIdentity,+            workURLString: carried.workURLString,+            genericNotes: carried.genericNotes,+            type: carried.type,+            genreTags: carried.genreTags,+            titleProvenance: carried.titleProvenance,+            createdAt: group.createdAt,+            modifiedAt: group.modifiedAt,+            entries: entries,+            groupState: group.state)+    }+}
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +334 / -11
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 7cab16c..21dd792 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -273,6 +273,18 @@ public final class AppLibraryModel {         stopSyncObservation()         launchReconcileTask?.cancel()         launchReconcileTask = nil+        duplicateFollowUpTask?.cancel()+        duplicateFollowUpTask = nil+        // The queued trigger and the chain count go with the repository they+        // were about: a request to re-scan the old library says nothing about+        // the one the next open publishes.+        duplicateReconcileRequested = false+        duplicateFollowUpChain = 0+        // The preserved conflicts go with the library they were about: they+        // stand for drafts against records in *that* store, and carrying them+        // into the next open would count them against a library they never+        // described (Q47).+        pendingConflicts = []         guard let repository else { return }         await repository.shutdown()         self.repository = nil@@ -299,6 +311,7 @@ public final class AppLibraryModel {     private func refreshDiagnosesAndSnapshots() async {         await refreshDiagnoses()         await refreshAll()+        dropSettledConflicts()     }      /// Re-derives the diagnoses, recording a failure rather than swallowing it.@@ -332,6 +345,15 @@ public final class AppLibraryModel {     }      /// Provides a detail model for a specific entry.+    ///+    /// The mutation closure schedules a duplicate pass as well as the refresh+    /// (Req 1.2): a reader deletion commits through this model, and a deletion+    /// is one of the actions that changes a set. It fires for an ordinary+    /// curation edit too — a note save schedules a pass that finds nothing.+    /// That is deliberate over-triggering: the surfaces do not report *which*+    /// kind of mutation committed, and a pass over a settled library is two+    /// scalar column walks, where missing a deletion leaves the set unresolved+    /// until an unrelated trigger.     public func entryDetailModel(for id: UUID) -> EntryDetailModel? {         guard let repo = repository else { return nil }         return EntryDetailModel(@@ -339,18 +361,49 @@ public final class AppLibraryModel {             library: repo,             capabilities: capabilities,             onMutation: { [weak self] in+                // A write against this record landed, so whatever was being+                // preserved for it is settled (Req 9.4). Before the refresh:+                // the banner is rebuilt from both halves and must not report+                // one moment's sets beside a conflict the reader just cleared.+                await self?.clearConflicts(resolvedBy: id)                 await self?.refreshDiagnosesAndSnapshots()+                await self?.scheduleDuplicateReconcile()+            },+            onConflict: { [weak self] conflict in+                await self?.recordConflict(conflict, recordType: .entry)             }         )     } -    /// Provides a detail model for a specific work.+    /// Provides a detail model for a specific work. Same trigger as+    /// `entryDetailModel`, for the same reason.     public func workDetailModel(for id: UUID) -> WorkDetailModel? {         guard let repo = repository else { return nil }         return WorkDetailModel(             workID: id, library: repo, capabilities: capabilities,             onMutation: { [weak self] in+                await self?.clearConflicts(resolvedBy: id)                 await self?.refreshDiagnosesAndSnapshots()+                await self?.scheduleDuplicateReconcile()+            },+            onConflict: { [weak self] conflict in+                await self?.recordConflict(conflict, recordType: .work)+            })+    }++    /// The resolution sheet for one duplicate set (Requirement 4).+    ///+    /// Schedules a pass on commit, like every other reader action (Req 1.2) —+    /// the resolution is one of the three actions that changes a set, and the+    /// pass is what settles whatever the collapse left behind.+    public func duplicateResolutionModel(for setKey: DuplicateSetKey) -> DuplicateResolutionModel? {+        guard let repo = repository else { return nil }+        return DuplicateResolutionModel(+            setKey: setKey,+            library: repo,+            onMutation: { [weak self] in+                await self?.refreshDiagnosesAndSnapshots()+                await self?.scheduleDuplicateReconcile()             })     } @@ -365,9 +418,15 @@ public final class AppLibraryModel {     /// Provides a move-to model for an entry.     public func moveToModel(for entryID: UUID) -> MoveToModel? {         guard let repo = repository else { return nil }-        return MoveToModel(entryID: entryID, library: repo, onMutation: { [weak self] in-            await self?.refreshDiagnosesAndSnapshots()-        })+        return MoveToModel(+            entryID: entryID, library: repo,+            onMutation: { [weak self] in+                await self?.clearConflicts(resolvedBy: entryID)+                await self?.refreshDiagnosesAndSnapshots()+            },+            onConflict: { [weak self] conflict in+                await self?.recordConflict(conflict, recordType: .entry)+            })     }      /// Provides the composed teaching surface's view model for a Recent row's@@ -424,13 +483,104 @@ public final class AppLibraryModel {         )     } -    /// The diagnosis surface's model (Req 4.1, 4.2). The caller supplies the-    /// re-teach route because navigation is its concern, not the model's.+    /// The diagnosis surface's model (Req 4.1, 4.2, and Req 9.3's duplicate+    /// rows). The caller supplies the re-teach route because navigation is its+    /// concern, not the model's.     public func libraryDiagnosticsModel(-        onReteach: @escaping @MainActor (String) -> Void+        onReteach: @escaping @MainActor (String) -> Void,+        onResolveDuplicate: (@MainActor (DuplicateSetKey) -> Void)? = nil     ) -> LibraryDiagnosticsModel? {         guard let repo = repository else { return nil }-        return LibraryDiagnosticsModel(library: repo, onReteach: onReteach)+        return LibraryDiagnosticsModel(+            library: repo, onReteach: onReteach, onResolveDuplicate: onResolveDuplicate,+            // A live read, not a copy: the view holds this model in `@State` for+            // the life of the screen, and a conflict recorded or cleared while+            // the reader is on it has to reach the list (Req 9.4).+            pendingConflicts: { [weak self] in self?.pendingConflicts ?? [] })+    }++    // MARK: - Preserved edit conflicts (Req 2.10, Q47)++    /// What is waiting for the reader that no duplicate *set* accounts for.+    ///+    /// Held here rather than in the repository because the draft it stands for+    /// lives in a view model: the repository refused the write and handed the+    /// edit back, and the only copy of it is on screen.+    public private(set) var pendingConflicts: [PendingConflict] = []++    /// Req 9.1's banner count: sets awaiting a decision plus preserved+    /// conflicts, each counted once. The two halves are published from different+    /// places — the sets by the Recent read, the conflicts by this model — and+    /// overlaying them here is what keeps the banner from reporting one moment's+    /// sets beside another moment's conflicts.+    public var duplicateBannerCount: Int {+        recentPresentation.duplicateWorkload.reviewCount + pendingConflicts.count+    }++    /// Records a refused write so the banner and Check Library can carry it.+    ///+    /// A conflict about a record already in the list replaces it: the reader has+    /// one draft per record, and two rows for it would be two claims about the+    /// same edit.+    func recordConflict(_ conflict: WriteConflict, recordType: DuplicateRecordType) {+        let pending = PendingConflict(+            id: conflict.recordID,+            conflict: conflict,+            recordType: recordType,+            message: EntryDetailModel.conflictMessage(conflict))+        if let index = pendingConflicts.firstIndex(where: { $0.id == pending.id }) {+            pendingConflicts[index] = pending+        } else {+            pendingConflicts.append(pending)+        }+    }++    /// Clears one preserved conflict — the reader resolved it, or discarded the+    /// draft it was holding.+    public func clearConflict(_ id: UUID) {+        pendingConflicts.removeAll { $0.id == id }+    }++    /// A write against `recordID` committed, so nothing is being preserved for+    /// it any more (Req 9.4).+    ///+    /// **Without this the count could never reach zero.** Check Library tells+    /// the reader to open the record and save their edit again once the copies+    /// are resolved; they do exactly that, it lands — and the banner and the row+    /// stayed for the rest of the session, which is the unsatisfiability Q57+    /// spent this phase removing from `.duplicateIdentity`.+    ///+    /// The survivor counts as well as the addressed record. A+    /// `.survivorDiverged` conflict names a record that has *gone*: re-saving+    /// against its own id would only refuse again, and the reader's way through+    /// it is the surviving copy — so a write that lands there is what settles it.+    func clearConflicts(resolvedBy recordID: UUID) {+        pendingConflicts.removeAll { pending in+            pending.id == recordID || pending.conflict.survivorID == recordID+        }+    }++    /// Drops the conflicts whose *cause* is gone.+    ///+    /// A `.torn` or `.disclosureStale` conflict says "this record holds copies+    /// that differ". When no published set covers the record any more, that+    /// sentence has stopped being true — the reader resolved it here, or their+    /// other device did — and Req 9.4 says the count clears without a restart+    /// rather than waiting for them to re-enter a screen.+    ///+    /// `.survivorDiverged` is deliberately not swept: its record is already gone+    /// and no set will ever mention it, so a sweep would drop it the moment it+    /// was recorded, taking the only notice of a preserved edit with it.+    private func dropSettledConflicts() {+        let workload = recentPresentation.duplicateWorkload+        pendingConflicts.removeAll { pending in+            switch pending.conflict {+            case .torn, .disclosureStale:+                return workload.item(for: pending.id, type: pending.recordType) == nil+            case .survivorDiverged:+                return false+            }+        }     }      // MARK: - Sync visibility (Req 8)@@ -450,6 +600,27 @@ public final class AppLibraryModel {     /// special-casing its absence.     var syncStatusSource: (any SyncStatusReporting)? { syncMonitor } +    /// Req 1.3's follow-up pass, and the reader-action/import triggers, which+    /// share it: one outstanding duplicate pass at a time.+    private var duplicateFollowUpTask: Task<Void, Never>?++    /// A reader-action or import trigger that arrived while a pass was in+    /// flight (Req 1.2). It is *queued*, not dropped: the in-flight pass may+    /// have derived its scan before the reader's write landed, and import is+    /// the trigger that reliably manufactures same-UUID sets (M4b Q18).+    private var duplicateReconcileRequested = false++    /// How many follow-ups this chain has already run. Req 1.3's re-arm is+    /// consumed at the end of every pass, so a set that keeps aborting could+    /// otherwise reschedule itself for the life of the session.+    private var duplicateFollowUpChain = 0++    /// The bound on that chain. Three is the longest run the model produces+    /// honestly — defer, follow-up, one abort, its re-arm — and past it the+    /// latch is dropped and the sets wait for the next ordinary trigger rather+    /// than spinning.+    static let duplicateFollowUpChainLimit = 3+     /// Q45's once-per-launch pass, scheduled after the open publishes Recent.     private var launchReconcileTask: Task<Void, Never>?     private var launchReconcileStarted = false@@ -520,7 +691,9 @@ public final class AppLibraryModel {     func handleSyncArrivals() async {         guard let repo = repository else { return }         do {-            _ = try await repo.reconcileAfterSync()+            // The arrival tier: the duplicate phase runs only where the last+            // refresh's scan or the session ledger says there is work (Q53/Q58).+            _ = try await repo.reconcileAfterSync(tier: .arrival)         } catch {             // Reconciliation has no user-facing errors: a failed pass stopped at             // a chunk boundary and the next trigger converges it. The refresh@@ -529,6 +702,10 @@ public final class AppLibraryModel {                 "Reconciliation after arrivals failed: \(String(describing: error), privacy: .public)")         }         await refreshDiagnosesAndSnapshots()+        // Read *after* the refresh: its scan is what spots the sets a gated pass+        // did not process, which is how a hydration's final batch gets its pass+        // (Q58).+        await scheduleDuplicateFollowUpIfNeeded()     }      /// Schedules the once-per-launch reconcile (Q45).@@ -550,8 +727,9 @@ public final class AppLibraryModel {             let outcome = try await repo.reconcileAfterSync()             // An empty pass wrote nothing and the open published Recent moments             // ago, so re-deriving over it would repeat the open's read for no-            // change. A pass that moved custody or re-pinned records changed-            // what the screen is built from, so it refreshes.+            // change. A pass that moved custody, re-pinned records, resolved a+            // duplicate set or published reader workload changed what the screen+            // is built from, so it refreshes — `isEmpty` covers both halves.             if !outcome.isEmpty { await refreshDiagnosesAndSnapshots() }         } catch {             Self.logger.error(@@ -560,9 +738,150 @@ public final class AppLibraryModel {             // published may describe a graph that no longer holds.             await refreshDiagnosesAndSnapshots()         }+        await scheduleDuplicateFollowUpIfNeeded()         launchReconcileCompleted = true     } +    /// Req 1.3's follow-up: a pass that deferred a deletion owes the session+    /// another one, after pending arrivals have been processed.+    ///+    /// One task at a time, and the latch is *consumed* rather than read, so a+    /// launch pass and the arrival that follows it cannot schedule two+    /// follow-ups for one deferral. When a pass is already in flight the latch+    /// is deliberately left alone rather than consumed: that pass re-reads it+    /// when it ends (`scheduleDuplicateSuccessorIfNeeded`), so the deferral is+    /// answered by the successor instead of being swallowed here.+    private func scheduleDuplicateFollowUpIfNeeded() async {+        guard let repo = repository, duplicateFollowUpTask == nil else { return }+        guard await repo.takeDuplicateFollowUp() else { return }+        // Re-checked after the await: the actor hop is a suspension point, and a+        // trigger can take the slot across it. The latch is **handed back**+        // rather than dropped — this call consumed it, so returning here without+        // re-arming would discard a deferral nobody else is holding, and the+        // successor at the tail of that other pass would find nothing to read.+        guard duplicateFollowUpTask == nil else {+            await repo.armDuplicateFollowUp()+            return+        }+        duplicateFollowUpChain = 0+        startDuplicatePass(awaitingQuiescence: true)+    }++    /// One duplicate pass, in a task.+    ///+    /// `awaitingQuiescence` separates the two callers Req 1.2 and Req 1.3 give+    /// this method. A **follow-up** waits for arrivals to go quiet (Q62): it+    /// exists because a set changed between two passes, and running it into the+    /// same movement would only defer again. A **reader action or import** does+    /// not: Q62's 30 s cap was justified for the follow-up, and making a+    /// reader's deletion wait up to half a minute behind an unrelated hydration+    /// is not what Req 1.2 asks for — its pass is about a write that has already+    /// committed.+    private func startDuplicatePass(awaitingQuiescence: Bool) {+        duplicateFollowUpTask = Task { [weak self] in+            await self?.runDuplicatePass(awaitingQuiescence: awaitingQuiescence)+        }+    }++    private func runDuplicatePass(awaitingQuiescence: Bool) async {+        guard let repo = repository else {+            duplicateFollowUpTask = nil+            return+        }+        if awaitingQuiescence { await syncMonitor?.awaitQuiescence() }+        do {+            let outcome = try await repo.reconcileAfterSync()+            if !outcome.isEmpty { await refreshDiagnosesAndSnapshots() }+        } catch {+            Self.logger.error(+                "Duplicate follow-up pass failed: \(String(describing: error), privacy: .public)")+        }+        // Cleared *before* the successor is considered, so the successor can+        // take the slot — and so `waitForDuplicateFollowUp` sees either nothing+        // pending or the successor, never this task twice.+        duplicateFollowUpTask = nil+        await scheduleDuplicateSuccessorIfNeeded()+    }++    /// What the tail of a pass owes: the trigger it displaced, or the deferral+    /// it just earned.+    ///+    /// Req 1.3's re-arm had no consumer without this. In a session with no+    /// further sync arrival and no relaunch — the ordinary case, since the abort+    /// happened *because* something raced — nothing else reads the latch, and the+    /// set stayed unresolved for the whole session.+    ///+    /// The queued trigger goes first: it stands for a write that has landed,+    /// where the latch stands for one that has not settled.+    private func scheduleDuplicateSuccessorIfNeeded() async {+        guard let repo = repository, duplicateFollowUpTask == nil else { return }+        if duplicateReconcileRequested {+            duplicateReconcileRequested = false+            duplicateFollowUpChain = 0+            startDuplicatePass(awaitingQuiescence: false)+            return+        }+        guard await repo.takeDuplicateFollowUp() else {+            duplicateFollowUpChain = 0+            return+        }+        // Same re-check as above, for the same suspension point, and the latch+        // goes back for the same reason.+        guard duplicateFollowUpTask == nil else {+            await repo.armDuplicateFollowUp()+            return+        }+        // The bound. Every honest re-arm needs an observed change to the set, so+        // a chain this long means something is writing to it faster than the+        // pass can settle it — and one more pass would not win that race either.+        // Dropping the latch leaves the sets tolerated (M4a) until the next+        // launch, arrival, import or reader action, which is the state Q19+        // already accepts for a short session.+        guard duplicateFollowUpChain < Self.duplicateFollowUpChainLimit else {+            Self.logger.notice(+                "Duplicate follow-up chain hit its bound; leaving the sets for the next trigger.")+            duplicateFollowUpChain = 0+            return+        }+        duplicateFollowUpChain += 1+        startDuplicatePass(awaitingQuiescence: true)+    }++    /// Test seam: awaits the scheduled pass **and any successor it schedules**,+    /// mirroring `waitForLaunchReconcile()`.+    ///+    /// The loop is what makes it a seam for the whole chain rather than for one+    /// link of it; it terminates because the chain is bounded.+    func waitForDuplicateFollowUp() async {+        while let task = duplicateFollowUpTask {+            await task.value+        }+    }++    /// Req 1.2's reader-action and import triggers: a full pass, scheduled after+    /// the write has committed.+    ///+    /// Scheduled rather than awaited, and *after* the caller returns rather than+    /// inside it. `confirmImport` holds `bulkOperationInProgress` until the+    /// method exits, so a pass called from within it would defer itself and+    /// nothing would re-fire it.+    ///+    /// A trigger arriving while a pass is in flight is **queued, not dropped**.+    /// The in-flight pass derived its scan at some point in the past, possibly+    /// before this write landed, so treating it as covering this trigger loses+    /// the pass Req 1.2 requires. One pending flag rather than a queue: two+    /// requests want the same thing, which is one more pass over the store as it+    /// now stands.+    public func scheduleDuplicateReconcile() {+        guard repository != nil else { return }+        guard duplicateFollowUpTask == nil else {+            duplicateReconcileRequested = true+            return+        }+        duplicateFollowUpChain = 0+        startDuplicatePass(awaitingQuiescence: false)+    }+     /// Test seam: awaits the scheduled launch pass.     func waitForLaunchReconcile() async {         await launchReconcileTask?.value@@ -635,6 +954,10 @@ public final class AppLibraryModel {     private func handleImportCompletion() async {         interruptedImport = nil         await refreshDiagnosesAndSnapshots()+        // Req 1.2's import trigger. Here rather than inside `confirmImport`,+        // whose `defer` holds `bulkOperationInProgress` until the method exits:+        // a pass called from within it would defer itself and never re-fire.+        scheduleDuplicateReconcile()     }      /// Req 4.4's sentence, or nil when no import stopped partway.

Things to double-check

Req 10.1's remaining ~7 s is unattributed.

The original cost model attributed the whole ~9 s to 300 per-set saves at ~30 ms each. Batching them recovered ~1.6 s. Two hypotheses are recorded as hypotheses, not findings — the next person to touch this should measure before assuming.

Nothing is measured on device.

The AsterismCore package test target is in no scheme's test action, so every number here is host-only. Req 10.1's settling pass in particular has no device figure. This is a deliberate deferral, not an oversight.

Q97 — Work groups that arrive holding different bucket keys.

Work convergence writes no bucket key, so a group whose rows arrived holding different urlIdentity or lastParsedTitle values permanently bridges two unrelated Work sets through the union–find. The app no longer creates that state, but nothing repairs one that arrives. Recorded open; needs a design answer before code.

The share extension's re-share path does not redirect.

Req 2.10's post-collapse redirect covers every app-side editor. Re-share returns .invalidated, which the view model turns into a terminal state and the draft is gone. Consistent with the design, which names only the four app editors — but the correctness rests on Req 2.10 scoping to 'the local app instance', and nothing records that reading.