asterism branch T-2294/feature-wrong-host-work-url-heal commits 27 files 39 touched lines +4910 / -134 requirements 25 full / 2 partial / 0 missing

Pre-push review: wrong-host-work-url-heal

T-2294 — a membership Work URL on another host becomes a Work-keyed tolerated diagnosis that quarantines nothing, healed by MembershipReconciler phase 1b, carried through the three folds, normalised at import. Reviewed by four parallel agents (reuse, quality, efficiency, spec/docs) after two in-phase design-critic rounds; all fixable findings applied in d247933.

At a glance

  • LibraryDiagnosis.wrongHostWorkURL — Work-keyed, tolerated, quarantines nothing; both producers derive it through one WrongHostMembership.pairs(from:).
  • WrongHostWorkURLResolver — pure: blocked closure, then one ordered pass with a per-destination claim; the apply step lands destinations before sources.
  • carryWorkURL at de-dup, merge collapse and export — no fold loses a Work URL (Req 1.6).
  • Import pre-pass normalises where the destination write is known to land; the plan gate names the membership (Q55).
  • Found and fixed a pre-existing bug: unquantized healCreatedAt made any healed library fail its own export validation (Q67).
  • Merged origin/main (T-2295) — the branch had been one commit behind on the same files.

Verdict

Ready to push

All 20 spec tasks are implemented with behavioural tests; make test-core and make build-ios are green on the merged tree (the only failure seen is the documented Bootstrap store-digest flake, passing in isolation). Five major review findings — the stale branch base, per-Work saves on the launch reconcile, the duplicated off-host classification, the duplicated import write predicate, and the two-copy diagnosis derivation — are fixed and recorded (Q68–Q75). What remains is a judgement call for the author: make test-performance-m4 has not been run against the new per-membership work on the foreground scan.

Review findings

19 raised · 16 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

A Work is a story you follow. It can be on more than one site — www.example.com and m.example.com count as two different sites — and Asterism keeps, per site, a link to where that story lives on that site. That link is the Work URL.

Old builds sometimes saved the link for site A onto the record for site B. The library used to treat that as proof the whole site was broken, so it quarantined the site: every new chapter captured from it stopped getting the site's rules applied, and Merge refused to touch its Works. One stale link disabled a site.

Three things changed:

  • It is no longer a quarantine. A link on the wrong site is now a to-do item — a tolerated diagnosis — listed in Check Library. The site keeps working normally.
  • The app repairs it by itself. On its next tidy-up pass Asterism moves the link to the site it actually points at, adding the story to that site if it was not on it yet (without inventing the site itself).
  • Restoring a backup no longer fails because of it. Backups holding this residue used to be refused outright. Now the import fixes the link where it can work out the answer, and leaves it for the tidy-up pass where it cannot.

The Check Library entry says both things: that Asterism will move the link, and that you can fix it yourself right now by opening the work and confirming or clearing the address.

Why It Matters

The real-world trigger (ticket T-2289) was a single link on the user's own library that took rule application away from every webtoons capture for weeks. The cost was wildly out of proportion to the fault, and the only repair was a manual edit the app never pointed at. This makes the fault small and self-healing.

Key Concepts

  • Membership — the record joining one Work to one site. It holds that site's Work URL. One Work on three sites has three memberships.
  • Hostnamewww.example.com. Sites are keyed on the exact hostname; m. and www. are deliberately not treated as the same site.
  • Quarantine — the library's "this site is damaged, be conservative" switch. Turning it off for this fault is the point of the change.
  • Tolerated diagnosis — a fault the library reports but works around. There were three; this adds a fourth.
  • Torn group — one Work stored as several rows sharing its id, a transient state sync can produce. The repair has to consider every row of the group.
  • Reconciliation pass — the background tidy-up that runs at launch and after sync. The repair is a new phase inside it.
  • Heal — a repair the app performs silently, with no reader involvement.

Changes Overview

  • LibraryDiagnostics.swift:69LibraryDiagnosis.wrongHostWorkURL(workID:workTitle:memberships:) and its id/hostname/recordCount/caseRank/payloadKey arms; :196 WrongHostMembership and pairs(from:); :392 the record counting; :627-726 the foreground scan producer.
  • LibraryValidator.swift:912isToleratedWrongHostWorkURL, the shared narrowing; :250 the narrowed arm drops the finding; :461-495 the full arm accumulates per Work.
  • WorkURLPlanner.swift:128-169StoredWorkURL, holdsWorkURL, classifyWorkURL(_:on:), offHost(_:on:): one emptiness/host rule for four callers.
  • WrongHostWorkURLResolver.swift (new, 175 lines) — the pure resolver.
  • MembershipReconciler.swift:92 Heals option set; :392-572 phase 1b healWrongHostWorkURLs; :685/:700 the shared fold carry.
  • Models.swift:1075 WorkSiteMembership.OrderKey; indexByWorkID now returns memberships in survivor order carrying workURLString; :448-461 case-insensitive Work.memberships(in:forHostMatching:); :1277 UUID.bytesPrecede.
  • GroupOrdering.swift:359keepRepresentative.
  • DuplicateReconciler.swift:755-822 collapse carry; :1345 propagation copies only on-host values.
  • BackupArchiveProjection.swift:678-707 — the export fold carries the value.
  • LibraryRepository+ConfirmImport.swift:666/:732normalizedMemberships and willWriteMembership; LibraryRepository+BackupImportGates.swift:77 refusal.
  • LibraryRepository.swift:295/:367 — the derived healsWrongHostWorkURL gate.
  • MaintenanceViewModels.swift:291-434 — the Check Library row.

Implementation Approach

One rule, one spelling. The recurring shape is a rule that already existed in two or three slightly different forms being collapsed to a single function every caller reads: classifyWorkURL replaces three spellings of "is this membership empty"; OrderKey makes value snapshots and live rows sort by one comparator; WrongHostMembership.pairs is called by both diagnosis producers; carriedWorkURL is called by all three folds.

Diagnosis: two producers, one output. The full validation and the cheap foreground scan both emit the case. That is not redundancy — refreshDiagnostics carries forward tuple diagnoses only and replaces the tolerated set with the scan's output, so a state only the validator produced would vanish on the first foreground refresh, taking the heal's gate with it. Both key on the membership's workID column, never the work relationship, so a Work that has not arrived is the same diagnosis in both with a nil title.

Repair: pure resolver, then apply. WrongHostWorkURLResolver.resolve takes a snapshot of every membership of one Work and returns [Move]land, remove, stay. It reads no store and writes nothing. The algorithm is a least fixpoint marking hostnames blocked (they hold a malformed value, or point at a blocked hostname), then one pass in (source hostname, survivor order) with a per-destination claimed flag. That is what makes cycles rotate: a destination holding only movable values is empty after the pass, because every one of them lands or is removed. healWrongHostWorkURLs only carries the answer to the rows — lands first, minting where absent, then clears each moved value from every row of the group on its source hostname, matching on the exact value, because a cycle lands a value on a hostname that also held one.

Gate. healsWrongHostWorkURL is derived from the published diagnoses rather than latched. Three things publish diagnostics (the open's full validation, refreshDiagnostics, import completion) and a derived property cannot be forgotten by any of them. This is deliberately not the worksWithoutMembership pattern, which is false at open by design.

Import. normalizedMemberships is a pure static over the archive's records plus two facts about the target library — which membership ids it holds and which Works this import applied. It applies the heal's precedence, but only where it knows what the destination will hold after the import; everything else imports unchanged for the heal. One sequential pass, not a fixpoint: a chain a→b, b→c moves b and leaves a, and the launch heal (which can also mint) closes the remainder.

Trade-offs

  • Pure resolver over in-place mutation. Buys generated-input testing (400 seeded cases) and a convergence argument; costs a snapshot type and a second traversal.
  • Two producers of one diagnosis. Buys survival across a foreground refresh; costs a duplicated derivation that only stays honest because both call pairs(from:).
  • Mint the membership, never the Site row. Preserves the reader's address on the site it belongs to; costs a standing .siteMissing row and one extra affected record until something is captured from that host — possibly never.
  • Un-masking. Removing the membership finding from the hostname-keyed tuple map exposes Site/Work/Entry findings it used to stand in front of. Those are real damage, so reporting them is correct — but a commit gate comparing against the hostname's prior diagnosis can now refuse where it passed before, and a hostname can become quarantined on the very pass that heals it.
  • Import chunk straddle accepted. A Work's source and destination records sit in unrelated chunks under the archive's id order, so an interrupted import can leave the value on no library row. The archive is a durable second copy and re-running the import re-normalises.
  • Save on a Work boundary, not per Work. Req 3.11 asks that a Work's moves land together, which a batch boundary on a Work edge gives; a save per Work made a settling pass over hundreds of Works pay a round trip each.

Technical Deep Dive

Resolver semantics (WrongHostWorkURLResolver.swift:117). Every membership is classified and keyed on the lowercased hostname (Q65 — stored hostnames are not normalised while WorkURLPlanner.host(of:) always lowercases, so keying on the stored spelling left an Example.com row invisible to a value naming example.com, and the heal would mint a second row in a different case that de-duplication never folds).

  • blocked starts as the hostnames holding an .other value (held, not an absolute HTTP(S) URL — a blank string included) and grows by least fixpoint along candidate edges. Iteration, not recursion, is what keeps a cycle with no malformed value unblocked, which is what makes it rotate.
  • Candidates are de-duplicated on (hostname, value) (Q70), so torn rows holding one value under one hostname are one candidate and count once in movedWorkURLs — the apply step clears by value across the group anyway.
  • The ordered pass emits remove when the destination holds an on-host address or is already claimed, land otherwise. A chain into an on-host address therefore resolves as remove-then-land, not as a block.

Apply-step edge cases (MembershipReconciler.swift:464-570):

  • Mint target (Q61): the membership joins the first row in GroupOrdering.sortedWorkRows order that already holds a membership, not the representative. representativeComponents sorts a membership-less row first (empty primary hostname), so minting there would make the minted row that row's primary and move the site the Work is presented under, breaking Req 3.5. Only a group with no memberships at all falls back to the representative.
  • Abandoned land (Q66): a row outside the workID reading — nil column, live relationship (Q58) — can already hold the destination hostname and a value. Minting beside it would leave de-duplication to fold the pair and possibly keep the row without the moved address, so the move is dropped: the source keeps its value and the Work stays reported.
  • Counting: moved and touched are local until wrote is known true. A plan every one of whose lands was abandoned writes nothing and counts nothing — otherwise report.isEmpty would be false forever and the launch path would refresh on every pass.
  • Determinism: plans are iterated in UUID.bytesPrecede order, because Dictionary iteration is per-process seeded and two devices must write the same library.

Timestamp quantization (Q67). healCreatedAt now runs its result through MillisecondInstant.quantize. latest.addingTimeInterval(0.001) lands one ULP from the same instant built through MillisecondInstant, and the archive's date encoding quantizes on the way out — so the decoded value was a different Date and BackupV7Exporter's decode-validation refused the file it had just written. Pre-existing for the phase-1 missing-membership heal; found by the Req 4.2 round-trip test. A library holding any heal-minted membership could not be backed up.

No Work reference outlives an enumerate batch (Q60, Q53). The scan needs the representative row's displayTitle, and GroupOrdering reads a Work's primary membership — so holding rows until after enumerate and sorting them there would fault siteMemberships on models the batch has already released. GroupOrdering.keepRepresentative folds each row into a running (orderKey, value) inside the batch, keeping the value from the least row seen, which is what sortedWorkRows(_:).first answers over a group held whole. Ties fall to the row met first in fetch order (Q73).

Performance. indexByWorkID reads three more scalars per membership and sorts groups — but only groups with more than one row build an OrderKey, so the common single-membership Work pays nothing beyond the extra column reads; this sits on the Req 10.1 foreground-refresh budget. UUID.bytesPrecede avoids two uuidString allocations per comparison in OrderKey.<, called O(n log n) times on the foreground scan and the arrival duplicate phase. The scan faults Work rows only for diagnosed workIDs. The heal resolves first and fetches Work rows only for Works with something to write, chunked at batchSize. Propagation classifies the carrier's URLs once for the whole fan-out instead of re-parsing per row.

Architecture Impact

  • The tolerated set grows from three states to four; every doc comment counting them was updated.
  • library-integrity-tolerance Decision 3's "import stays strict" boundary gains one named exception, recorded in the gate's own header comment as well as in the decision log. multi-site-works Q97 is superseded by this spec's Decision 1.
  • MembershipReconciler.run's heals parameter changes from Bool to an OptionSet, so the two gated repairs are independently armed at the single production call site.
  • No schema change, no archive-format change (Req 4.1), pinned by tests.
  • LibraryValidationError.wrongHostWorkURL survives as the validator's internal throw and the strict gate's report; the tolerated case is a separate LibraryDiagnosis. Both surfaces exist in MaintenanceViewModels because a membership naming no Work stays the tuple failure (Q31).

Potential Issues

  • Un-masking is a live behaviour change beyond this feature's tests. Q21 accepts that recordPostCommitDiagnosis and introducedDiagnosis comparisons shift on hostnames where a real finding was hidden behind the membership one. One test pins the un-masking itself; nothing pins the commit gates' new refusals.
  • Hostname case is normalised in the heal and the diagnosis, not in the store. A minted membership is created with the lowercased destination hostname while a pre-existing row keeps its stored spelling, and de-duplication still groups on the exact stored hostname (MembershipReconciler.Key). The apply step avoids creating that pair (it finds the existing row case-insensitively first), but nothing repairs a mixed-case pair that arrives some other way.
  • A blocked Work stays diagnosed indefinitely. By design (Q38, Q14) — but the Check Library row will promise "Asterism will move the link on its next check" for a value it will never move. The manual route is offered in the same sentence, so this is a wording risk rather than a correctness one.
  • Import's sequential pass is order-sensitive by construction (Q64). A chain a→b, b→c normalises b only. Pinned by a test, and the heal closes the rest — but an import into a library that never runs a reconciliation pass keeps the residue.
  • willWriteMembership now gates the whole commit loop, not just the update branch (LibraryRepository+ConfirmImport.swift:592). It reads existingMembershipIDs = Set(rowsByID.keys) where the old code read if let rows = rowsByID[record.id], !rows.isEmpty. Dictionary(grouping:) never yields an empty value array, so the two agree — but the equivalence rests on that invariant rather than on the same expression.
  • UUID.bytesPrecede replaces Q60's stated "stringify only inside <". The orders are equivalent (uppercase hex digits sort in byte order, dashes at fixed positions) and the code comment says why, but the decision log still describes the earlier shape.

Important changes — detailed

The wrong-host finding stops being a hostname quarantine

Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift

Why it matters. This is the whole point of the feature: one stale link used to take rule application off every capture on the site. Both validator arms now divert the finding away from the hostname-keyed tuple map, so it quarantines nothing — and, as a side effect, it stops masking Site/Work/Entry findings that sat behind it.

What to look at. isToleratedWrongHostWorkURL (LibraryValidator.swift:912); the narrowed arm's guard at :250; the full arm's accumulation and per-Work emission at :461-495

Takeaway. The divert is narrowed twice — actualHost != nil and the membership's workID column non-nil — and is deliberately not conditioned on strictness, which is also what stops the import plan gate refusing an old archive for this one field.
Rationale. Decision 1 (supersedes multi-site-works Q97); Q50 for the narrowing, Q47 for the narrowed arm dropping it, Q21 for the un-masking consequence, Q31/Q58 for the nil-Work and nil-column cases.

The pure resolver decides every move from one snapshot

Packages/AsterismCore/Sources/AsterismCore/WrongHostWorkURLResolver.swift

Why it matters. All of Req 3.4's precedence — cycles, collisions, blocked chains, torn rows — lives in one 60-line function that reads no store. That is what makes it testable over 400 generated Works and what makes the pass idempotent and order-independent.

What to look at. WrongHostWorkURLResolver.resolve (WrongHostWorkURLResolver.swift:117-173): classify, blocked least fixpoint, candidate de-duplication on (hostname, value), one ordered pass with a per-destination claim

Takeaway. Read the blocked closure first: it is the difference between a cycle rotating (unblocked, so every member's destination empties) and a chain into a malformed value staying whole. "Fixpoint" alone would admit the greatest one, under which every cycle blocks itself.
Rationale. Q42 (pure resolver for generated-input testing), Q49 (closure-then-pass, not a bare fixpoint), Q24 (candidate order on synced fields so two devices agree), Q70 (candidate de-duplication), Q65 (lowercased keys).

The apply step: destinations before sources, in one save per Work boundary

Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift

Why it matters. Req 3.11 says no value may be absent from every row at a point observable after a save. The loop writes every land (or mints), then clears the sources by exact value — and saves only on a Work edge, so an interrupted pass leaves a legal library.

What to look at. healWrongHostWorkURLs (MembershipReconciler.swift:392-572); mint-target selection at :476; the mint at :512-522; the clear loop at :537-555; the save cadence at :564-570

Takeaway. Three non-obvious guards: the mint joins the first row that already holds a membership (not the representative, which would move the presented site); a land is abandoned where a row outside the workID reading already holds the destination and a value; and the counters stay local until wrote is true, so a wholly-abandoned plan reports no change.
Rationale. Q22/3.11 for the ordering, Q61 for the mint target, Q66 for the abandoned land and blank hostnames, Q51 for clearing by exact value, Q75 for the batched save (which relaxed the design's "one save per Work").

One fold-carry rule at three sites, and a propagation guard

Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift

Why it matters. De-duplication, merge collapse and backup export all fold a (Work, hostname) group to one survivor. Without a carry, a heal-minted row in identity state none loses to a later rule-identity row and the address the heal just preserved disappears silently — which Req 4.2 cannot survive.

What to look at. carriedWorkURL / carryWorkURL (MembershipReconciler.swift:685-711); dedupe :603-615; DuplicateReconciler.collapseMemberships :755-822; BackupArchiveProjection :678-707; propagation guard DuplicateReconciler.swift:1345

Takeaway. The carry takes an off-host value too (de-duplication runs ungated every pass while the heal has a one-derivation lag), and the collapse keeper is resolved over the whole survivor group rather than the target row — a hostname can be in the survivor set because a sibling row holds it.
Rationale. Q41 (one helper for three folds), Q33 (carry off-host values too), Q54 (value-returning, so the export projection does not dirty its context), Q62 (keeper over the whole survivor group), Q17 (why export is in scope at all).

The import pre-pass normalises only what it can decide

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift

Why it matters. Old archives with this residue were refused outright. The pre-pass applies the heal's precedence to the archive's records, but only where it knows what the destination record will hold after the import — otherwise both records import unchanged and the heal decides.

What to look at. normalizedMemberships (LibraryRepository+ConfirmImport.swift:666-729) and willWriteMembership (:732-741); the commit loop's new shared gate at :592

Takeaway. Pure by construction — archive records plus two facts about the target library — so the whole "will the destination be written x what will it hold" matrix is testable without a store. One sequential pass, not a fixpoint: a chain a to b to c normalises b only, deliberately.
Rationale. Decision 3 (import normalises rather than refusing), Q30 (decide on the post-import value), Q32 (never import a source without its URL where the write will not land), Q52 (destination unique), Q64 (one pass, chain left to the heal), Q44 (pre-pass mutating record values only).

The heal's gate is derived, not latched

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift

Why it matters. Three things publish diagnostics — the open's full validation, refreshDiagnostics, and import completion. A latch would have to be set by each; a derived property cannot be forgotten. Critically it is armed by the open, unlike the neighbouring worksWithoutMembership latch, so a value the open found is healed on the launch pass.

What to look at. healsWrongHostWorkURL (LibraryRepository.swift:295-305) and the Heals option set passed into reconcileAfterSync at :367

Takeaway. The gate reads the published diagnoses, which is why the foreground scan must reproduce the diagnosis (Req 2.4) — refreshDiagnostics replaces the tolerated set rather than carrying it forward.
Rationale. Q46 (derived, not latched), Q28 (the open must arm it, and a pass that healed counts as a change), Q40 (option set rather than two Bools), Q10 (why both derivations produce the state).

healCreatedAt now quantizes through MillisecondInstant

Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift

Why it matters. A pre-existing bug the Req 4.2 round-trip test exposed: latest + 0.001 s lands one ULP from the same instant built through MillisecondInstant, the archive's date encoding quantizes on the way out, and BackupV7Exporter's decode-validation then refused the file it had just written. A library holding any heal-minted membership could not be backed up at all.

What to look at. healCreatedAt (MembershipReconciler.swift:336-345)

Takeaway. Any date a heal synthesises has to be built on the same quantization grid the archive round-trips on. The value stays a pure function of stored content, so two devices healing the same Work still agree.
Rationale. Q67, recorded as a Fixed entry in the CHANGELOG. Pre-existing for the phase-1 missing-membership heal.

Key decisions

Tolerate the wrong-host Work URL instead of quarantining the hostname

Decision 1, superseding multi-site-works Q97. Q97 made the value a thrown tuple failure on the reasoning that nothing repairs it. On the real library that quarantine took rule application off every webtoons capture for weeks over one Work's link. Building the repair removes Q97's own condition for refusal, so the decision is amended on its terms rather than reversed on the merits. Rejected: keeping the quarantine and adding the heal (until the pass runs every capture is still conservative, and a device that never runs the pass keeps the disproportion), and relaxing isOnHost to accept a sibling host (breaks the property commitWorkURL relies on, and "same site modulo one label" is a guess with no public-suffix logic behind it).

Move the address; mint the membership, never the Site row

Decision 2. A Work URL is a site-specific address, so the value is correct data on the wrong row and a move records the truth. The minted membership has no URL identity, a creation timestamp that cannot make it primary, and site: nil — the shape MembershipReconciler.heal already produces, completed by SiteReconciler.heal when the row arrives. Rejected: blanking the value (deletes reader-confirmed content the move can preserve), and minting the Site row too (reconciliation creating Sites is exactly what cloudkit-mirroring Q40 removed after it minted one synthetic Site per hostname during hydration). Accepted cost (Q16): a standing .siteMissing row and one extra affected record until something is captured from that host.

Import normalises the shape rather than refusing the archive

Decision 3, amending library-integrity-tolerance Decision 3's "import stays strict" boundary for this one shape. An archive legal apart from one field with a deterministic repair should import; refusing it made old backups unusable for a defect the reader did not cause. The gate's own header comment carries the exception so a later reader does not restore the throw. Rejected: keep refusing (old archives stay un-importable), and import as-is with no normalisation (leaves a known-wrong value in a fresh library for a heal round-trip when the archive itself holds the right destination).

Key the diagnosis per Work, not per hostname (Q9)

A tuple diagnosis keeps one finding per hostname, so two Works with off-host URLs on one site — or a torn group holding the value on several rows — would collapse into one row or count as several. The Check Library row's route is the Work, so the Work is the natural key. The precedent is workWithoutMembership (multi-site-works Q30).

Both derivations produce the diagnosis, keyed on the workID column (Q10, Q48)

refreshDiagnostics carries forward only tuple diagnoses and replaces the tolerated set with the foreground pass's output, so a state only the full validator produced would vanish on the first refresh — and the heal's gate would have no source. Both producers key on the membership's workID column rather than the work relationship, because the two disagree for a row whose Work has not arrived; keying on the column is what makes the two derivations equal (Req 2.4). A row with a nil column but a live relationship stays a hostname-keyed .siteTuple (Q58).

A value that is not an absolute HTTP(S) URL stays out of scope (Q14, Q27, Q63)

It is malformed rather than residue: no known build wrote one, nothing repairs it, and library-integrity-tolerance Decision 4 keeps malformed values loud. Consequences that fall out: such a value blocks a move onto its hostname (Q27), a blank string counts as held rather than empty (Q63), and a hostname whose rows hold both an on-host address and a malformed value blocks the land rather than removing the source (Q71).

Blocked closure then one ordered pass, not a bare fixpoint (Q49, Q34)

"Fixpoint" alone admits the greatest fixpoint, under which every cycle is blocked. The closure-then-pass form is an algorithm the property tests can pin, and it makes a chain into an on-host value resolve as remove-then-land. A block stated only for the immediate destination let a chain a→b→c with c malformed put two values on b; stating it as a least fixpoint keeps cycles rotating and collisions single-winner while a blocked chain stays put and reported.

Candidate order on synced fields only, so two devices agree (Q24, Q26)

Source hostname ascending, then the membership survivor comparator (identity state, creation time, id). Without an order, two devices healing concurrently could keep different values and, after sync, each would remove the other's — a deletion neither chose. A row-order tie-break would fall back to fetch order, which two devices need not share. The same order settles torn rows disagreeing on one source hostname, which is why the diagnosis can name a host at all (Q35).

The gate is derived from published diagnostics rather than latched (Q46, Q28)

Three publishers (open, refresh, import completion) would each have to set a latch. The neighbouring worksWithoutMembership latch is deliberately unset at open, which is the wrong precedent here: the launch pass only refreshes when its outcome is non-empty, so copying it would leave a diagnosed value unhealed at launch and its diagnosis standing after the repair. MembershipReconcileReport.isEmpty therefore includes movedWorkURLs.

The heal runs before de-duplication and before torn-group propagation (Q13, Q37)

Propagation copies membership values per hostname across a torn group, so a heal running after it would have its clears undone on the next pass, breaking idempotence. De-duplication runs ungated on every pass and deletes losers whole, so it would fold two torn rows holding different off-host values before either was placed. Propagation additionally gains an isOnHost guard (Req 1.5) so it stops multiplying the residue.

Folds carry a discarded row's Work URL, off-host values included (Q33, Q41, Q54, Q62)

One value-returning helper serves all three folds, because three sites folding by the same survivor order should share the one carry rule or they drift as the propagation and collapse rules once did. Value-returning rather than assigning, because the export projection is a read-only fold that must not dirty its context. Off-host values are carried too: de-duplication is ungated while the heal has a one-derivation lag, so an on-host-only carry would delete an off-host value before the heal ever saw it. The collapse keeper is resolved over the whole survivor group, because a hostname can be in the survivor set because a sibling row holds it.

Import decides on the post-import value, and only where the write will land (Q30, Q32)

The import applies an archive record's Work URL unconditionally, nil included, so the pre-import library value is not what the destination ends up holding; deciding on it would move a URL onto a record the same import then blanks. And commitMemberships skips updating an existing row whose Work the import does not apply — moving the URL onto a record whose write is then skipped, while importing the source without it, would leave the value on no row. Where the outcome is unknown, both records import unchanged and the heal decides.

Save on a Work boundary rather than once per Work (Q75)

Amends the design's "one save per Work". Req 3.11 asks that a Work's moves land together with the destination written before the source is cleared — a batch boundary on a Work edge gives that, while a save per Work made a settling pass repairing hundreds of Works pay a round trip each. Phase 1 already batches its mints the same way. The test that pinned two saves for two Works was pinning the implementation; it now uses three Works against a batch size of two, which pins both the batching and the Work edge.

Hostnames are compared case-insensitively throughout the heal (Q65, Q68)

Stored hostnames are not normalised while WorkURLPlanner.host(of:) always lowercases, so an Example.com membership was invisible to the resolver and would be minted a second time in a different case — a pair de-duplication never folds. The resolver's snapshot, on-host, blocked and claimed sets are all keyed on the lowercased hostname; the apply step matches destinations case-insensitively before minting; and both diagnosis producers plus the import pre-pass key on the lowercased source hostname so the Check Library row and the heal agree on how many pairs there are.

Review findings

SeverityAreaFindingResolution
majorbranch vs origin/mainBranch was behind origin/main by df1b3e5 (T-2295), which edits the same diagnosis switches, scan loop and view model.Merged origin/main (181da4e); one add/add hunk in LibraryDiagnostics.swift resolved keeping both; app build and make test-core green afterwards.
majorMembershipReconciler phase 1bOne save per diagnosed Work on the launch reconcile; a library-wide residue could mean thousands of transactions.Saves every batchSize Works with the boundary on a Work edge (Q75), mirroring phase 1. The save-boundary test was adjusted: it pinned the implementation, not Req 3.11.
majorLibraryToleranceScan.scanOff-host classification hand-rolled as isOnHost + host(of:) — two URL parses per membership on the foreground scan, and a second spelling of the Q63 rule.Uses WorkURLPlanner.offHost(_:on:) over classifyWorkURL; cheap dedupe test first. The import's offHostDestination collapsed into the same accessor.
majorLibraryRepository+ConfirmImportwillWriteMembership restated the commit loop's branch condition 140 lines away; drift would strip a source URL the loop then never writes.The commit loop now calls willWriteMembership; records classified once; Set(rowsByID.keys).
majorValidator vs scan derivationThe [WrongHostMembership] derivation existed twice (validator and scan) with nothing enforcing Req 2.4 agreement.WrongHostMembership.pairs(from:) is the one derivation both producers call; pairs keyed on the lowercased hostname (Q68).
minorHostname caseProducers keyed pairs on the stored spelling while the heal keyed lowercased; import candidates sorted on the raw hostname.Both producers and the import order on the lowercased hostname (Q68).
minorDuplicateReconciler propagationCarrier membership classification repeated per target row; a redundant second sort in the collapse delete loop.Hoisted out of the row loop; Dictionary(grouping:); second sort dropped.
minorMembershipReconciler apply stepEager resolvedWorkID index over the whole table for a rare branch; uuidString allocations in sorts; abandoned Set<Int> coupling two loops by index; hand-rolled case-insensitive clear loop.Index built only for planned Works; UUID.bytesPrecede; applied: [Move] array; Work.memberships(in:forHostMatching:).
minorFold carryThree near-identical carry call sites; dedupe and export re-sorted already-ordered losers.carryWorkURL(to:from:inSurvivorOrder:) for the two mutating folds; inSurvivorOrder flag skips the re-sort.
minorMaintenanceViewModelsDuplicated work-noun quoting; three formats for a source→host pair; resolution as two parallel comma lists.One workNoun with optional id, one perPair formatter, resolution phrased per pair (Q74).
minorBackupImportGatesRefusal detail re-spelled the .wrongHostWorkURL description; raw "WorkSiteMembership" literals.LibraryValidationError.wrongHostClause shared with description; membershipType constant at the new sites.
minorStale commentsThree comments still said the tolerated set has three members.LibraryValidator, +Bootstrap, +RecentPresentation updated to four.
minorAPI surfaceStoredWorkURL, holdsWorkURL, classifyWorkURL, membership(in:forHostMatching:) public with no cross-module caller.Made internal; isOnHost stays public (WorkDetailModel uses it).
nitWrongHostWorkURLResolverUnreachable `?? ""`; NUL-joined composite string key.Bound the value; private Candidate: Hashable.
nitGroupOrderingScan hand-rolled 'least under representative order' so representativeComponents was widened to internal.GroupOrdering.keepRepresentative; representativeComponents private again.
nitUnrecorded rulesImport order without survivor tie-break; resolver de-dup on (hostname, value); blocked outranks on-host; isBlank in indexByWorkID; equal-key title tie.Recorded as Q69–Q73.
minorLibraryToleranceScan.Result.wrongHostWorkURLCountNo production reader; only tests read it.Kept with a diagnostic-only doc comment; removing it would only churn tests.
minorPerformance coverageNo M4 fixture arm or perf case covers the per-membership work in indexByWorkID or the phase-1b save cadence; make test-performance-m4 (~21 min) not run.Left for the author — see Double-check.
nitReq 2.1 evidenceWorkMergeModel / SettingsSyncModel.isHealthy asserted by quarantineMap proxy in the core package rather than the app models task 6 named.Left; code path is the same computed property. Open question in the review.

Per-file diffs

Click to expand.

Asterism/Asterism/ViewModels/MaintenanceViewModels.swift Modified +87 / -7
diff --git a/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift b/Asterism/Asterism/ViewModels/MaintenanceViewModels.swiftindex 86425bb..dffdd48 100644--- a/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift+++ b/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift@@ -278,11 +278,24 @@ public final class LibraryDiagnosticsModel {             workID: work?.id, workTitle: work?.title)     } -    /// T-2289: the Work a wrong-host Work URL diagnosis is repaired on.+    /// T-2289: the Work a wrong-host Work URL diagnosis is repaired on — the+    /// tolerated case (`wrong-host-work-url-heal` Req 2.5) as well as the tuple+    /// failure that stays for a membership naming no Work (Q31).+    ///+    /// The id is nil while the Work has not arrived, which is exactly when the+    /// row withholds the Open-work button: there is nothing to open yet.     private static func workNamed(by diagnosis: LibraryDiagnosis) -> (id: UUID?, title: String?)? {-        guard case .siteTuple(_, .wrongHostWorkURL(_, _, _, let workID, let workTitle)) = diagnosis-        else { return nil }-        return (workID.flatMap(UUID.init(uuidString:)), workTitle)+        switch diagnosis {+        case .siteTuple(_, .wrongHostWorkURL(_, _, _, let workID, let workTitle)):+            (workID.flatMap(UUID.init(uuidString:)), workTitle)+        case .wrongHostWorkURL(let workID, let workTitle, _):+            // Q48: the title is absent exactly while the Work has not arrived,+            // and there is no row to open then — so the id is withheld with it+            // and the button stays hidden rather than routing to nothing.+            (workTitle == nil ? nil : UUID(uuidString: workID), workTitle)+        default:+            nil+        }     }      private static func site(_ diagnosis: LibraryDiagnosis) -> String {@@ -301,14 +314,67 @@ public final class LibraryDiagnosticsModel {             "\(Pluralisation.count(entryCount, "entry", "entries")) and \(Pluralisation.count(workCount, "work", "works")) name this site, but the library holds no site record for it."         case .workWithoutMembership:             "This work is not on any site. It still opens and can still be deleted, and Asterism puts it back on a site as soon as one of its entries arrives."+        case .wrongHostWorkURL(let workID, let workTitle, let memberships):+            // `wrong-host-work-url-heal` Q45/Q57, one sentence per source+            // hostname, each naming **both** hostnames. The row's site line is+            // the first source hostname only, so "not this site" would attribute+            // a second pair to the wrong site. A Work that has not arrived is+            // named by its id prefix, which is all there is to name it by.+            // The noun is derived once — it is the same in every sentence.+            Self.perPair(memberships, noun: Self.workNoun(workID: workID, workTitle: workTitle)) {+                noun, source, actual in+                "\(noun) has a link saved under \(source) that is on \(actual)."+            }         }     } +    /// The Work as a Check Library row can name it: its title where it has+    /// arrived, "A work" plus enough of its id to tell two apart where the+    /// diagnosis carries one, and "the work" where it carries neither.+    private static func workNoun(workID: String?, workTitle: String?) -> String {+        if let workTitle, !workTitle.isEmpty { return "“\(workTitle)”" }+        guard let workID else { return "the work" }+        return "A work (\(workID.prefix(8)))"+    }++    /// One phrase per `(source hostname, actual host)` pair, joined.+    ///+    /// Q57: every line that names a pair names **both** of its hostnames, so a+    /// Work holding off-host links under two sites can never be phrased as two+    /// parallel comma lists the reader has to align by position — which is what+    /// the resolution line did. Three lines say something about the pairs, and+    /// this is the one place that walks them.+    private static func perPair(+        _ memberships: [WrongHostMembership], separator: String = " ",+        _ phrase: (String, String) -> String+    ) -> String {+        memberships+            .map { phrase($0.hostname, $0.actualHost) }+            .joined(separator: separator)+    }++    /// The same, for the one line that also names the Work in every sentence.+    private static func perPair(+        _ memberships: [WrongHostMembership], noun: String,+        _ phrase: (String, String, String) -> String+    ) -> String {+        perPair(memberships) { phrase(noun, $0, $1) }+    }+     /// T-2289: the reader could not tell which rule the tuple diagnosis meant,     /// so a row that would not clear looked like the app refusing for no reason.     private static func detail(_ diagnosis: LibraryDiagnosis) -> String? {-        guard case .siteTuple(_, let reason) = diagnosis else { return nil }-        return reason.description+        switch diagnosis {+        case .siteTuple(_, let reason):+            reason.description+        case .wrongHostWorkURL(_, _, let memberships):+            // `wrong-host-work-url-heal` Req 2.5: the pairs, so a Work holding+            // off-host addresses under two sites shows both rather than the+            // problem line's first hostname alone.+            Self.perPair(memberships, separator: ", ") { "\($0) → \($1)" }+        default:+            nil+        }     }      private static func recordCountText(_ diagnosis: LibraryDiagnosis) -> String {@@ -317,6 +383,9 @@ public final class LibraryDiagnosticsModel {             Pluralisation.count(diagnosis.recordCount, "site record affected", "site records affected")         case .siteMissing, .workWithoutMembership:             Pluralisation.count(diagnosis.recordCount, "record affected", "records affected")+        case .wrongHostWorkURL:+            Pluralisation.count(+                diagnosis.recordCount, "work record affected", "work records affected")         }     } @@ -327,7 +396,7 @@ public final class LibraryDiagnosticsModel {             // URL that is not on this site — so the repair is the Work's URL,             // and re-teaching would not touch it. The Work is named, and the             // row offers to open it.-            "Re-teaching cannot clear this. Open \(workTitle.map { "“\($0)”" } ?? "the work") and, under Work URL, confirm an address on \(hostname) or clear it."+            "Re-teaching cannot clear this. Open \(Self.workNoun(workID: nil, workTitle: workTitle)) and, under Work URL, confirm an address on \(hostname) or clear it."         case .siteTuple:             // Req 3.4 either way: when there is nothing to re-teach from, the             // row still says re-teaching *would* clear this — it names the@@ -355,6 +424,17 @@ public final class LibraryDiagnosticsModel {             // entries, and it needs no reader action at all. A work with no             // entries left has nothing to repair from and can be deleted.             "Nothing to do: Asterism restores the site as soon as an entry for it arrives. A work with no entries can be deleted."+        case .wrongHostWorkURL(_, _, let memberships):+            // Q11/Q45: the row carries both the heal promise and the manual+            // repair, because until the pass runs the reader's own edit is the+            // faster fix and the row should not withdraw it. Q74: the promise is+            // phrased **per pair** rather than as two comma lists the reader has+            // to read in parallel to learn which link goes where.+            "Asterism will move "+                + Self.perPair(memberships, separator: ", ") { "the \($0) link to \($1)" }+                + " on its next check. To fix it now, open the work and, under Work URL, "+                + "confirm an address on "+                + "\(memberships.map(\.hostname).joined(separator: ", ")) or clear it."         }     } 
Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift Modified +72 / -0
diff --git a/Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift b/Asterism/AsterismTests/LibraryDiagnosticsModelTests.swiftindex 8553509..ec4b7f0 100644--- a/Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift+++ b/Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift@@ -278,6 +278,78 @@ struct LibraryDiagnosticsModelTests {         #expect(row.detail?.contains("it is on m.example") == true)     } +    /// `wrong-host-work-url-heal` Req 2.5. The tolerated diagnosis — the one the+    /// heal repairs — reads as a Work-level problem, names **both** hostnames of+    /// every pair (Q57), lists the pairs as its detail, and routes to the Work so+    /// the reader can make the fix the resolution line offers.+    @Test("A tolerated wrong-host Work URL row names both hosts and opens the work")+    func toleratedWrongHostWorkURLRowNamesBothHosts() async throws {+        let workID = UUID()+        let (model, _) = Self.model(+            Self.diagnostics(+                tolerated: [+                    .wrongHostWorkURL(+                        workID: workID.uuidString, workTitle: "A Serial",+                        memberships: [+                            WrongHostMembership(hostname: "a.example", actualHost: "b.example"),+                            WrongHostMembership(hostname: "c.example", actualHost: "d.example"),+                        ])+                ]))+        await model.load()++        let row = try #require(model.rows.first)+        // The site line is the *first* source hostname, which is exactly why the+        // second sentence has to name `c.example` itself.+        #expect(row.site == "a.example")+        #expect(row.problem.contains("“A Serial” has a link saved under a.example that is on b.example."))+        #expect(row.problem.contains("“A Serial” has a link saved under c.example that is on d.example."))+        #expect(!row.problem.localizedCaseInsensitiveContains("not this site"))+        #expect(row.detail == "a.example → b.example, c.example → d.example")+        #expect(row.recordCountText == "1 work record affected")+        #expect(row.reteachHostname == nil)+        #expect(row.workID == workID)+        #expect(row.workTitle == "A Serial")+        // Q45 in full: the heal promise first, then the manual repair, because+        // until the pass runs the reader's own edit is the faster fix. Both+        // halves name every host of the pairs, in the same order.+        #expect(+            row.resolution == "Asterism will move the a.example link to b.example, the c.example "+                + "link to d.example on its next check. To fix it now, open the work and, under "+                + "Work URL, confirm an address on a.example, c.example or clear it.")+        #expect(!row.resolution.localizedCaseInsensitiveContains("re-teach"))+    }++    /// The same row while the Work has not arrived: nothing to open, so no+    /// Open-work button, and the id prefix is all there is to name it by.+    @Test("A wrong-host Work URL row for an unarrived Work offers no work to open")+    func unarrivedWrongHostWorkURLRowOffersNoWork() async throws {+        let workID = UUID()+        let (model, _) = Self.model(+            Self.diagnostics(+                tolerated: [+                    .wrongHostWorkURL(+                        workID: workID.uuidString, workTitle: nil,+                        memberships: [+                            WrongHostMembership(hostname: "a.example", actualHost: "b.example")+                        ])+                ]))+        await model.load()++        let row = try #require(model.rows.first)+        #expect(row.workTitle == nil)+        #expect(row.workID == nil, "there is no Work row to open yet")+        #expect(+            row.problem+                == "A work (\(workID.uuidString.prefix(8))) has a link saved under a.example that is on b.example.")+        // The rest of the row is the arrived row's: still not re-teachable, still+        // one work record, and the resolution still promises the heal — which is+        // the only repair available while there is no Work to open.+        #expect(row.reteachHostname == nil)+        #expect(row.recordCountText == "1 work record affected")+        #expect(row.detail == "a.example → b.example")+        #expect(row.resolution.hasPrefix("Asterism will move the a.example link to b.example"))+    }+     @Test("Taking the open-work route hands the Work id to the caller")     func openWorkRouteHandsBackTheWorkID() async {         final class Box: @unchecked Sendable { var value: UUID? }
CHANGELOG.md Modified +60 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 16a6452..f07af30 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -26,6 +26,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Changed +- **Compatibility and records (wrong-host-work-url-heal, phase 3,+  T-2294).** `WrongHostWorkURLCompatibilityTests` pin that the heal changes+  no schema or archive version, that a heal-minted membership is spelled+  in the 7/8 wire shape with `urlIdentityState: "none"`, and that a+  membership minted on a host with no `Site` row round-trips through+  export and import (the projection's synthesised untaught wire Site+  becomes a real row). The golden export is unchanged. `multi-site-works`+  Q97 is superseded by this spec's Decision 1; `library-integrity-tolerance`+  Decisions 3 and 4 carry the import exception and the fourth tolerated+  state.++- **Heal, import and Check Library row (wrong-host-work-url-heal,+  phase 2, T-2294).** `MembershipReconciler` gains phase 1b: a pure+  `WrongHostWorkURLResolver` (blocked closure over malformed values, then+  one pass in `(source hostname, survivor order)` with a per-destination+  claim, Q49) and an apply step that lands each off-host Work URL on the+  membership for its host — minting the membership on the first row that+  already holds one (Q61), matching hostnames case-insensitively (Q65),+  never over a row that already holds a value (Q66) — destinations before+  sources, saving on Work boundaries, gated by the derived `healsWrongHostWorkURL`+  (Q46) and reported in `movedWorkURLs`, which the launch pass refreshes+  on. De-duplication, merge collapse and export carry a discarded row's+  Work URL via `carriedWorkURL`; the collapse keeper is resolved over the+  whole survivor group (Q62). Per-hostname Work URL propagation copies only+  a value on its own host (Req 1.5). One `WorkURLPlanner.classifyWorkURL`+  rule decides empty/on-host/off-host/other everywhere (Q63). Backup import+  normalises an off-host value in a pre-pass where the destination write is+  known to land (sequential, one pass, Q64) and no longer refuses the+  archive for it; the plan gate names the membership id and hostname (Q55).+  The Check Library row for the tolerated case is pinned by tests.++- **Diagnosis (wrong-host-work-url-heal, phase 1, T-2294).** A membership+  Work URL on another host is now a Work-keyed tolerated diagnosis+  (`LibraryDiagnosis.wrongHostWorkURL`) that quarantines nothing, instead+  of a hostname-keyed `.siteTuple` that took the site's rules away (Decision+  1). Both producers emit the same diagnosis for the same store: the full+  validation (`LibraryValidator.run(graph:)` accumulates per Work and takes+  the representative row's title, Q53; the narrowed hostname arm drops it,+  Q47) and the foreground `LibraryToleranceScan` (value snapshots only, no+  `Work` reference outlives an `enumerate` batch). A torn group disagreeing+  on one hostname names the value the heal will keep (Q35): `indexByWorkID`+  and `MembershipReconciler.survivorFirst` share one+  `WorkSiteMembership.OrderKey`, built only for groups with more than one+  row (Q60). `WorkMembershipBasis.workURLString`, the `MembershipReconciler.Heals`+  option set, `MembershipReconcileReport.movedWorkURLs` and the derived+  `LibraryRepository.healsWrongHostWorkURL` gate (Q46) are in place for the+  phase-2 heal, which does not run yet. The Check Library row names both+  hostnames per pair (Q57), carries the pairs as detail and opens the Work+  once it has arrived. Nil-`workID`-column memberships stay outside the+  tolerance (Q58); `affectedRecordCount` skips a Work only for a+  `.siteMissing` with `workCount > 0` on a source hostname (Q59).+ - **Deletions and verification (drop-superseded-columns, phase 3,   T-2280).** The V7-shaped test facade (`MembershipTestSupport`'s column   doors, `WorkMergeOutcome.workURL/identityEvidence/identityDisposition`)@@ -437,6 +489,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Fixed +- **A library holding a heal-minted membership could not be backed up+  (T-2294, Q67).** `MembershipReconciler.healCreatedAt` returned+  `latest + 0.001 s` unquantized; the archive's date encoding rounded it on+  the way out, so `BackupV7Exporter`'s decode-validation refused the file it+  had just written. Pre-existing for the missing-membership heal. The mint+  now quantizes through `MillisecondInstant`, so two devices healing the+  same Work still agree.+ - **A retired rule row that will not decode no longer quarantines its   site, and Check Library names the row it diagnoses (T-2289).** The   validator decoded every retained title pattern and URL rule, so one bad
Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift Modified +28 / -9
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swiftindex eb023b3..d4b23ae 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift@@ -675,15 +675,34 @@ extension LibraryRepository {             byKey[MembershipReconciler.Key(workID: workID, hostname: row.hostname), default: []]                 .append(row)         }-        let folded = byKey.values.compactMap { MembershipReconciler.survivorFirst($0).first }-        return (folded + unattributed)-            .map {-                BackupV7Membership(-                    id: $0.id, workID: $0.resolvedWorkID, hostname: $0.hostname,-                    createdAt: $0.createdAt, urlIdentity: $0.urlIdentity,-                    urlIdentityState: $0.urlIdentityState,-                    urlIdentityRuleID: $0.urlIdentityRuleID, workURLString: $0.workURLString)-            }+        func record(_ row: WorkSiteMembership, workURLString: String?) -> BackupV7Membership {+            BackupV7Membership(+                id: row.id, workID: row.resolvedWorkID, hostname: row.hostname,+                createdAt: row.createdAt, urlIdentity: row.urlIdentity,+                urlIdentityState: row.urlIdentityState,+                urlIdentityRuleID: row.urlIdentityRuleID, workURLString: workURLString)+        }++        // `wrong-host-work-url-heal` Req 1.6, Q17: the folded-away rows' Work URL+        // comes with the survivor. A heal-minted row is in identity state+        // `none`, so a later rule-identity row for the same hostname sorts ahead+        // of it — and without this the address the heal had just preserved would+        // leave the archive silently, which is the one thing+        // [4.2](../../../../specs/wrong-host-work-url-heal/requirements.md#42)+        // cannot survive. Nothing is written back: the fold returns the value+        // and the record carries it (Q54).+        var records: [BackupV7Membership] = []+        for rows in byKey.values {+            let ordered = MembershipReconciler.survivorFirst(rows)+            guard let keeper = ordered.first else { continue }+            records.append(+                record(+                    keeper,+                    workURLString: MembershipReconciler.carriedWorkURL(+                        survivor: keeper.workURLString, losers: Array(ordered.dropFirst()),+                        inSurvivorOrder: true)))+        }+        return (records + unattributed.map { record($0, workURLString: $0.workURLString) })             .sorted { $0.id.uuidString < $1.id.uuidString }     } 
Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift Modified +69 / -15
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swiftindex 5dc0f5d..fae4a5a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift@@ -747,8 +747,14 @@ enum DuplicateReconciler {         // set whose members are on *different* sites (Req 5.2), and deleting the         // loser's membership there would drop a site presence the reader was         // asked to keep.-        var survivorHostnames = Set(-            survivor.flatMap { $0.membershipValues.map(\.hostname) })+        // Indexed rather than flattened to a name set, because the carry below+        // needs the **rows** (`wrong-host-work-url-heal` Q62): a hostname can be+        // in this set because a *sibling* survivor row holds it, and asking+        // `target` alone for the keeper found nothing and dropped the loser's+        // address — the loss 1.6 exists to prevent.+        let survivorRowsByHostname = Dictionary(+            grouping: survivor.flatMap(\.membershipValues), by: \.hostname)+        var survivorHostnames = Set(survivorRowsByHostname.keys)          // One id-set fetch per chunk of losers rather than one fetch per loser:         // a settling pass collapses hundreds of sets, and each loser's fetch was@@ -768,6 +774,13 @@ enum DuplicateReconciler {         }          var removed = 0+        // The rows this collapse discards, by hostname, and the row that keeps+        // each hostname where a loser's was *moved* into the slot. Collected+        // before anything is deleted: the carry below sorts the discarded rows+        // by their ordering columns, and a deleted model is not a row to read+        // three columns off.+        var discardedByHostname: [String: [WorkSiteMembership]] = [:]+        var movedByHostname: [String: WorkSiteMembership] = [:]         // Sorted, and each Work's rows in membership order: which loser's row is         // *moved* for a hostname the survivor lacks depends on which is reached         // first, and `Set` iteration is per-process seeded.@@ -776,8 +789,33 @@ enum DuplicateReconciler {                 if survivorHostnames.insert(membership.hostname).inserted {                     membership.workID = target.id                     membership.work = target+                    movedByHostname[membership.hostname] = membership                     continue                 }+                discardedByHostname[membership.hostname, default: []].append(membership)+            }+        }+        // `wrong-host-work-url-heal` Req 1.6: a discarded row's confirmed Work+        // URL goes to the row that keeps the hostname, where that row holds+        // none. The rule is `MembershipReconciler`'s, shared with the other two+        // folds so a collapse and a de-duplication cannot disagree about which+        // address survives.+        //+        // The keeper is the row a loser's membership was *moved* into where+        // there is one, and otherwise the survivor order's first over the whole+        // survivor group — every survivor row's membership on that hostname, not+        // `target`'s alone (Q62).+        for (hostname, discarded) in discardedByHostname.sorted(by: { $0.key < $1.key }) {+            guard let keeper = movedByHostname[hostname]+                    ?? MembershipReconciler.survivorFirst(+                        survivorRowsByHostname[hostname] ?? []).first+            else { continue }+            MembershipReconciler.carryWorkURL(to: keeper, from: discarded)+        }+        // Unordered: deleting rows is order-independent, and the carry above is+        // the only part of this that has to be the same on every device.+        for discarded in discardedByHostname.values {+            for membership in discarded {                 context.delete(membership)                 removed += 1             }@@ -1289,6 +1327,28 @@ enum DuplicateReconciler {         // The carrier's **stored** assignment: what propagates is what the         // carrier row holds, not the canonicalized key the comparison uses.         let carrierAssignment = WorkTypeAssignment.assignment(of: carrier)+        // The carrier's confirmed Work URLs, **per hostname**. A Work URL is a+        // site-specific address (Q8), so reading one off the carrier's primary+        // membership and writing it onto whichever membership of the row sorted+        // first published a `b.example` address as the row's `a.example` link+        // the moment either row was on more than one site — the single-site+        // assumption this feature removes.+        //+        // `wrong-host-work-url-heal` [1.5](../../../../specs/wrong-host-work-url-heal/requirements.md#15):+        // and only a value that is **on** that hostname. The carrier may still+        // hold an off-host address an older build wrote; copying it across the+        // group would multiply the residue the heal is there to move, and the+        // heal runs first (Q13) so a value still here on this pass is one it+        // could not place. Classified **once** for the whole fan-out: it is a+        // property of the carrier, and re-parsing every carrier URL per row made+        // a torn group of n rows pay n times for one answer.+        let carriedURLs: [(hostname: String, url: String)] = carrier.membershipValues+            .compactMap { membership in+                guard let url = membership.workURLString,+                      WorkURLPlanner.isOnHost(url, hostname: membership.hostname)+                else { return nil }+                return (membership.hostname, url)+            }         for row in rows where row !== carrier {             if row.genericNotes != carrier.genericNotes {                 row.genericNotes = carrier.genericNotes@@ -1306,20 +1366,14 @@ enum DuplicateReconciler {                     changed = true                 }             }-            // The carrier's confirmed Work URLs, **per hostname**. A Work URL is-            // a site-specific address (Q8), so reading one off the carrier's-            // primary membership and writing it onto whichever membership of the-            // row sorted first published a `b.example` address as the row's-            // `a.example` link the moment either row was on more than one site —-            // the single-site assumption this feature removes. A hostname the-            // row is not on is skipped: propagation carries authored content-            // across a torn group, it does not mint site presence.-            for carried in carrier.membershipValues {-                guard let url = carried.workURLString,-                    let target = row.membership(for: carried.hostname),-                    target.workURLString != url+            // A hostname the row is not on is skipped: propagation carries+            // authored content across a torn group, it does not mint site+            // presence.+            for carried in carriedURLs {+                guard let target = row.membership(for: carried.hostname),+                    target.workURLString != carried.url                 else { continue }-                target.workURLString = url+                target.workURLString = carried.url                 changed = true             }             // The carrier's own ordering, not the normalised one the comparison
Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift Modified +18 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swiftindex 7bf00b1..2d087b1 100644--- a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift@@ -346,6 +346,24 @@ public enum GroupOrdering {         stableSorted(rows, key: representativeComponents)     } +    /// Folds one row into a group's running representative: `running` ends up+    /// holding the value taken from the **least** row under representative+    /// order, ties going to the row met first — which is what+    /// `sortedWorkRows(_:).first` answers over a group held whole.+    ///+    /// For a caller that walks the rows in `enumerate` batches and must not+    /// retain them (`wrong-host-work-url-heal` Q60): it copies the value it+    /// needs out of the row inside the batch that produced it and lets this+    /// keep the least. Living here rather than at the call site is also what+    /// keeps `representativeComponents` private to this file.+    static func keepRepresentative<Value>(+        _ running: inout (key: [OrderComponent], value: Value)?, of row: Work, value: Value+    ) {+        let key = representativeComponents(row)+        if let running, OrderComponent.compare(key, running.key) != .orderedAscending { return }+        running = (key, value)+    }+     public static func sortedPatternRows(_ rows: [TitlePattern]) -> [TitlePattern] {         stableSorted(rows, key: representativeComponents)     }
Packages/AsterismCore/Sources/AsterismCore/LibraryDiagnostics.swift Modified +157 / -5
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryDiagnostics.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryDiagnostics.swiftindex 8a48824..5981285 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryDiagnostics.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryDiagnostics.swift@@ -51,6 +51,24 @@ public enum LibraryDiagnosis: Equatable, Sendable, Identifiable {     /// about named hostnames, and this Work belongs to none of them.     case workWithoutMembership(workID: String) +    /// `wrong-host-work-url-heal` Decision 1: a Work whose site membership holds+    /// a confirmed Work URL that is an absolute HTTP(S) address on **another**+    /// host.+    ///+    /// It is residue an older build left — the address is correct data on the+    /// wrong row — and it has a deterministic repair, so it is tolerated rather+    /// than quarantining the hostname the way `multi-site-works` Q97 made it.+    /// `MembershipReconciler` moves each value to the membership for the host it+    /// names ([3.1](../../../../specs/wrong-host-work-url-heal/requirements.md#31)–[3.3](../../../../specs/wrong-host-work-url-heal/requirements.md#33)).+    ///+    /// Keyed by the **Work** (Q9), not by hostname: two Works with off-host+    /// addresses on one site must not collapse into one row, and the Check+    /// Library row's route is the Work. `workTitle` is nil until the Work has+    /// arrived; `memberships` names one pair per source hostname, in hostname+    /// order (Q35).+    case wrongHostWorkURL(+        workID: String, workTitle: String?, memberships: [WrongHostMembership])+     /// Whether a hostname's rows are still a `.duplicateSiteRows` diagnosis:     /// true while any row but the winner owns a title pattern or URL rule.     ///@@ -79,6 +97,7 @@ public enum LibraryDiagnosis: Equatable, Sendable, Identifiable {         case .duplicateSiteRows(let hostname, _): "duplicateSiteRows:\(hostname)"         case .siteMissing(let hostname, _, _): "siteMissing:\(hostname)"         case .workWithoutMembership(let workID): "workWithoutMembership:\(workID)"+        case .wrongHostWorkURL(let workID, _, _): "wrongHostWorkURL:\(workID)"         }     } @@ -88,6 +107,9 @@ public enum LibraryDiagnosis: Equatable, Sendable, Identifiable {         case .duplicateSiteRows(let hostname, _): hostname         case .siteMissing(let hostname, _, _): hostname         case .workWithoutMembership: nil+        // The **first source** hostname, so the row lists under a site the+        // reader recognises rather than in the hostname-less tier.+        case .wrongHostWorkURL(_, _, let memberships): memberships.first?.hostname         }     } @@ -110,6 +132,9 @@ public enum LibraryDiagnosis: Equatable, Sendable, Identifiable {         case .duplicateSiteRows(_, let rowCount): rowCount         case .siteMissing(_, let entryCount, let workCount): entryCount + workCount         case .workWithoutMembership: 1+        // One Work, however many of its rows and memberships hold such a value+        // ([2.6](../../../../specs/wrong-host-work-url-heal/requirements.md#26)).+        case .wrongHostWorkURL: 1         }     } @@ -121,6 +146,7 @@ public enum LibraryDiagnosis: Equatable, Sendable, Identifiable {         case .duplicateSiteRows: 1         case .siteMissing: 2         case .workWithoutMembership: 3+        case .wrongHostWorkURL: 4         }     } @@ -133,6 +159,9 @@ public enum LibraryDiagnosis: Equatable, Sendable, Identifiable {         case .duplicateSiteRows(_, let rowCount): String(rowCount)         case .siteMissing(_, let entryCount, let workCount): "\(entryCount)/\(workCount)"         case .workWithoutMembership(let workID): workID+        case .wrongHostWorkURL(let workID, _, let memberships):+            workID + "\u{1F}"+                + memberships.map { "\($0.hostname)→\($0.actualHost)" }.joined(separator: ",")         }     } @@ -158,6 +187,63 @@ public enum LibraryDiagnosis: Equatable, Sendable, Identifiable {     } } +/// One source hostname of a `.wrongHostWorkURL` diagnosis, and the host the+/// address on it is actually on.+///+/// One pair per source hostname however many rows of a torn group hold a value+/// there: the host named is the one on the value first in the heal's candidate+/// order (Q35), which is what makes the two derivations agree.+public struct WrongHostMembership: Equatable, Sendable, Comparable {+    /// The membership's own hostname — where the address should have been.+    public let hostname: String+    /// The host the address names.+    public let actualHost: String++    public init(hostname: String, actualHost: String) {+        self.hostname = hostname+        self.actualHost = actualHost+    }++    /// By source hostname, then by the host named — the order the diagnosis+    /// lists them in. One diagnosis holds at most one pair per source hostname,+    /// so the second component only ever settles two pairs a caller built+    /// outside that rule, and it is there so the order stays total.+    public static func < (lhs: WrongHostMembership, rhs: WrongHostMembership) -> Bool {+        (lhs.hostname, lhs.actualHost) < (rhs.hostname, rhs.actualHost)+    }++    /// One Work's pairs, from its memberships **in candidate order**.+    ///+    /// Both producers of the diagnosis call this — the foreground scan over+    /// `indexByWorkID`'s value snapshots and the full validator over its rows —+    /// which is what makes [Req 2.4](../../../../specs/wrong-host-work-url-heal/requirements.md#24)'s+    /// "the same diagnosis" true rather than merely intended. Spelling the rule+    /// twice is how the two derivations drifted in the first place.+    ///+    /// Keyed on the **lowercased** source hostname (Q68), the same key the heal+    /// resolves on (Q65): a Work with an `Example.com` row and an `example.com`+    /// row is one source hostname to the heal, so it must be one pair here.+    /// The first value in candidate order wins, and the host it names is the+    /// pair's (Q35).+    static func pairs(+        from memberships: some Sequence<(hostname: String, value: String?)>+    ) -> [WrongHostMembership] {+        var byHostname: [String: String] = [:]+        for membership in memberships {+            let hostname = membership.hostname.lowercased()+            // The cheap test first: the URL parse is the expensive half, and a+            // hostname already answered needs neither.+            guard byHostname[hostname] == nil,+                  let actualHost = WorkURLPlanner.offHost(membership.value, on: hostname)+            else { continue }+            byHostname[hostname] = actualHost+        }+        return byHostname+            .map { WrongHostMembership(hostname: $0.key, actualHost: $0.value) }+            .sorted()+    }+}+ /// The library's overall size, needed only to tell a routine sync artefact from /// damage (Q21). A ratio cannot be computed from the diagnoses alone: they say /// how many records are unresolved, not how many exist.@@ -303,6 +389,14 @@ public struct LibraryDiagnostics: Equatable, Sendable {         // the groups above; the id keeps it counted once however many passes         // reported it.         var membershipLessWorks: Set<String> = []+        // The Works a `.wrongHostWorkURL` names, and the hostnames a+        // `.siteMissing` counts Works under. A Work already counted as an+        // orphaned Work on one of its **source** hostnames is not counted again+        // here (Q29): `.siteMissing` carries counts rather than ids, so the+        // hostnames the two diagnoses share are all the overlap that can be+        // reasoned about.+        var wrongHostWorkURLWorks: [(workID: String, hostnames: [String])] = []+        var missingSiteHostnames: Set<String> = []          for diagnosis in diagnoses {             switch diagnosis {@@ -313,15 +407,27 @@ public struct LibraryDiagnostics: Equatable, Sendable {             case .siteMissing(let hostname, let entryCount, let workCount):                 orphanedEntries[hostname] = max(orphanedEntries[hostname] ?? 0, entryCount)                 orphanedWorks[hostname] = max(orphanedWorks[hostname] ?? 0, workCount)+                if workCount > 0 { missingSiteHostnames.insert(hostname) }             case .workWithoutMembership(let workID):                 membershipLessWorks.insert(workID)+            case .wrongHostWorkURL(let workID, _, let memberships):+                wrongHostWorkURLWorks.append((workID, memberships.map(\.hostname)))             }         } +        // A second pass, because the `.siteMissing` a Work is already counted by+        // can come after it in the list.+        var countedWrongHostWorks: Set<String> = []+        for entry in wrongHostWorkURLWorks+        where !entry.hostnames.contains(where: missingSiteHostnames.contains) {+            countedWrongHostWorks.insert(entry.workID)+        }+         return siteRows.values.reduce(0, +)             + orphanedEntries.values.reduce(0, +)             + orphanedWorks.values.reduce(0, +)             + membershipLessWorks.count+            + countedWrongHostWorks.count     }      private static func shapeSuggestsDamage(@@ -335,9 +441,11 @@ public struct LibraryDiagnostics: Equatable, Sendable {     } } -/// The cheap half of diagnosis derivation (Decision 7): the three tolerated-/// states are entirely determined by identity columns, so recognising them needs-/// no rule replay and no tuple validation. That is what makes Req 1.5's+/// The cheap half of diagnosis derivation (Decision 7): the tolerated states are+/// entirely determined by identity columns — the wrong-host Work URL added by+/// `wrong-host-work-url-heal` included, which is one more scalar read on a walk+/// that already indexes the memberships — so recognising them needs no rule+/// replay and no tuple validation. That is what makes Req 1.5's /// re-derivation on foreground affordable while the full validator keeps running /// at open. Neither pass runs on the capture path in either process (Req 1.6). public enum LibraryToleranceScan {@@ -399,6 +507,15 @@ public enum LibraryToleranceScan {         public let entriesWithoutCitationBlobCount: Int         /// TitlePatterns whose `definitionData` is nil, on the same terms.         public let patternsWithoutDefinitionBlobCount: Int+        /// How many **Works** this walk found holding an off-host membership+        /// Work URL — the count beside the `.wrongHostWorkURL` diagnoses this+        /// pass emits, on the same terms as the counters above.+        ///+        /// **Diagnostic only.** It is not the heal's gate and nothing derives+        /// one from it: the gate reads the *published* diagnoses, so the open's+        /// full validation arms it too (Q46). It exists so a caller — the tests+        /// among them — can see what this walk found without re-deriving it.+        public let wrongHostWorkURLCount: Int          public init(             diagnoses: [LibraryDiagnosis], shape: LibraryShape,@@ -406,7 +523,8 @@ public enum LibraryToleranceScan {             workTypeCollisionCount: Int = 0,             worksWithoutMembershipCount: Int = 0,             entriesWithoutCitationBlobCount: Int = 0,-            patternsWithoutDefinitionBlobCount: Int = 0+            patternsWithoutDefinitionBlobCount: Int = 0,+            wrongHostWorkURLCount: Int = 0         ) {             self.diagnoses = diagnoses             self.shape = shape@@ -415,6 +533,7 @@ public enum LibraryToleranceScan {             self.worksWithoutMembershipCount = worksWithoutMembershipCount             self.entriesWithoutCitationBlobCount = entriesWithoutCitationBlobCount             self.patternsWithoutDefinitionBlobCount = patternsWithoutDefinitionBlobCount+            self.wrongHostWorkURLCount = wrongHostWorkURLCount         }     } @@ -508,13 +627,39 @@ public enum LibraryToleranceScan {         // orphan awaiting its Work (Req 8.3) and belongs to no hostname's count.         let membershipsByWorkID = try WorkSiteMembership.indexByWorkID(             context: context, batchSize: batchSize)+        // `wrong-host-work-url-heal` Req 2.4: the same diagnosis the full+        // validation produces, from the columns this walk already holds. The+        // index comes back in the heal's candidate order, so the pair for a+        // source hostname names the host on the value first in that order even+        // where a torn group disagrees (Q35) — `pairs` keeps the first it meets.+        var wrongHostPairs: [UUID: [WrongHostMembership]] = [:]         for (workID, memberships) in membershipsByWorkID {             for membership in memberships {                 workIDsByHostname[membership.hostname, default: []].insert(workID)             }+            let pairs = WrongHostMembership.pairs(+                from: memberships.lazy.map { (hostname: $0.hostname, value: $0.workURLString) })+            if !pairs.isEmpty { wrongHostPairs[workID] = pairs }         }+        // The diagnosed Works' titles, taken from the group's representative row+        // (Q53) at a cost proportional to findings rather than to the library —+        // the rule this whole routine is written under. A diagnosed `workID`+        // naming no row is a Work that has not arrived, and its diagnosis+        // carries no title.+        //+        // The title is copied out as a **value** inside the batch that produced+        // it (Q60). `GroupOrdering` reads a Work's primary membership, so+        // holding the rows until after `enumerate` and sorting them there would+        // fault `siteMemberships` on models the batch has already released;+        // `keepRepresentative` keeps the value from the least row seen, which is+        // what `sortedWorkRows(_:).first` answers over a group held whole.+        var diagnosedWorkTitles: [UUID: (key: [OrderComponent], value: String)] = [:]         try context.enumerate(FetchDescriptor<Work>(), batchSize: batchSize) { work in             workIdentities.record(work.id)+            if wrongHostPairs[work.id] != nil {+                GroupOrdering.keepRepresentative(+                    &diagnosedWorkTitles[work.id], of: work, value: work.displayTitle)+            }             // Q30's tolerated state, counted rather than diagnosed here: the             // full validator is what reports it, and this is the gate that             // decides whether an arrival pays for the heal that repairs it.@@ -573,6 +718,12 @@ public enum LibraryToleranceScan {                     entryCount: entriesByHostname[hostname] ?? 0,                     workCount: worksByHostname[hostname] ?? 0))         }+        for (workID, pairs) in wrongHostPairs {+            diagnoses.append(+                .wrongHostWorkURL(+                    workID: workID.uuidString, workTitle: diagnosedWorkTitles[workID]?.value,+                    memberships: pairs))+        }         // The identity tallies produce no diagnoses (Q57). They are counted, not         // reported: rows sharing an application UUID are `DuplicateWorkload`'s         // business, and this walk's only remaining interest in them is the@@ -603,7 +754,8 @@ public enum LibraryToleranceScan {             workTypeCollisionCount: workTypeCollisions(among: workTypeRows),             worksWithoutMembershipCount: worksWithoutMembership,             entriesWithoutCitationBlobCount: entriesWithoutCitationBlob,-            patternsWithoutDefinitionBlobCount: patternsWithoutDefinitionBlob)+            patternsWithoutDefinitionBlobCount: patternsWithoutDefinitionBlob,+            wrongHostWorkURLCount: wrongHostPairs.count)     }      /// How many normalized names more than one *visible* identity claims.
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift Modified +55 / -6
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swiftindex d4da7f5..9ef8405 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift@@ -10,8 +10,17 @@ extension LibraryRepository {     /// marker can be touched (Req 5.2). Per-Site quarantine diagnoses are an     /// import failure — an imported library must be wholly legal.     ///-    /// Strict on purpose: the open paths tolerate three states from this-    /// milestone on, and an import must keep refusing all three (Decision 3).+    /// Strict on purpose, with **one named exception**. The open paths tolerate+    /// four states; an import keeps refusing the first three — an Entry or Work+    /// on a hostname with no Site row, more than one Site row for a hostname,+    /// and two records of one type sharing an application UUID+    /// (`library-integrity-tolerance` Decision 3, amended). The fourth, a+    /// membership Work URL that is an absolute HTTP(S) URL on another host, is+    /// **normalised rather than refused**: `commitMemberships`' pre-pass moves+    /// the value onto the archive's own membership for the host it names, and+    /// what it cannot place imports unchanged for the heal+    /// (`wrong-host-work-url-heal` Decision 3, Q32, Q64). Only the shapes+    /// outside that tolerance still refuse here — see the note on `refusal`.     ///     /// Duplicate type identities are refused a step earlier, by the archive's own     /// reference validator — the strict duplicate arm for the type table lives@@ -42,11 +51,51 @@ extension LibraryRepository {         try materializeArchive(payload, into: context)         let diagnoses = try LibraryValidator.validateStrict(context: context)         guard diagnoses.isEmpty else {-            let (hostname, reason) = diagnoses.sorted { $0.key < $1.key }.first!-            throw LibraryValidationError.invalidStateTuple(-                type: "Site", id: hostname,-                reason: "imported Site is not legal: \(reason)")+            let (hostname, error) = diagnoses.sorted { $0.key < $1.key }.first!+            throw refusal(hostname: hostname, error: error)         }         return try rowCounts(context: context)     }++    /// The refusal a strict finding is reported as.+    ///+    /// Every refusal used to read "imported Site is not legal", whatever the+    /// finding, because the map is keyed by hostname and the Site arm was the+    /// only one anybody expected to fire. A membership finding names a *row*,+    /// not a designation, and a reader told their Site is illegal has nowhere to+    /// look — so a membership finding is reported as the membership's own tuple,+    /// naming the record id and the hostname it sits on+    /// (`wrong-host-work-url-heal` [1.4], Q20, Q55). Everything else keeps the+    /// wrapper it has always had.+    ///+    /// The only `wrongHostWorkURL` that reaches here is one outside the+    /// tolerance: a value that is not an absolute HTTP(S) URL (Q14) or a+    /// membership naming no Work (Q31). The tolerated shape is diverted by the+    /// validator's shared arm and never reaches this map.+    ///+    /// [1.4]: ../../../../specs/wrong-host-work-url-heal/requirements.md#14+    private static func refusal(+        hostname: String, error: LibraryValidationError+    ) -> LibraryValidationError {+        switch error {+        case .invalidStateTuple(let type, let id, let reason)+        where type == LibraryValidationError.membershipType:+            return .invalidStateTuple(+                type: type, id: id,+                reason: "imported work site membership on \(hostname) is not legal: \(reason)")+        case .wrongHostWorkURL(let membershipID, _, let actualHost, _, _):+            // The clause is the error's own (`wrongHostClause`), so the refusal+            // and the finding it wraps cannot say different things about the+            // same value.+            let detail = "its confirmed Work URL is "+                + LibraryValidationError.wrongHostClause(actualHost: actualHost)+            return .invalidStateTuple(+                type: LibraryValidationError.membershipType, id: membershipID,+                reason: "imported work site membership on \(hostname) is not legal: \(detail)")+        default:+            return .invalidStateTuple(+                type: "Site", id: hostname,+                reason: "imported Site is not legal: \(error)")+        }+    } }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swiftindex edc69fd..4597ed6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift@@ -522,7 +522,7 @@ extension LibraryRepository {      /// Store-level validation on the open path — over V9, the only schema this     /// package declares. States outside Req 1.1 still-    /// fail closed; the three tolerated states and every illegal Site tuple come+    /// fail closed; the four tolerated states and every illegal Site tuple come     /// back as diagnoses, so the library opens and quarantines what it must     /// (Q29, Req 1.1, 9.4).     @discardableResult
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift Modified +162 / -9
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swiftindex cd6fd40..2def58c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift@@ -17,6 +17,19 @@ public struct InterruptedImportReport: Codable, Sendable, Equatable {     } } +/// The one field the off-host pre-pass rewrites. `BackupV7Membership`'s+/// properties are `let`, so the value is rebuilt through the memberwise+/// initialiser rather than mutated — which is what keeps the record a value the+/// commit path can treat as verbatim.+extension BackupV7Membership {+    fileprivate func withWorkURLString(_ value: String?) -> BackupV7Membership {+        BackupV7Membership(+            id: id, workID: workID, hostname: hostname, createdAt: createdAt,+            urlIdentity: urlIdentity, urlIdentityState: urlIdentityState,+            urlIdentityRuleID: urlIdentityRuleID, workURLString: value)+    }+}+ // MARK: - The single import commit path  extension LibraryRepository {@@ -556,24 +569,38 @@ extension LibraryRepository {         guard !records.isEmpty else { return }         var rowsByID = Dictionary(             grouping: try context.fetch(FetchDescriptor<WorkSiteMembership>()), by: \.id)+        // `wrong-host-work-url-heal` Req 1.1–1.3, 1.7, 1.8: an off-host Work URL+        // is moved onto the archive's own membership for the host it names,+        // wherever this import is what decides the destination's value. Run+        // before the chunk loop so a Work's source and destination records are+        // both normalised whichever chunks they fall in (Q56 accepts that they+        // may not fall in the same one).+        let existingMembershipIDs = Set(rowsByID.keys)+        let normalized = Self.normalizedMemberships(+            records,+            existingMembershipIDs: existingMembershipIDs,+            appliedWorkIDs: appliedWorkIDs)         var touched: Set<UUID> = []-        for chunk in chunks(of: records, size: batchSize) {+        for chunk in chunks(of: normalized, size: batchSize) {             for record in chunk {+                // The pre-pass's own predicate, so "will this record be written"+                // has one answer here and there. An update of a record naming no+                // Work, or naming one whose record this import did not apply,+                // carries no timestamp this row can be judged by — and the+                // posture over an unjudgeable update is to keep what the library+                // holds. An insert is unconditional.+                guard Self.willWriteMembership(+                    record, existingMembershipIDs: existingMembershipIDs,+                    appliedWorkIDs: appliedWorkIDs)+                else { continue }                 let target = record.workID.flatMap { workTargets[$0] }                 if let rows = rowsByID[record.id], !rows.isEmpty {-                    // A record naming no Work, or naming one whose record this-                    // import did not apply, carries no timestamp this row can be-                    // judged by — and the posture over an unjudgeable update is-                    // to keep what the library holds.-                    guard let workID = record.workID, appliedWorkIDs.contains(workID) else {-                        continue-                    }                     for row in rows {                         apply(record, to: row)                         row.site = sitesByHostname[record.hostname] ?? row.site                         if let target { row.work = target }                     }-                    touched.insert(workID)+                    if let workID = record.workID { touched.insert(workID) }                 } else {                     let row = ArchiveRecordBuilders.makeMembership(                         record, work: target, site: sitesByHostname[record.hostname])@@ -586,6 +613,132 @@ extension LibraryRepository {         }     } +    // MARK: - The off-host Work URL pre-pass (`wrong-host-work-url-heal` Req 1)++    /// The archive's membership records with every off-host Work URL this import+    /// can place moved onto the record for the host it names.+    ///+    /// **Pure, and deliberately so.** It decides the whole precedence from the+    /// archive's own records plus two facts about the target library — which+    /// membership ids it already holds and which Works this import applied — so+    /// the sixteen cells of "will the destination be written × what will it hold"+    /// are testable without a store. `apply`/`makeMembership` stay a verbatim+    /// copy of whatever comes out of here (Q44).+    ///+    /// The rule is the heal's, applied only where the outcome is knowable+    /// (Decision 3, Q25, Q30, Q32):+    ///+    /// - A record naming no Work takes no part, as source or as destination: the+    ///   diagnosis is keyed by Work and the heal moves values on a Work, so an+    ///   orphan membership has neither (Q31).+    /// - The destination is the archive's own record of the same Work for the+    ///   host the URL names. The archive-level checks refuse a Work with two+    ///   memberships on one hostname, so it is unique (Q52).+    /// - It is **written** by this import iff the library holds no row with its+    ///   id (an insert, which is unconditional) or its Work is one this import+    ///   applied (the update branch's own gate). Where it is not written, what it+    ///   ends up holding is the library's value, which this pass cannot see, so+    ///   both records import unchanged and the heal decides.+    /// - Where it is written, its post-import value is the archive's: empty →+    ///   the URL moves onto it and the source imports without one ([1.8]);+    ///   on-host → the destination keeps its address and the source is stripped+    ///   ([1.2]); anything else (its own off-host value, or a malformed one) →+    ///   both import unchanged for the heal, which can mint the row this pass+    ///   cannot ([1.3], Q25).+    ///+    /// Candidates are taken in the heal's order — source hostname ascending+    /// (Q24) — so two values naming one host place the first and strip the+    /// second ([1.7]), and every device that imports this archive keeps the same+    /// one whatever order the records sit in the array.+    ///+    /// **One sequential pass, not a fixpoint** (Q64). Each candidate reads its+    /// destination's value at the moment it is considered, so a chain `a → b,+    /// b → c` with `c` empty moves `b`'s value onto `c` and leaves `a`'s where it+    /// is: when `a` was considered, `b` still held an off-host value of its own,+    /// which is the "leave both unchanged" cell above. Nothing here re-reads the+    /// chain, and deliberately so — the archive is a durable copy and the launch+    /// heal, which can also mint the row this pass cannot, closes the remainder.+    ///+    /// [1.2]: ../../../../specs/wrong-host-work-url-heal/requirements.md#12+    /// [1.3]: ../../../../specs/wrong-host-work-url-heal/requirements.md#13+    /// [1.7]: ../../../../specs/wrong-host-work-url-heal/requirements.md#17+    /// [1.8]: ../../../../specs/wrong-host-work-url-heal/requirements.md#18+    internal static func normalizedMemberships(+        _ records: [BackupV7Membership],+        existingMembershipIDs: Set<UUID>,+        appliedWorkIDs: Set<UUID>+    ) -> [BackupV7Membership] {+        var indicesByWork: [UUID: [Int]] = [:]+        for (index, record) in records.enumerated() {+            guard let workID = record.workID else { continue }+            indicesByWork[workID, default: []].append(index)+        }+        var normalized = records+        for indices in indicesByWork.values {+            // One destination per host. `Q52` says the archive cannot hold two+            // records for one `(Work, host)` pair; keeping the first rather than+            // asserting means a hand-built payload that does is merely ignored+            // instead of trapping.+            var destinations: [String: Int] = [:]+            for index in indices {+                let host = normalized[index].hostname.lowercased()+                if destinations[host] == nil { destinations[host] = index }+            }+            // Classified **once** per record, and ordered on the lowercased+            // hostname the destinations are keyed by (Q68): a source's own+            // classification cannot change under this pass — a record written as+            // a destination was empty, and an empty record is not a candidate —+            // so re-deriving it inside the loop was one URL parse per candidate+            // for an answer already in hand.+            let candidates = indices+                .compactMap { index -> (index: Int, hostname: String, host: String)? in+                    guard let host = WorkURLPlanner.offHost(+                        normalized[index].workURLString, on: normalized[index].hostname)+                    else { return nil }+                    return (index, normalized[index].hostname.lowercased(), host)+                }+                .sorted { $0.hostname < $1.hostname }+            for candidate in candidates {+                let source = candidate.index+                guard let value = normalized[source].workURLString,+                      let destination = destinations[candidate.host],+                      willWriteMembership(+                        normalized[destination],+                        existingMembershipIDs: existingMembershipIDs,+                        appliedWorkIDs: appliedWorkIDs)+                else { continue }+                // Held iff non-nil, blank included (Q63): `""` is a value the+                // validator reports and Check Library shows, so treating it as+                // free space would let this move write over it.+                switch WorkURLPlanner.classifyWorkURL(+                    normalized[destination].workURLString,+                    on: normalized[destination].hostname) {+                case .empty:+                    normalized[destination] = normalized[destination].withWorkURLString(value)+                    normalized[source] = normalized[source].withWorkURLString(nil)+                case .onHost:+                    normalized[source] = normalized[source].withWorkURLString(nil)+                case .offHost, .other:+                    continue+                }+            }+        }+        return normalized+    }++    /// Whether `commitMemberships` will write this record's values onto the+    /// library — the insert branch, which is unconditional, or the update+    /// branch, which is gated on the Work's record having been applied.+    private static func willWriteMembership(+        _ record: BackupV7Membership,+        existingMembershipIDs: Set<UUID>,+        appliedWorkIDs: Set<UUID>+    ) -> Bool {+        guard existingMembershipIDs.contains(record.id) else { return true }+        guard let workID = record.workID else { return false }+        return appliedWorkIDs.contains(workID)+    }+     /// The reader's dismissed pairs (Req 5.5, 5.8). Matched by row UUID and     /// value-guarded by `recordedAt`, which is the same comparable the     /// reconciler's latest-wins rule reads — so an older archive cannot undo a
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swiftindex 5d1a2ae..80bb8f2 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift@@ -89,7 +89,7 @@ extension LibraryRepository {             // `entry.site`'s own patterns. Converting this to a per-Entry             // `record.site` read would also charge 5,000 to-one faults to a path             // on a 2 s budget (Req 5.1).-            // More than one row for a hostname is one of the three tolerated+            // More than one row for a hostname is one of the four tolerated             // states, so this resolves a winner rather than throwing.             let sitesByHostname = SiteResolutionOrder.winnersByHostname(sites)             // `compactMapValues` drops the hostnames whose winning row retains an
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift Modified +26 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex a54654e..4335cdc 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -130,7 +130,7 @@ public actor LibraryRepository {     /// 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+    /// Re-derives the tolerated states from the store as it stands and     /// republishes `quarantined` from the merged map (Req 1.5).     ///     /// **The union is the point.** `LibraryToleranceScan` reads identity columns@@ -282,6 +282,22 @@ public actor LibraryRepository {     /// has nothing to repair and the next refresh arms it if it has.     internal var worksWithoutMembership = false +    /// Whether the published diagnoses hold a wrong-host Work URL — the gate for+    /// the heal that moves the address to the membership for its host+    /// ([3.7](../../../../specs/wrong-host-work-url-heal/requirements.md#37)).+    ///+    /// **Derived, not latched** (Q46). Three things publish diagnostics — the+    /// open's full validation, `refreshDiagnostics`, and an import completion —+    /// and each would otherwise have to remember to set a flag. The+    /// `worksWithoutMembership` latch above is deliberately false from the open,+    /// which is the wrong precedent here: a value the open's validation found+    /// must be healed on the launch pass rather than a refresh later (Q28).+    internal var healsWrongHostWorkURL: Bool {+        diagnostics.diagnoses.contains {+            if case .wrongHostWorkURL = $0 { true } else { 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@@ -344,6 +360,14 @@ public actor LibraryRepository {         }         let cachedTuples = diagnostics.tupleDiagnoses         let runsDuplicatePhase = duplicatePhaseRuns(tier: tier)+        // Both membership repairs are gated on the last derivation having+        // reported the state they repair, and they are read here — outside the+        // locked context — because both are properties of the published+        // diagnostics rather than of the store the pass is about to open.+        let membershipHeals: MembershipReconciler.Heals = [+            worksWithoutMembership ? .missingMembership : [],+            healsWrongHostWorkURL ? .wrongHostWorkURL : [],+        ]          bulkOperationInProgress = true         defer { bulkOperationInProgress = false }@@ -376,7 +400,7 @@ public actor LibraryRepository {             // find, whichever tier asked.             pass.memberships = try MembershipReconciler.run(                 context: context, batchSize: Self.bulkOperationBatchSize,-                saveStrategy: saveStrategy, heals: worksWithoutMembership)+                saveStrategy: saveStrategy, heals: membershipHeals)              // The hostnames this pass wrote to. The Site half is filtered by the             // cached tuple set — a hostname with nothing to shed costs nothing,
Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift Modified +95 / -7
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swiftindex 439e29f..8d7601b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift@@ -109,10 +109,26 @@ public enum LibraryValidationError: Error, Equatable, Sendable, CustomStringConv         case .unreadableTitlePattern(let id, let reason):             "Title rule \(id) cannot be read: \(reason)"         case .wrongHostWorkURL(let membershipID, let hostname, let actualHost, _, _):-            "Invalid WorkSiteMembership tuple \(membershipID): confirmed Work URL must be absolute HTTP(S) on \(hostname); "-                + (actualHost.map { "it is on \($0)" } ?? "it is not an absolute HTTP(S) URL")+            "Invalid \(Self.membershipType) tuple \(membershipID): confirmed Work URL must be absolute HTTP(S) on \(hostname); "+                + "it is \(Self.wrongHostClause(actualHost: actualHost))"         }     }++    /// The record type a membership finding names, spelled once. It appears in+    /// this error's own text and in the refusal the import gate wraps it in, and+    /// the two have to agree — a reader told about a `WorkSiteMembership` in one+    /// place and something else in the other has nowhere to look.+    static let membershipType = "WorkSiteMembership"++    /// What a wrong-host finding says about the value itself: the host it is+    /// actually on, or that it is not an absolute HTTP(S) URL at all.+    ///+    /// Read by this error's description **and** by the import gate's refusal,+    /// which puts it in a sentence of its own — the gate used to re-spell it,+    /// which is how the two texts drift.+    static func wrongHostClause(actualHost: String?) -> String {+        actualHost.map { "on \($0)" } ?? "not an absolute HTTP(S) URL"+    } }  public enum LibraryValidator {@@ -130,7 +146,7 @@ public enum LibraryValidator {     }      /// Tolerant store-level validation from a live context — the app and-    /// extension open paths. Throws on a fetch failure; the three tolerated+    /// extension open paths. Throws on a fetch failure; the four tolerated     /// states and every illegal Site tuple come back as diagnoses.     public static func validate(context: ModelContext) throws -> LibraryDiagnostics {         try fromContext(context) { try validate(graph: $0) }@@ -224,7 +240,16 @@ public enum LibraryValidator {             }             for membership in memberships {                 do { try validate(membership: membership) }-                catch let error as LibraryValidationError { record(hostname, error) }+                catch let error as LibraryValidationError {+                    // Q47: this arm returns a **tuple** map, and a wrong-host+                    // Work URL is not a tuple finding — it is the Work-keyed+                    // tolerated state `validate(graph:)` and the foreground pass+                    // report. Recording it here would re-quarantine a hostname+                    // the heal had just written to, for a neighbouring value the+                    // heal correctly left in place.+                    guard !isToleratedWrongHostWorkURL(error, on: membership) else { continue }+                    record(hostname, error)+                }             }             for work in winners(of: works, in: workIndex, id: { $0.id.uuidString }) {                 do {@@ -314,9 +339,14 @@ public enum LibraryValidator {     /// cross-Site duplicates and unresolved Entry/Work → Site references throw,     /// per-Site tuple failures come back as hostname-keyed diagnoses.     ///-    /// Reachable from three call sites only — the backup import gates — because+    /// Reachable from one call site only — the backup import plan gate — because     /// an archive must be wholly legal (Decision 3). Keeping the return type is-    /// what lets those gates stay untouched.+    /// what lets that gate stay untouched.+    ///+    /// One named exception since `wrong-host-work-url-heal` Decision 3: a+    /// membership Work URL on another host is diverted to the tolerated set by+    /// the shared arm below, so it never reaches this map and no longer refuses+    /// an archive on its own ([1.4](../../../../specs/wrong-host-work-url-heal/requirements.md#14)).     public static func validateStrict(graph: LibraryGraph) throws -> [String: LibraryValidationError] {         try run(graph: graph, strictness: .strict).tuple     }@@ -425,10 +455,44 @@ public enum LibraryValidator {         let membershipRows = graph.memberships.isEmpty             ? graph.works.flatMap(\.membershipValues) : graph.memberships         var seenMemberships: Set<ObjectIdentifier> = []+        // `wrong-host-work-url-heal` Req 2.3: the rows holding an off-host Work+        // URL, grouped by the Work they name, so the diagnosis below can name+        // one pair per source hostname.+        var wrongHostRows: [UUID: [WorkSiteMembership]] = [:]         for membership in membershipRows         where seenMemberships.insert(ObjectIdentifier(membership)).inserted {             do { try validate(membership: membership) }-            catch let error as LibraryValidationError { record(membership.hostname, error) }+            catch let error as LibraryValidationError {+                // Decision 1: the finding is tolerated rather than recorded, so+                // it takes no site's rules away and does not stand in front of a+                // Work or Entry finding on the same hostname (Q21).+                if isToleratedWrongHostWorkURL(error, on: membership), let workID = membership.workID {+                    wrongHostRows[workID, default: []].append(membership)+                    continue+                }+                record(membership.hostname, error)+            }+        }+        // One diagnosis per Work (Q9), naming one pair per source hostname in+        // hostname order. Where a torn group disagrees on a hostname the pair+        // names the host on the value **first in the heal's candidate order**+        // (Q35) — the same order the foreground pass takes them in, which is+        // what makes the two derivations equal (Req 2.4). The title comes from+        // the group's representative row, or is absent while the Work has not+        // arrived (Q48, Q53).+        for (workID, rows) in wrongHostRows {+            // Survivor order over the Work's rows is the candidate order within+            // each hostname, and `pairs` keeps the first it meets per hostname —+            // the one rule, shared with the foreground scan.+            let pairs = WrongHostMembership.pairs(+                from: MembershipReconciler.survivorFirst(rows).lazy+                    .map { (hostname: $0.hostname, value: $0.workURLString) })+            guard !pairs.isEmpty else { continue }+            tolerated.append(+                .wrongHostWorkURL(+                    workID: workID.uuidString,+                    workTitle: works[workID.uuidString]?.first?.displayTitle,+                    memberships: pairs))         }          for group in workRows {@@ -828,6 +892,30 @@ public enum LibraryValidator {     /// arrived, exactly as `Entry.site` tolerates. So is a membership whose Work     /// has not arrived (Req 8.3) — an orphan's tuple is checked, its absent Work     /// is not.+    /// Whether a membership finding is the shape `wrong-host-work-url-heal`+    /// tolerates: an absolute HTTP(S) address on another host, on a row that+    /// names a Work.+    ///+    /// Both conditions are narrowing (Q50, Q31). `validate(membership:)` raises+    /// the same error for a value that is not an absolute HTTP(S) URL — nothing+    /// wrote one and nothing repairs one, so it stays the loud tuple failure Q14+    /// keeps it as — and a membership naming no Work has neither a diagnosis key+    /// nor a Work for the heal to move values on.+    ///+    /// The **column**, never `membership.work`: the two disagree for a row whose+    /// Work has not arrived, and keying on the column is what makes the full+    /// validation and the foreground pass produce the same diagnosis (Q48).+    ///+    /// Deliberately not conditioned on strictness. The plan gate reads the tuple+    /// map alone, so diverting here is also what stops an old archive being+    /// refused for this one field ([1.4](../../../../specs/wrong-host-work-url-heal/requirements.md#14)).+    private static func isToleratedWrongHostWorkURL(+        _ error: LibraryValidationError, on membership: WorkSiteMembership+    ) -> Bool {+        guard case .wrongHostWorkURL(_, _, let actualHost, _, _) = error else { return false }+        return actualHost != nil && membership.workID != nil+    }+     private static func validate(membership: WorkSiteMembership) throws {         let id = membership.id.uuidString         guard !M2Unicode.isBlank(membership.hostname) else {
Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift Modified +11 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swiftindex 3e7d818..e6b0997 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift@@ -184,10 +184,19 @@ public struct EntryAssignmentBasis: Sendable, Equatable { public struct WorkMembershipBasis: Sendable, Equatable, Hashable {     public let hostname: String     public let urlIdentity: String?--    public init(hostname: String, urlIdentity: String?) {+    /// The confirmed Work URL on that site, where the row holds one.+    ///+    /// `wrong-host-work-url-heal`: the foreground pass recognises an address on+    /// another host from this value alone, so the walk that already reads the+    /// two above reads a third scalar rather than a second table. Every other+    /// producer of a basis leaves it nil — the duplicate relation is not about+    /// the address — so nothing else's equality moves.+    public let workURLString: String?++    public init(hostname: String, urlIdentity: String?, workURLString: String? = nil) {         self.hostname = hostname         self.urlIdentity = urlIdentity+        self.workURLString = workURLString     } } 
Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift Modified +372 / -40
diff --git a/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swiftindex 5fddf28..42370c1 100644--- a/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift@@ -26,6 +26,17 @@ public struct MembershipReconcileReport: Equatable, Sendable {     public var membershipsRemoved = 0     public var pairsRemoved = 0     public var reattached = 0+    /// How many **values** the wrong-host Work URL phase moved or removed+    /// ([3.10](../../../../specs/wrong-host-work-url-heal/requirements.md#310)):+    /// a count, like everything else here, with the hostnames it touched+    /// carried in `touchedHostnames` and no title or address anywhere.+    ///+    /// Values, not Works, so the field and the log line say what they are named+    /// for: a two-hostname swap changes two addresses and reads as two. A plan+    /// the pass could not write — every land blocked by a row outside its+    /// reading (`wrong-host-work-url-heal` Q66) — counts nothing, so a library+    /// it cannot repair does not report a change on every pass.+    public var movedWorkURLs = 0     /// Every hostname this pass wrote to, in any phase.     ///     /// `reconcileAfterSync` re-validates these, whether or not they arrived@@ -38,6 +49,7 @@ public struct MembershipReconcileReport: Equatable, Sendable {      public var isEmpty: Bool {         healed.isEmpty && membershipsRemoved == 0 && pairsRemoved == 0 && reattached == 0+            && movedWorkURLs == 0     } } @@ -70,10 +82,29 @@ public struct MembershipReconcileReport: Equatable, Sendable { /// never on the capture path. enum MembershipReconciler { -    /// - Parameter heals: whether phase 1 runs. It is the only phase that walks-    ///   the whole Entry table, and on an arrival it runs only where the last-    ///   tolerance scan saw a Work with no membership (Decision 5). The other-    ///   three are table fetches that fault nothing — phase 0 fetches the+    /// Which of the two gated repairs this pass runs (Q40).+    ///+    /// Both are gated on the last derivation having reported the state they+    /// repair, and they are gated *independently* — a library with a Work+    /// missing a membership need not also pay for a wrong-host walk. One option+    /// set rather than two `Bool`s, so the single production call site cannot+    /// transpose them.+    struct Heals: OptionSet, Sendable {+        let rawValue: Int++        /// Phase 1: mint the membership an Entry's Work is missing (Req 8.1).+        static let missingMembership = Heals(rawValue: 1 << 0)+        /// Phase 1b: move an off-host membership Work URL to the membership for+        /// the host it names (`wrong-host-work-url-heal` Req 3).+        static let wrongHostWorkURL = Heals(rawValue: 1 << 1)++        static let all: Heals = [.missingMembership, .wrongHostWorkURL]+    }++    /// - Parameter heals: which gated repairs run. Phase 1 is the only phase+    ///   that walks the whole Entry table, and on an arrival it runs only where+    ///   the last tolerance scan saw a Work with no membership (Decision 5). The+    ///   other three are table fetches that fault nothing — phase 0 fetches the     ///   orphans by predicate, and the two dedupes group rows they already hold     ///   — so they stay unconditional.     static func run(@@ -81,7 +112,7 @@ enum MembershipReconciler {         rowsByHostname: [String: [Site]]? = nil,         batchSize: Int = LibraryRepository.bulkOperationBatchSize,         saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy(),-        heals: Bool = true+        heals: Heals = .all     ) throws -> MembershipReconcileReport {         var report = MembershipReconcileReport() @@ -96,9 +127,20 @@ enum MembershipReconciler {         // inserts and deletes nothing; it re-points, which does not change the         // set of rows.         var membershipRows = try context.fetch(FetchDescriptor<WorkSiteMembership>())-        if heals {+        // One read of the Site table for both heals, and only where one of them+        // runs: an ungated pass must not pay for a fetch neither phase reads.+        let winners = heals.isEmpty+            ? [:]+            : try siteWinners(context: context, rowsByHostname: rowsByHostname)+        if heals.contains(.missingMembership) {             membershipRows += try heal(-                context: context, existing: membershipRows, rowsByHostname: rowsByHostname,+                context: context, existing: membershipRows, winners: winners,+                batchSize: batchSize, saveStrategy: saveStrategy, into: &report)+        }+        if heals.contains(.wrongHostWorkURL) {+            membershipRows += try healWrongHostWorkURLs(+                context: context, existing: membershipRows,+                unarrivedWorkIDs: unarrivedWorkIDs, winners: winners,                 batchSize: batchSize, saveStrategy: saveStrategy, into: &report)         }         try dedupeMemberships(@@ -113,7 +155,8 @@ enum MembershipReconciler {                 Membership reconciliation: healed \(report.healed.count, privacy: .public), \                 re-attached \(report.reattached, privacy: .public), \                 removed \(report.membershipsRemoved, privacy: .public) memberships and \-                \(report.pairsRemoved, privacy: .public) distinct pairs+                \(report.pairsRemoved, privacy: .public) distinct pairs, \+                moved \(report.movedWorkURLs, privacy: .public) work URLs                 """)         }         return report@@ -131,7 +174,7 @@ enum MembershipReconciler {     }      /// The step a healed membership sits **behind** every membership its Work-    /// already holds (Q62).+    /// already holds (`multi-site-works` Q62).     ///     /// One millisecond, which is the granularity every other timestamp in this     /// library is quantized to, and a fixed offset from synced content rather@@ -194,7 +237,7 @@ enum MembershipReconciler {      /// The healed membership's `createdAt` is derived from synced content, never     /// from a clock reading — and it is derived so that a heal can never take-    /// the **primary** slot (Q62).+    /// the **primary** slot (`multi-site-works` Q62).     ///     /// Two devices heal the same Work independently (Req 2.6) and their two rows     /// have to be *comparable*: the dedupe survivor rule reads `createdAt` before@@ -223,7 +266,7 @@ enum MembershipReconciler {     private static func heal(         context: ModelContext,         existing: [WorkSiteMembership],-        rowsByHostname: [String: [Site]]?,+        winners: [String: Site],         batchSize: Int,         saveStrategy: any RepositorySaveStrategy,         into report: inout MembershipReconcileReport@@ -234,16 +277,6 @@ enum MembershipReconciler {             held.insert(Key(workID: workID, hostname: membership.hostname))         } -        var winners: [String: Site] = [:]-        if let rowsByHostname {-            for (hostname, rows) in rowsByHostname {-                winners[hostname] = SiteResolutionOrder.sorted(rows).first-            }-        } else {-            winners = SiteResolutionOrder.winnersByHostname(-                try context.fetch(FetchDescriptor<Site>()))-        }-         // `enumerate`, not a fetch: the Entry table is the largest in the         // library and this pass runs after every arrival, so materialising all         // of it to find the handful of Works missing a membership is the cost@@ -290,15 +323,254 @@ enum MembershipReconciler {     /// hold, or the Work's own creation date where it holds none — which is the     /// only case where a healed row is legitimately the primary, because it is     /// the only one.+    ///+    /// **Quantized**, like every other date this library writes. `addingTimeInterval`+    /// alone lands one ULP away from the same instant built through+    /// `MillisecondInstant`, and the archive's date encoding quantizes on the way+    /// out — so an unquantized `createdAt` decodes back as a *different* `Date`+    /// and `BackupV7Exporter`'s decode-validation refuses the file it just wrote.+    /// A library holding a minted membership could not be backed up at all+    /// ([4.2](../../../../specs/wrong-host-work-url-heal/requirements.md#42)).+    /// The value stays a pure function of stored content, so two devices healing+    /// the same Work still agree.     private static func healCreatedAt(for work: Work) -> Date {         guard let latest = (work.siteMemberships ?? []).map(\.createdAt).max() else {             return work.createdAt         }-        return max(latest, work.createdAt).addingTimeInterval(healStep)+        return MillisecondInstant.quantize(+            max(latest, work.createdAt).addingTimeInterval(healStep))     }      private typealias Heal = MembershipReconcileReport.Heal +    /// The hostname's **winning** Site row, from the caller's map where it has+    /// one and from the Site table otherwise.+    ///+    /// Both heals mint memberships and both must pin to the same row (Q57), so+    /// the fetch is one helper rather than a copy per phase — and one read+    /// rather than one per phase, since `run` calls it once for both.+    private static func siteWinners(+        context: ModelContext, rowsByHostname: [String: [Site]]?+    ) throws -> [String: Site] {+        guard let rowsByHostname else {+            return SiteResolutionOrder.winnersByHostname(+                try context.fetch(FetchDescriptor<Site>()))+        }+        return rowsByHostname.compactMapValues { SiteResolutionOrder.sorted($0).first }+    }++    // MARK: - Phase 1b: move an off-host Work URL to its own site (Req 3)+    //+    // Every bare `Qnn` from here to the end of `healWrongHostWorkURLs` is+    // `wrong-host-work-url-heal`'s, not `multi-site-works`' — the log the rest+    // of this file cites. The two overlap in the low sixties, so the first+    // reference in each comment names the spec.++    /// Moves every off-host membership Work URL to the membership for the host+    /// it names+    /// ([3.1](../../../../specs/wrong-host-work-url-heal/requirements.md#31)–[3.11](../../../../specs/wrong-host-work-url-heal/requirements.md#311)).+    ///+    /// Runs **before** de-duplication and before torn-group propagation+    /// (`wrong-host-work-url-heal` Q13, Q37): propagation copies membership+    /// values across a torn group per hostname, so a heal running after it would+    /// have its clears undone on the next pass, and de-duplication deletes losers+    /// whole, so it would fold two torn rows holding different off-host values+    /// before either was placed.+    ///+    /// The decision is `WrongHostWorkURLResolver`'s, taken from one reading of+    /// the Work before any write (Q26); this only carries the answer to the+    /// rows. Everything it writes for one Work lands in **one save**+    /// ([3.11](../../../../specs/wrong-host-work-url-heal/requirements.md#311)),+    /// destinations before sources, so no observable state has a value on no row.+    ///+    /// - Parameter existing: the membership table as `run` read it, phase 1's+    ///   inserts included.+    /// - Parameter unarrivedWorkIDs: phase 0's answer. A Work with no row cannot+    ///   be minted on (Q48), so its memberships are left where they are.+    /// - Returns: the memberships this phase minted, so the dedupe below sees+    ///   the whole table without reading it again.+    private static func healWrongHostWorkURLs(+        context: ModelContext,+        existing: [WorkSiteMembership],+        unarrivedWorkIDs: Set<UUID>,+        winners: [String: Site],+        batchSize: Int,+        saveStrategy: any RepositorySaveStrategy,+        into report: inout MembershipReconcileReport+    ) throws -> [WorkSiteMembership] {+        // Grouped by the `workID` **column**, which is what both derivations of+        // the diagnosis key on (`wrong-host-work-url-heal` Q48, Q58): a row the+        // column does not name is not part of this Work's reading here either,+        // so the heal repairs exactly what was reported.+        //+        // A **blank** hostname is skipped (Q66): both producers skip one, so a+        // membership carrying it is not in the diagnosis, and repairing an+        // unreported row is exactly what the tolerance is not.+        var rowsByWorkID: [UUID: [WorkSiteMembership]] = [:]+        for membership in existing where !M2Unicode.isBlank(membership.hostname) {+            guard let workID = membership.workID, !unarrivedWorkIDs.contains(workID)+            else { continue }+            rowsByWorkID[workID, default: []].append(membership)+        }++        // The resolution is pure and cheap; the Work rows it needs are not. So+        // resolve first and fetch only for the Works that have something to+        // write — on a library with one diagnosed Work that is one small query.+        var plans: [(workID: UUID, moves: [WrongHostWorkURLResolver.Move])] = []+        for (workID, rows) in rowsByWorkID {+            let moves = WrongHostWorkURLResolver.resolve(+                WrongHostWorkURLResolver.WorkSnapshot(+                    rows.map {+                        WrongHostWorkURLResolver.Membership(+                            hostname: $0.hostname, value: $0.workURLString,+                            orderKey: WorkSiteMembership.OrderKey($0))+                    }))+            // A `stay` writes nothing and leaves the Work reported (Q38).+            guard moves.contains(where: { if case .stay = $0 { false } else { true } })+            else { continue }+            plans.append((workID, moves))+        }+        guard !plans.isEmpty else { return [] }+        let plannedWorkIDs = Set(plans.map(\.workID))++        // The wider index — every row the *relationship* attaches included — and+        // only for the Works this pass is about to write to. It is read once per+        // plan, before minting (Q66): a Q58 row already holding the destination+        // hostname is outside the reading above but is still a real row, and+        // minting beside it would leave the dedupe to fold the pair and possibly+        // discard the value that had just moved.+        var resolvedRowsByWorkID: [UUID: [WorkSiteMembership]] = [:]+        for membership in existing where !M2Unicode.isBlank(membership.hostname) {+            guard let resolved = membership.resolvedWorkID,+                  plannedWorkIDs.contains(resolved)+            else { continue }+            resolvedRowsByWorkID[resolved, default: []].append(membership)+        }++        var worksByID: [UUID: [Work]] = [:]+        for slice in LibraryRepository.chunks(of: plans.map(\.workID), size: batchSize) {+            let claimed: [UUID] = Array(slice)+            for work in try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { claimed.contains($0.id) }))+            {+                worksByID[work.id, default: []].append(work)+            }+        }++        var minted: [WorkSiteMembership] = []+        var pending = 0+        // Sorted, so two devices that both run the pass write the same library+        // in the same order — `Dictionary` iteration is per-process seeded.+        for plan in plans.sorted(by: { UUID.bytesPrecede($0.workID, $1.workID) }) {+            let workRows = GroupOrdering.sortedWorkRows(worksByID[plan.workID] ?? [])+            guard let representative = workRows.first else { continue }+            // The row a minted membership **joins**, and the row whose own+            // memberships date it: the first in representative order that+            // already holds one (Q61). Not the representative itself — a row+            // with no membership sorts *first* under `representativeComponents`+            // (its primary hostname is empty), and minting there would make the+            // minted row that row's primary and move the site the Work is+            // presented under, against 3.5. Only a group with no membership at+            // all falls back to the representative, where the minted row is+            // legitimately the only one.+            let host = workRows.first { !($0.siteMemberships ?? []).isEmpty } ?? representative+            var rows = rowsByWorkID[plan.workID] ?? []+            // The moves this pass is going to carry out. A land the pass+            // abandons (Q66) is simply not in it: the source keeps its value and+            // the Work stays reported, so the clear below must skip it too.+            var applied: [WrongHostWorkURLResolver.Move] = []+            var wrote = false++            // Destinations first, then sources (3.11). A cycle lands a value on+            // a hostname that also held one, so the clear matches the **exact+            // value** on the source hostname rather than emptying it (Q51).+            //+            // Hostnames are matched case-insensitively throughout (Q65): a move+            // names a lowercased hostname on both sides, and a stored one is not+            // normalised.+            for move in plan.moves {+                guard case .land(let value, _, let destination) = move else {+                    applied.append(move)+                    continue+                }+                if let target = Work.membership(in: rows, forHostMatching: destination) {+                    target.workURLString = value+                    wrote = true+                    applied.append(move)+                } else if let outside = Work.membership(+                    in: resolvedRowsByWorkID[plan.workID] ?? [], forHostMatching: destination),+                    WorkURLPlanner.holdsWorkURL(outside.workURLString) {+                    // A row the resolver never saw — its `workID` column is nil+                    // and only the relationship attaches it (Q58) — already holds+                    // the destination hostname *and* a value. Minting beside it+                    // would leave the dedupe to fold the pair, and the survivor+                    // it keeps might be the one without the moved address. The+                    // move stays, and the Work stays reported until the row is+                    // re-attached (Q66).+                    continue+                } else {+                    let membership = WorkSiteMembership(+                        hostname: destination,+                        createdAt: healCreatedAt(for: host),+                        urlIdentityState: .none,+                        workURLString: value,+                        workID: plan.workID,+                        work: host,+                        // Nil-tolerant by design (3.6): the heal mints a+                        // membership, never a Site row, and a membership ahead+                        // of its Site row is a state the library already carries.+                        site: winners[destination])+                    context.insert(membership)+                    rows.append(membership)+                    minted.append(membership)+                    wrote = true+                    applied.append(move)+                }+            }+            // One count per **value** placed or cleared, which is what the field+            // is named for and what the log line prints (3.10). Counting Works+            // made a swap read as one move when two addresses had changed. Both+            // accumulators are local until the plan is known to have written:+            // a plan every one of whose lands was abandoned is not a change, and+            // counting it would keep `isEmpty` false for ever and make the launch+            // path refresh on every pass.+            var moved = 0+            var touched: Set<String> = []+            for move in applied {+                let cleared: (value: String, hostname: String)+                switch move {+                case .land(let value, let source, let destination):+                    cleared = (value, source)+                    touched.insert(destination)+                case .remove(let value, let source): cleared = (value, source)+                case .stay: continue+                }+                for row in Work.memberships(in: rows, forHostMatching: cleared.hostname)+                where row.workURLString == cleared.value {+                    row.workURLString = nil+                    wrote = true+                }+                touched.insert(cleared.hostname)+                moved += 1+            }+            guard wrote else { continue }+            report.movedWorkURLs += moved+            report.touchedHostnames.formUnion(touched)+            // Saved on a **Work** boundary, as phase 1 saves on a chunk of+            // mints (Q75): 3.11 asks that a Work's moves land together, not that+            // each Work pays its own save, and a settling pass repairing+            // hundreds of Works paid one round trip each for a guarantee a batch+            // already gives. Every boundary is still a legal library.+            pending += 1+            if pending >= batchSize {+                try saveStrategy.save(context)+                pending = 0+            }+        }+        if pending > 0 { try saveStrategy.save(context) }+        return minted+    }+     // MARK: - Phase 2: one membership per (work, hostname) (Req 8.2, Q23)      /// - Parameter unarrivedWorkIDs: the Works phase 0 could not re-attach.@@ -331,7 +603,18 @@ enum MembershipReconciler {          var losers: [WorkSiteMembership] = []         for (key, rows) in groups where rows.count > 1 {-            losers.append(contentsOf: survivorFirst(rows).dropFirst())+            let ordered = survivorFirst(rows)+            let discarded = Array(ordered.dropFirst())+            // Req 1.6: the survivor takes a discarded row's Work URL where it+            // holds none. Here, in the grouping loop, because this is the last+            // place the survivor and its own losers are still paired — the list+            // below is flattened across every group.+            if let keeper = ordered.first {+                // `discarded` came off the survivor sort above, so it is already+                // in the order the carry applies — no second sort.+                carryWorkURL(to: keeper, from: discarded, inSurvivorOrder: true)+            }+            losers.append(contentsOf: discarded)             report.touchedHostnames.insert(key.hostname)         }         guard !losers.isEmpty else { return }@@ -352,28 +635,77 @@ enum MembershipReconciler {     /// state-`none` row discard a rule-derived identity — the exact pair Req 2.6     /// produces when one device heals a Work the other had already migrated.     ///-    /// `Work.membership(for:)` reads it too (Q61): the row every caller gets-    /// handed for a hostname is the row this phase would keep, so a duplicate-    /// that has arrived but not yet been deduped cannot change what matching,-    /// merge or the Work URL commit sees.+    /// `Work.membership(for:)` reads it too (`multi-site-works` Q61): the row+    /// every caller gets handed for a hostname is the row this phase would keep,+    /// so a duplicate that has arrived but not yet been deduped cannot change+    /// what matching, merge or the Work URL commit sees.+    /// The comparator itself is `WorkSiteMembership.OrderKey` (Q60), so the+    /// foreground scan's value snapshots and these rows sort by one rule.+    /// Decorate-sort-undecorate: `sorted(by:)` calls its comparator O(n log n)+    /// times, and building the key inside it would read three columns per call+    /// for an O(n) set of keys — the same trade `LibraryDiagnosis.sortedByKey`+    /// is written for.     static func survivorFirst(         _ rows: [WorkSiteMembership]     ) -> [WorkSiteMembership] {-        rows.sorted { left, right in-            let leftRank = rank(left.urlIdentityState)-            let rightRank = rank(right.urlIdentityState)-            if leftRank != rightRank { return leftRank > rightRank }-            if left.createdAt != right.createdAt { return left.createdAt < right.createdAt }-            return left.id.uuidString < right.id.uuidString-        }+        guard rows.count > 1 else { return rows }+        return rows+            .map { (key: WorkSiteMembership.OrderKey($0), row: $0) }+            .sorted { $0.key < $1.key }+            .map(\.row)     } -    private static func rank(_ state: WorkURLIdentityState) -> Int {-        switch state {-        case .rule: 2-        case .legacyUnverified: 1-        case .none: 0-        }+    /// The Work URL a fold's survivor should hold+    /// ([1.6](../../../../specs/wrong-host-work-url-heal/requirements.md#16), Q41).+    ///+    /// Three places fold rows for one `(Work, hostname)` pair to one — this+    /// phase, `DuplicateReconciler.collapseMemberships`, and the backup export's+    /// projection — and all three fold by the same survivor order. A discarded+    /// row's confirmed address is reader content, and losing it because a row+    /// with a stronger identity state sorted ahead of it is a silent loss the+    /// reader cannot see, let alone undo.+    ///+    /// The value is carried **whether or not it is on the hostname** (Q33): this+    /// phase runs ungated on every pass while the wrong-host heal is gated with+    /// a one-derivation lag, so carrying only on-host values would delete an+    /// off-host one before the heal ever saw it. What is carried off-host stays+    /// diagnosed and is placed on a later pass.+    ///+    /// **Value-returning, not assigning** (Q54): the export projection is a+    /// read-only fold and must not dirty its context.+    ///+    /// "Holds a value" is `WorkURLPlanner.holdsWorkURL` — non-nil, blank+    /// included (Q63). A blank survivor is a value the validator reports and+    /// Check Library shows, so a loser's address must not be written over it.+    /// - Parameter losers: the discarded rows.+    /// - Parameter inSurvivorOrder: whether `losers` is already in survivor+    ///   order. Two of the three folds have just sorted their group to find the+    ///   survivor, and sorting the same rows again here is work for an answer+    ///   already in hand.+    static func carriedWorkURL(+        survivor: String?, losers: [WorkSiteMembership], inSurvivorOrder: Bool = false+    ) -> String? {+        if WorkURLPlanner.holdsWorkURL(survivor) { return survivor }+        let ordered = inSurvivorOrder ? losers : survivorFirst(losers)+        return ordered.lazy+            .first { WorkURLPlanner.holdsWorkURL($0.workURLString) }?.workURLString+    }++    /// The same rule applied to the row that keeps the pair — the form the two+    /// **store** folds want, where the export projection keeps the+    /// value-returning one (Q54).+    ///+    /// Value-guarded: a keeper that already holds what would be carried is not+    /// written, so a fold over a settled group dirties nothing (Req 3.8).+    static func carryWorkURL(+        to keeper: WorkSiteMembership, from losers: [WorkSiteMembership],+        inSurvivorOrder: Bool = false+    ) {+        guard let carried = carriedWorkURL(+            survivor: keeper.workURLString, losers: losers, inSurvivorOrder: inSurvivorOrder),+            keeper.workURLString != carried+        else { return }+        keeper.workURLString = carried     }      // MARK: - Phase 3: one row per dismissed pair (Req 5.8)
Packages/AsterismCore/Sources/AsterismCore/Models.swift Modified +134 / -5
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex abd9f74..b402dc3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -429,6 +429,36 @@ public final class Work {     ) -> WorkSiteMembership? {         MembershipReconciler.survivorFirst(rows.filter { $0.hostname == hostname }).first     }++    /// Every row on `hostname` with the hostname matched+    /// **case-insensitively**, in survivor order+    /// (`wrong-host-work-url-heal` Q65).+    ///+    /// A stored hostname is not normalised, while a host derived from a URL+    /// always is, so a caller holding a derived host — the wrong-host heal, which+    /// asks about the membership of the host a Work URL names — cannot use the+    /// exact match above: a `Example.com` row would be invisible to it and it+    /// would mint a second row in a different case, which nothing ever folds.+    /// Every other caller holds a stored hostname already and keeps the exact+    /// match, which is one string comparison rather than two allocations.+    ///+    /// Plural because the heal needs both halves of the question — the row a+    /// value lands on, and *every* row a value is cleared from — and one+    /// spelling of "on this host" is what keeps those two agreeing.+    static func memberships(+        in rows: [WorkSiteMembership], forHostMatching hostname: String+    ) -> [WorkSiteMembership] {+        let host = hostname.lowercased()+        return MembershipReconciler.survivorFirst(rows.filter { $0.hostname.lowercased() == host })+    }++    /// The survivor of `memberships(in:forHostMatching:)` — the row every+    /// caller that wants one gets handed.+    static func membership(+        in rows: [WorkSiteMembership], forHostMatching hostname: String+    ) -> WorkSiteMembership? {+        memberships(in: rows, forHostMatching: hostname).first+    } }  @Model@@ -989,19 +1019,96 @@ public final class WorkSiteMembership {     /// the cost Q84 is written around. A row naming no Work is an orphan     /// awaiting one (Req 8.3) and belongs to no Work's list; a row with a blank     /// hostname is on no site (Q30) and contributes no relation.+    ///+    /// Each Work's list comes back in **survivor order** — the order membership+    /// de-duplication keeps a survivor by, and the order the wrong-host Work URL+    /// heal takes its candidates in (Q35). Without it, a caller asking "which+    /// value does this Work hold on this hostname" would be answered by fetch+    /// order, which two devices need not agree on, and the foreground pass and+    /// the full validator could name different hosts for one torn group. The+    /// ordering columns are read as three scalars beside the ones the basis+    /// carries; the rows themselves are still not retained.     static func indexByWorkID(         context: ModelContext, batchSize: Int     ) throws -> [UUID: [WorkMembershipBasis]] {-        var index: [UUID: [WorkMembershipBasis]] = [:]+        // The ordering columns are carried as raw scalars, not as an `OrderKey`:+        // the key is built below for the groups that actually have rows to sort,+        // so the common single-membership Work pays no key at all (Q60).+        typealias Row = (+            state: WorkURLIdentityState, createdAt: Date, id: UUID, basis: WorkMembershipBasis+        )+        var index: [UUID: [Row]] = [:]         try context.enumerate(             FetchDescriptor<WorkSiteMembership>(), batchSize: batchSize         ) { membership in-            guard let workID = membership.workID, !membership.hostname.isEmpty else { return }+            // The same blankness test `LibraryValidator.validate(membership:)`+            // applies, so a whitespace-only hostname is on no site here either.+            guard let workID = membership.workID, !M2Unicode.isBlank(membership.hostname)+            else { return }             index[workID, default: []].append(-                WorkMembershipBasis(-                    hostname: membership.hostname, urlIdentity: membership.urlIdentity))+                (membership.urlIdentityState, membership.createdAt, membership.id,+                 WorkMembershipBasis(+                    hostname: membership.hostname, urlIdentity: membership.urlIdentity,+                    workURLString: membership.workURLString)))+        }+        return index.mapValues { rows in+            guard rows.count > 1 else { return rows.map(\.basis) }+            return rows+                .map { (key: OrderKey($0.state, $0.createdAt, $0.id), basis: $0.basis) }+                .sorted { $0.key < $1.key }+                .map(\.basis)+        }+    }++    /// Q23's survivor order as three scalars, so a caller that must not retain+    /// the rows can still sort by it — and so the one order has one spelling.+    ///+    /// `indexByWorkID` walks the whole table under `enumerate` and yields value+    /// snapshots; `wrong-host-work-url-heal` Q35 makes the two derivations of+    /// the wrong-host diagnosis agree only because they take their candidates in+    /// **one** order. `MembershipReconciler.survivorFirst` sorts on it too.+    ///+    /// The identifier is kept as a `UUID` and stringified only inside `<`+    /// (Q60): the key is built on the foreground scan and the arrival duplicate+    /// phase, and a `uuidString` per membership there is an allocation on a path+    /// this repo already keeps keys out of.+    struct OrderKey: Comparable {+        let identityRank: Int+        let createdAt: Date+        let id: UUID++        init(_ membership: WorkSiteMembership) {+            self.init(membership.urlIdentityState, membership.createdAt, membership.id)+        }++        init(_ state: WorkURLIdentityState, _ createdAt: Date, _ id: UUID) {+            identityRank = Self.rank(state)+            self.createdAt = createdAt+            self.id = id+        }++        /// The strongest identity state first, then the earliest created, then+        /// the lowest identifier.+        ///+        /// State leads because earliest-created alone would let a healed+        /// state-`none` row discard a rule-derived identity — the exact pair+        /// `multi-site-works` Req 2.6 produces when one device heals a Work the+        /// other had already migrated.+        static func < (left: OrderKey, right: OrderKey) -> Bool {+            if left.identityRank != right.identityRank {+                return left.identityRank > right.identityRank+            }+            if left.createdAt != right.createdAt { return left.createdAt < right.createdAt }+            return UUID.bytesPrecede(left.id, right.id)+        }++        private static func rank(_ state: WorkURLIdentityState) -> Int {+            switch state {+            case .rule: 2+            case .legacyUnverified: 1+            case .none: 0+            }         }-        return index     } } @@ -1166,3 +1273,25 @@ extension Work {         return changes     } }++extension UUID {+    /// The order `uuidString` gives, without the two allocations per+    /// comparison: the string is the sixteen bytes in order as hex, so+    /// comparing the bytes unsigned answers the same question.+    ///+    /// The identifier is the last tie-break of several orders here+    /// (`WorkSiteMembership.OrderKey`, the heal's plan order), and+    /// `sorted(by:)` calls its comparator O(n log n) times — on the foreground+    /// scan and the arrival duplicate phase, which is where this repo already+    /// keeps `uuidString` out of the loop (`wrong-host-work-url-heal` Q60).+    static func bytesPrecede(_ left: UUID, _ right: UUID) -> Bool {+        withUnsafeBytes(of: left.uuid) { left in+            withUnsafeBytes(of: right.uuid) { right in+                for index in left.indices where left[index] != right[index] {+                    return left[index] < right[index]+                }+                return false+            }+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/WorkURLPlanner.swift Modified +48 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkURLPlanner.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkURLPlanner.swiftindex 7f42f2f..ba6b01e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkURLPlanner.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkURLPlanner.swift@@ -120,6 +120,54 @@ public enum WorkURLPlanner {         return host == hostname.lowercased()     } +    /// What a stored membership Work URL is, relative to the hostname the+    /// membership is on+    /// (`wrong-host-work-url-heal` [Q63](../../../../specs/wrong-host-work-url-heal/decision_log.md)).+    /// **Internal**: nothing outside the package classifies a stored value —+    /// the app asks `isOnHost` about one address at a time.+    enum StoredWorkURL: Equatable, Sendable {+        /// The membership holds nothing at all — the only field a move can fill.+        case empty+        /// An address on the membership's own hostname: never replaced (Q23).+        case onHost+        /// An absolute HTTP(S) address on another host, named lowercased.+        case offHost(destination: String)+        /// A value that is **held** but is not an absolute HTTP(S) URL — a blank+        /// string included. The validator reports it as a tuple failure with no+        /// actual host, so this feature never moves it and never writes over it.+        case other+    }++    /// Whether a membership holds a Work URL at all: **iff the column is+    /// non-nil** (Q63).+    ///+    /// One emptiness rule, in one place. Three call sites used to spell it three+    /// ways — `value == nil`, `value == nil || value!.isEmpty`, and "nil or+    /// blank" — so a membership holding `""` was empty to the import pre-pass,+    /// held to the fold carry, and empty again to the resolver. A blank value is+    /// a *reported* value: treating it as free space lets a move overwrite+    /// something the reader can still see in Check Library.+    static func holdsWorkURL(_ value: String?) -> Bool { value != nil }++    /// `value` classified against the hostname holding it (Q63) — the one+    /// derivation the heal's resolver, the fold carry and the import pre-pass+    /// all read.+    static func classifyWorkURL(_ value: String?, on hostname: String) -> StoredWorkURL {+        guard holdsWorkURL(value) else { return .empty }+        guard let value, let host = host(of: value) else { return .other }+        // The same comparison `isOnHost` makes, on a host already derived: a+        // hostname is matched ASCII-case-insensitively, which is what a host is.+        return host == hostname.lowercased() ? .onHost : .offHost(destination: host)+    }++    /// The host `value` names where that is another host than the one holding+    /// it — the `.offHost` arm of the classification above, for the four callers+    /// that want the destination and nothing else.+    static func offHost(_ value: String?, on hostname: String) -> String? {+        guard case .offHost(let host) = classifyWorkURL(value, on: hostname) else { return nil }+        return host+    }+     /// The lowercased host of an absolute HTTP(S) URL, or nil where `value` is     /// not one — the single host derivation both checks above read.     internal static func host(of value: String) -> String? {
Packages/AsterismCore/Sources/AsterismCore/WrongHostWorkURLResolver.swift Modified +175 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WrongHostWorkURLResolver.swift b/Packages/AsterismCore/Sources/AsterismCore/WrongHostWorkURLResolver.swiftnew file mode 100644index 0000000..f0c57cc--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/WrongHostWorkURLResolver.swift@@ -0,0 +1,175 @@+import Foundation++/// Where every off-host Work URL of one Work goes+/// ([3.1](../../../../specs/wrong-host-work-url-heal/requirements.md#31)–[3.4](../../../../specs/wrong-host-work-url-heal/requirements.md#34)).+///+/// A **pure** function over a snapshot taken before any write (Q26): the whole+/// of [3.4](../../../../specs/wrong-host-work-url-heal/requirements.md#34)'s+/// precedence — cycles, collisions, blocked chains, torn rows — is decided here,+/// and `MembershipReconciler` only carries the answer to the rows. Sequential+/// mutation cannot produce the pinned swap and leaves longer cycles undefined,+/// and a resolution spread through a write loop cannot be tested over generated+/// Works (Q42).+///+/// The shape is Q49's: a **blocked closure** (a least fixpoint from the values+/// this feature must not touch) and then **one ordered pass** with a+/// per-destination claim. "Fixpoint" alone would admit the greatest one, under+/// which every cycle blocks itself.+enum WrongHostWorkURLResolver {++    /// One membership of the Work, across every row of a torn group.+    ///+    /// It carries the survivor **order key** rather than a row identifier: the+    /// candidate order is `(source hostname, survivor order)` (Q24), which is+    /// the order two devices agree on, and a bare identifier cannot express it.+    /// The row itself is not here — the resolver reads no store.+    struct Membership: Equatable {+        let hostname: String+        let value: String?+        let orderKey: WorkSiteMembership.OrderKey++        init(hostname: String, value: String?, orderKey: WorkSiteMembership.OrderKey) {+            self.hostname = hostname+            self.value = value+            self.orderKey = orderKey+        }+    }++    /// One reading of a Work, taken before any write+    /// ([3.4](../../../../specs/wrong-host-work-url-heal/requirements.md#34)).+    struct WorkSnapshot: Equatable {+        let memberships: [Membership]++        init(_ memberships: [Membership]) {+            self.memberships = memberships+        }+    }++    /// What the apply step does with one off-host value.+    ///+    /// `land` names its **source** hostname as well as its destination (Q51): a+    /// cycle lands a value on a hostname that also held one, so the clear that+    /// follows has to match the exact value on the exact source rather than+    /// emptying the hostname.+    ///+    /// Every hostname a move names is **lowercased** (Q65), destinations because+    /// `WorkURLPlanner.host(of:)` lowercases and sources so the two halves are+    /// one vocabulary. The apply step therefore matches rows on the lowercased+    /// stored hostname, not on the stored spelling.+    enum Move: Equatable {+        /// Move the value onto the membership for the host it names, minting+        /// that membership where the Work holds none+        /// ([3.1](../../../../specs/wrong-host-work-url-heal/requirements.md#31), [3.3](../../../../specs/wrong-host-work-url-heal/requirements.md#33)).+        case land(value: String, from: String, to: String)+        /// Clear the value: the destination holds, or has just received, a+        /// confirmed address on its own host+        /// ([3.2](../../../../specs/wrong-host-work-url-heal/requirements.md#32), Q6).+        case remove(value: String, from: String)+        /// The hostname holds a value the pass cannot place, so it stays and+        /// stays reported (Q34, Q38).+        case stay(hostname: String)+    }++    /// What makes two candidates of a torn group the same one (Q70): the+    /// hostname and the value, as a pair rather than as one string with a+    /// separator picked to be unlikely — a hostname cannot contain a NUL, but+    /// the reader of the line had to know that to believe it.+    private struct Candidate: Hashable {+        let hostname: String+        let value: String+    }++    /// - Returns: one move per off-host value, in candidate order, plus one+    ///   `stay` per hostname holding a value the pass cannot place. A Work with+    ///   nothing off-host resolves to no moves at all, which is every Work in a+    ///   healthy library.+    static func resolve(_ snapshot: WorkSnapshot) -> [Move] {+        var hasOnHost: Set<String> = []+        var blocked: Set<String> = []+        var candidates: [(hostname: String, orderKey: WorkSiteMembership.OrderKey,+                          value: String, destination: String)] = []++        // Every set here — and every hostname a `Move` names — is keyed on the+        // **lowercased** hostname (Q65). A stored hostname is not normalised,+        // while `WorkURLPlanner.host(of:)` always lowercases, so keying on the+        // stored spelling left a `Example.com` membership invisible to a value+        // naming `example.com`: it would land beside that row rather than on it,+        // and the dedupe folds by hostname and never folds the pair.+        for membership in snapshot.memberships {+            let hostname = membership.hostname.lowercased()+            switch WorkURLPlanner.classifyWorkURL(membership.value, on: hostname) {+            case .empty:+                continue+            case .onHost:+                hasOnHost.insert(hostname)+            // The value Q14 keeps out of the tolerance — a blank one included+            // (Q63). It blocks a move onto its hostname (Q27) and is never+            // itself moved.+            case .other:+                blocked.insert(hostname)+            case .offHost(let destination):+                // `.offHost` is only ever reported for a value that parsed, so+                // the bind is the classification's own guarantee rather than a+                // default standing in for one.+                if let value = membership.value {+                    candidates.append((hostname, membership.orderKey, value, destination))+                }+            }+        }+        guard !candidates.isEmpty else { return [] }++        // Q34's least fixpoint: a hostname whose value points at a blocked+        // hostname is itself blocked, because the value in front of it is not+        // going to move out of the way. Iterating rather than recursing keeps a+        // cycle with no malformed value *unblocked*, which is what makes it+        // rotate.+        var changed = true+        while changed {+            changed = false+            for candidate in candidates+            where blocked.contains(candidate.destination) && !blocked.contains(candidate.hostname) {+                blocked.insert(candidate.hostname)+                changed = true+            }+        }++        // Q24's order, on synced fields only, so two devices healing the same+        // Work concurrently keep the same value. Two rows of a torn group+        // holding the *same* value under one hostname are one candidate: the+        // clear below matches on the value, so the second would be a duplicate+        // of the first's work.+        var seen: Set<Candidate> = []+        let ordered = candidates+            .sorted {+                $0.hostname == $1.hostname+                    ? $0.orderKey < $1.orderKey+                    : $0.hostname < $1.hostname+            }+            .filter { seen.insert(Candidate(hostname: $0.hostname, value: $0.value)).inserted }++        var claimed: Set<String> = []+        var stayed: Set<String> = []+        var moves: [Move] = []+        for candidate in ordered {+            if blocked.contains(candidate.destination) {+                if stayed.insert(candidate.hostname).inserted {+                    moves.append(.stay(hostname: candidate.hostname))+                }+                continue+            }+            // A destination that holds only *movable* values is empty after this+            // pass — every one of them lands or is removed — so the field this+            // value moves into is free. One that holds an on-host address keeps+            // it (Q6), and one another value has already claimed is taken.+            guard !hasOnHost.contains(candidate.destination),+                  claimed.insert(candidate.destination).inserted+            else {+                moves.append(.remove(value: candidate.value, from: candidate.hostname))+                continue+            }+            moves.append(+                .land(value: candidate.value, from: candidate.hostname, to: candidate.destination))+        }+        return moves+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swift Modified +32 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swiftindex 8429947..4ae3c84 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swift@@ -493,6 +493,38 @@ struct BackupV7ExportTests {                 == CharacterCoverageFingerprint.of(Self.genericNotes))     } +    /// `wrong-host-work-url-heal` [1.6](../../../../specs/wrong-host-work-url-heal/requirements.md#16),+    /// Q17: the export folds a duplicated `(Work, hostname)` pair to the row+    /// de-duplication would keep, and the discarded row's Work URL comes with+    /// it. A heal-minted row is in identity state `none`, so a later+    /// rule-identity row for the same hostname sorts ahead of it — and without+    /// the carry the address the heal had just preserved would leave the archive+    /// silently, which is what+    /// [4.2](../../../../specs/wrong-host-work-url-heal/requirements.md#42)+    /// depends on.+    ///+    /// The fold is a read (Q54): projecting must leave the context clean.+    @Test("The export fold carries a discarded membership's Work URL")+    func exportFoldCarriesTheWorkURL() throws {+        let store = try LibraryStore()+        let work = try #require(try store.context.fetch(FetchDescriptor<Work>()).first)+        let twin = WorkSiteMembership(+            hostname: Self.host, createdAt: Self.early.addingTimeInterval(60),+            urlIdentityState: .none, workURLString: "https://\(Self.host)/serial",+            workID: work.id, work: work)+        store.context.insert(twin)+        try store.context.save()++        let payload = try LibraryRepository.projectV7Payload(context: store.context)++        // One record for the pair, and it is the survivor's — carrying the+        // address the discarded row held.+        #expect(payload.memberships.count == 1)+        #expect(payload.memberships.first?.id != twin.id)+        #expect(payload.memberships.first?.workURLString == "https://\(Self.host)/serial")+        #expect(!store.context.hasChanges, "the projection must not dirty its context")+    }+     /// Q78: enumerated whole, never works→children. A character that synced     /// ahead of its work is inert in the app, but dropping it from the backup     /// would be losing reader data to a timing accident.
Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateWorkloadTests.swift Modified +123 / -3
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateWorkloadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateWorkloadTests.swiftindex c249b36..2c37641 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateWorkloadTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateWorkloadTests.swift@@ -12,6 +12,7 @@ struct CrossSiteDuplicateWorkloadTests {      private static let first = UUID(uuidString: "D1000000-0000-4000-8000-000000000001")!     private static let second = UUID(uuidString: "D1000000-0000-4000-8000-000000000002")!+    private static let third = UUID(uuidString: "D1000000-0000-4000-8000-000000000003")!      /// Two Works with the same parsed title on two different sites — the pair     /// Req 5.2 exists to surface.@@ -120,6 +121,104 @@ struct CrossSiteDuplicateWorkloadTests {         #expect(try await fixture.repository.membershipRows(of: Self.second).isEmpty)     } +    /// `wrong-host-work-url-heal` [1.6](../../../../specs/wrong-host-work-url-heal/requirements.md#16):+    /// the collapse deletes the loser's row for a hostname the survivor already+    /// holds, and a confirmed address on that row is reader content. It goes to+    /// the row that keeps the hostname rather than with the row that goes.+    @Test("A collapse carries a deleted membership's Work URL to the survivor")+    func collapseCarriesTheWorkURL() async throws {+        let fixture = try await crossSiteFixture()+        try await fixture.repository.seedMembership(+            workID: Self.first, hostname: "second.example",+            createdAt: M5Fixture.epoch.addingTimeInterval(60))+        try await fixture.repository.setWorkURL(+            "https://second.example/serial", workID: Self.second, hostname: "second.example")++        try await fixture.repository.collapseWorkMemberships(+            loser: Self.second, survivor: Self.first)++        let rows = try await fixture.repository.membershipRows(of: Self.first)+        #expect(rows.map(\.hostname).sorted() == ["first.example", "second.example"])+        #expect(+            rows.first { $0.hostname == "second.example" }?.workURLString+                == "https://second.example/serial")+    }++    /// `wrong-host-work-url-heal` Q62: the survivor is a **group**, not one row.+    /// The hostname is in the survivor's set because a *sibling* torn row holds+    /// it, so asking the representative alone for the row that keeps it found+    /// nothing — and the loser's confirmed address went into the delete with its+    /// row, which is the loss 1.6 exists to prevent.+    @Test("A collapse carries a Work URL to a hostname a sibling survivor row holds")+    func collapseCarriesTheWorkURLToASiblingRow() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "first.example"), M5SeedSite(hostname: "second.example")],+            works: [+                // The representative of the survivor group: its primary hostname+                // sorts first, so it is the row `target` used to name.+                M5SeedWork(+                    id: Self.first, displayTitle: "A Serial", hostname: "first.example",+                    titleProvenance: .parsed, lastParsedTitle: "A Serial"),+                // Its torn sibling, and the only survivor row on the hostname the+                // loser's address is confirmed for.+                M5SeedWork(+                    id: Self.first, displayTitle: "A Serial", hostname: "second.example",+                    titleProvenance: .parsed, lastParsedTitle: "A Serial"),+                M5SeedWork(+                    id: Self.second, displayTitle: "A Serial", hostname: "second.example",+                    titleProvenance: .parsed, lastParsedTitle: "A Serial",+                    workURLString: "https://second.example/serial"),+            ])++        try await fixture.repository.collapseWorkMemberships(+            loser: Self.second, survivor: Self.first)++        let rows = try await fixture.repository.membershipRows(of: Self.first)+        #expect(rows.map(\.hostname).sorted() == ["first.example", "second.example"])+        #expect(+            rows.first { $0.hostname == "second.example" }?.workURLString+                == "https://second.example/serial")+    }++    /// The other side of the carry: two losers on a hostname the survivor lacks.+    /// The first in order is **moved** onto the survivor and the second is+    /// discarded, so the row that keeps the hostname is the moved one rather than+    /// anything the survivor already held — and the discarded row's address has+    /// to reach it (Q62).+    @Test("A collapse carries a second loser's Work URL to the row that was moved")+    func collapseCarriesToAMovedRow() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "first.example"), M5SeedSite(hostname: "third.example")],+            works: [+                M5SeedWork(+                    id: Self.first, displayTitle: "A Serial", hostname: "first.example",+                    titleProvenance: .parsed, lastParsedTitle: "A Serial"),+                // Lower identifier, so this row is reached first and its+                // membership is the one moved onto the survivor. It holds no+                // address of its own.+                M5SeedWork(+                    id: Self.second, displayTitle: "A Serial", hostname: "third.example",+                    titleProvenance: .parsed, lastParsedTitle: "A Serial"),+                M5SeedWork(+                    id: Self.third, displayTitle: "A Serial", hostname: "third.example",+                    titleProvenance: .parsed, lastParsedTitle: "A Serial",+                    workURLString: "https://third.example/serial"),+            ])++        try await fixture.repository.collapseWorkMemberships(+            losers: [Self.second, Self.third], survivor: Self.first)++        let rows = try await fixture.repository.membershipRows(of: Self.first)+        #expect(rows.map(\.hostname).sorted() == ["first.example", "third.example"])+        #expect(+            rows.first { $0.hostname == "third.example" }?.workURLString+                == "https://third.example/serial")+        #expect(try await fixture.repository.membershipRows(of: Self.second).isEmpty)+        #expect(try await fixture.repository.membershipRows(of: Self.third).isEmpty)+    }+     // MARK: - Redirect (Req 5.4)      /// The edit basis carries one duplicate relation per membership, so a write@@ -158,6 +257,18 @@ struct CrossSiteDuplicateWorkloadTests {  extension LibraryRepository { +    /// Puts a confirmed Work URL on a membership the fixture already seeded.+    fileprivate func setWorkURL(_ url: String, workID: UUID, hostname: String) async throws {+        try await withLockedContext(mode: .exclusive, operation: "setting a Work URL") { context in+            for membership in try context.fetch(+                FetchDescriptor<WorkSiteMembership>(predicate: #Predicate { $0.workID == workID }))+            where membership.hostname == hostname {+                membership.workURLString = url+            }+            try context.save()+        }+    }+     fileprivate func distinctPairCount() async throws -> Int {         try await withLockedContext(mode: .shared, operation: "counting distinct pairs") { context in             try context.fetch(FetchDescriptor<WorkDistinctPair>()).count@@ -167,12 +278,17 @@ extension LibraryRepository {     /// Drives `DuplicateReconciler.collapseMemberships` directly: the two collapse     /// paths both call it, and what it does to a loser's memberships is what is     /// under test rather than how a set got there.-    fileprivate func collapseWorkMemberships(loser: UUID, survivor: UUID) async throws {+    ///+    /// Both row lists are in **representative order**, which is what the two+    /// production callers pass — so `target` here is the same row a real collapse+    /// would treat as the survivor's first, and a torn survivor group is not a+    /// coin toss.+    fileprivate func collapseWorkMemberships(losers: [UUID], survivor: UUID) async throws {         try await withLockedContext(mode: .exclusive, operation: "collapsing memberships") {             context in             let rows = try context.fetch(FetchDescriptor<Work>())-            let loserRows = rows.filter { $0.id == loser }-            let survivorRows = rows.filter { $0.id == survivor }+            let loserRows = GroupOrdering.sortedWorkRows(rows.filter { losers.contains($0.id) })+            let survivorRows = GroupOrdering.sortedWorkRows(rows.filter { $0.id == survivor })             try DuplicateReconciler.collapseMemberships(                 from: loserRows, to: survivorRows,                 distinctPairs: try context.fetch(FetchDescriptor<WorkDistinctPair>()),@@ -181,4 +297,8 @@ extension LibraryRepository {             try context.save()         }     }++    fileprivate func collapseWorkMemberships(loser: UUID, survivor: UUID) async throws {+        try await collapseWorkMemberships(losers: [loser], survivor: survivor)+    } }
Packages/AsterismCore/Tests/AsterismCoreTests/LibraryDiagnosticsTests.swift Modified +79 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryDiagnosticsTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryDiagnosticsTests.swiftindex 5c97d9a..97203e5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryDiagnosticsTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryDiagnosticsTests.swift@@ -108,6 +108,85 @@ struct LibraryDiagnosticsTests {         #expect(diagnostics.affectedRecordCount == 7)     } +    /// `wrong-host-work-url-heal` [2.6](../../../../specs/wrong-host-work-url-heal/requirements.md#26):+    /// one Work record, however many of its rows and memberships hold an+    /// off-host address, and it is not counted again where a `.siteMissing`+    /// already counts a Work under one of the **source** hostnames the diagnosis+    /// names (Q29).+    @Test("A wrong-host Work URL counts one work record, and not twice")+    func wrongHostWorkURLCountsOneWorkRecord() {+        let diagnosis = LibraryDiagnosis.wrongHostWorkURL(+            workID: "work-1", workTitle: "A Serial",+            memberships: [+                WrongHostMembership(hostname: "a.example", actualHost: "b.example"),+                WrongHostMembership(hostname: "c.example", actualHost: "d.example"),+            ])++        #expect(+            LibraryDiagnostics.union(tupleDiagnoses: [:], toleratedStates: [diagnosis])+                .affectedRecordCount == 1)++        // The Work is already counted as an orphaned Work on `a.example`.+        #expect(+            LibraryDiagnostics.union(+                tupleDiagnoses: [:],+                toleratedStates: [+                    diagnosis,+                    .siteMissing(hostname: "a.example", entryCount: 0, workCount: 1),+                ]).affectedRecordCount == 1)++        // Q59: a `.siteMissing` counting **no** Works on a source hostname+        // overlaps nothing, so the Work is still counted. The skip is scoped to+        // `workCount > 0`, not to the hostname merely being named.+        #expect(+            LibraryDiagnostics.union(+                tupleDiagnoses: [:],+                toleratedStates: [+                    diagnosis,+                    .siteMissing(hostname: "a.example", entryCount: 3, workCount: 0),+                ]).affectedRecordCount == 4)++        // A `.siteMissing` on a hostname the diagnosis does not name overlaps+        // nothing the counter can see, so both are counted.+        #expect(+            LibraryDiagnostics.union(+                tupleDiagnoses: [:],+                toleratedStates: [+                    diagnosis,+                    .siteMissing(hostname: "elsewhere.example", entryCount: 0, workCount: 1),+                ]).affectedRecordCount == 2)++        // Order must not decide it: the `.siteMissing` can come first or last.+        #expect(+            LibraryDiagnostics.union(+                tupleDiagnoses: [:],+                toleratedStates: [+                    .siteMissing(hostname: "c.example", entryCount: 2, workCount: 1),+                    diagnosis,+                ]).affectedRecordCount == 3)+    }++    /// The tolerated case never quarantines: that is the whole of Decision 1.+    @Test("A wrong-host Work URL quarantines nothing")+    func wrongHostWorkURLQuarantinesNothing() {+        let diagnostics = LibraryDiagnostics.union(+            tupleDiagnoses: [:],+            toleratedStates: [+                .wrongHostWorkURL(+                    workID: "work-1", workTitle: "A Serial",+                    memberships: [+                        WrongHostMembership(hostname: "a.example", actualHost: "b.example")+                    ])+            ])++        #expect(diagnostics.quarantineMap().isEmpty)+        // It sorts under its first source hostname, and re-teaching cannot clear+        // it — the repair is on the Work, or the heal's.+        #expect(diagnostics.diagnoses.first?.hostname == "a.example")+        #expect(diagnostics.diagnoses.first?.clearableByReteaching == false)+        #expect(diagnostics.diagnoses.first?.id == "wrongHostWorkURL:work-1")+    }+     @Test("An empty diagnosis set affects no records")     func emptyDiagnosticsAffectNoRecords() {         let diagnostics = LibraryDiagnostics.union(tupleDiagnoses: [:], toleratedStates: [])
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift Modified +588 / -3
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swiftindex 8d9d856..776029e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift@@ -18,6 +18,13 @@ struct MembershipReconcilerTests {      private static let epoch = Date(timeIntervalSince1970: 1_800_000_000) +    /// What a mint one `healStep` past `epoch` writes: the instant, quantized.+    /// Spelled through `MillisecondInstant` rather than as `epoch + healStep`+    /// because those two are not the same `Date` — they differ by one ULP, which+    /// is the whole reason the mint quantizes.+    private static let mintedCreatedAt = MillisecondInstant.quantize(+        epoch.addingTimeInterval(MembershipReconciler.healStep))+     // MARK: - Heal (Req 8.1)      @Test("An Entry whose Work holds no membership on its hostname is healed once")@@ -224,6 +231,106 @@ struct MembershipReconcilerTests {         #expect(try store.pairs().map(\.id) == [low.id])     } +    // MARK: - Fold carry (`wrong-host-work-url-heal` Req 1.6, Q33)++    /// A discarded row's confirmed address is reader content: it goes to the+    /// survivor rather than with the row. **On-host and off-host alike** (Q33) —+    /// de-duplication runs ungated on every pass while the heal waits for a+    /// derivation, so carrying only on-host values would delete an off-host one+    /// before the heal ever saw it.+    @Test(+        "Dedupe carries a discarded row's Work URL to a survivor holding none",+        arguments: ["https://carry.example/work", "https://elsewhere.example/work"])+    func dedupeCarriesALosersWorkURL(url: String) throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "carry.example", memberships: [])+        // The survivor by Q23's order — a rule identity outranks the `none` the+        // loser carries, whatever their dates.+        let survivor = store.insertMembership(+            work: work, hostname: "carry.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .rule, identity: "42", ruleID: UUID())+        store.insertMembership(+            work: work, hostname: "carry.example", createdAt: Self.epoch, state: .none,+            workURL: url)+        try store.save()++        #expect(try store.reconcile(heals: []).membershipsRemoved == 1)++        #expect(try store.memberships().map(\.id) == [survivor.id])+        #expect(survivor.workURLString == url)+    }++    /// The survivor's own value is never replaced: a fold keeps what the row it+    /// keeps already says.+    @Test("Dedupe leaves a survivor that already holds a Work URL alone")+    func dedupeKeepsTheSurvivorsOwnWorkURL() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "carry.example", memberships: [])+        let survivor = store.insertMembership(+            work: work, hostname: "carry.example", createdAt: Self.epoch, state: .none,+            workURL: "https://carry.example/kept")+        store.insertMembership(+            work: work, hostname: "carry.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .none, workURL: "https://carry.example/discarded")+        try store.save()++        #expect(try store.reconcile(heals: []).membershipsRemoved == 1)++        #expect(try store.memberships().map(\.id) == [survivor.id])+        #expect(survivor.workURLString == "https://carry.example/kept")+    }++    /// Q37: where more than one discarded row holds a value, the first in the+    /// survivor order is the one carried — the same order every device sorts by.+    @Test("Dedupe carries the first discarded value in survivor order")+    func dedupeCarriesTheFirstLoserInOrder() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "carry.example", memberships: [])+        let survivor = store.insertMembership(+            work: work, hostname: "carry.example", createdAt: Self.epoch, state: .rule,+            identity: "42", ruleID: UUID())+        // Inserted latest-first, so insertion order is not what decides.+        store.insertMembership(+            work: work, hostname: "carry.example", createdAt: Self.epoch.addingTimeInterval(120),+            state: .none, workURL: "https://carry.example/later")+        store.insertMembership(+            work: work, hostname: "carry.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .none, workURL: "https://carry.example/earlier")+        try store.save()++        #expect(try store.reconcile(heals: []).membershipsRemoved == 2)++        #expect(try store.memberships().map(\.id) == [survivor.id])+        #expect(survivor.workURLString == "https://carry.example/earlier")+    }++    /// Q12, and the seam the two phases share: a row the heal mints goes into+    /// the list the de-duplication reads, in the same run. Here the Work already+    /// holds a row for the destination hostname whose `workID` column is nil+    /// (Q58), so it is outside the heal's reading and the heal mints beside it —+    /// and the fold that follows keeps one row **with** the address.+    @Test("A minted membership is deduped in the same run and keeps its address")+    func mintedMembershipIsVisibleToDedupe() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "a.example", memberships: [])+        store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/work")+        let unnamed = store.insertMembership(+            work: work, hostname: "b.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .none, attachWithoutColumn: true)+        try store.save()++        let report = try store.reconcile()++        #expect(report.movedWorkURLs == 1)+        #expect(report.membershipsRemoved == 1)+        let onB = try store.memberships().filter { $0.hostname == "b.example" }+        #expect(onB.map(\.id) == [unnamed.id])+        #expect(onB.first?.workURLString == "https://b.example/work")+        #expect(try store.reconcile().isEmpty)+    }+     // MARK: - Re-attach and orphan tolerance (Req 8.3, Q22, Q37)      @Test("A membership whose Work has arrived is re-attached by its workID")@@ -309,6 +416,355 @@ struct MembershipReconcilerTests {         #expect(try store.pairs().map(\.id) == [pair.id])     } +    // MARK: - Wrong-host Work URL heal (`wrong-host-work-url-heal` Req 3)++    /// [3.1](requirements.md#31): the address moves onto the membership for the+    /// host it names, and leaves the one it was on.+    @Test("An off-host Work URL lands on the membership for its own host")+    func wrongHostURLLandsOnTheExistingMembership() throws {+        let store = try ReconcileStore()+        store.insertSite(hostname: "a.example")+        store.insertSite(hostname: "b.example")+        let work = store.insertWork(hostname: "a.example", memberships: [])+        let source = store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/work")+        let destination = store.insertMembership(+            work: work, hostname: "b.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .none)+        try store.save()++        let report = try store.reconcile()++        #expect(destination.workURLString == "https://b.example/work")+        #expect(source.workURLString == nil)+        // Counts and hostnames only ([3.10](requirements.md#310)): the report has+        // no field a title or an address could reach.+        var expected = MembershipReconcileReport()+        expected.movedWorkURLs = 1+        expected.touchedHostnames = ["a.example", "b.example"]+        #expect(report == expected)++        // [3.8](requirements.md#38): the second pass has nothing to write.+        #expect(try store.reconcile().isEmpty)+        #expect(destination.workURLString == "https://b.example/work")+    }++    /// [3.2](requirements.md#32), Q6: the destination already holds a confirmed+    /// address on its own host, so the stale value goes rather than replacing it.+    @Test("An off-host Work URL is removed where the destination is already addressed")+    func wrongHostURLRemovedWhereTheDestinationIsAddressed() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "a.example", memberships: [])+        let source = store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/stale")+        let destination = store.insertMembership(+            work: work, hostname: "b.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .rule, identity: "42", ruleID: UUID(), workURL: "https://b.example/confirmed")+        try store.save()++        #expect(try store.reconcile().movedWorkURLs == 1)++        #expect(source.workURLString == nil)+        #expect(destination.workURLString == "https://b.example/confirmed")+        #expect(try store.reconcile().isEmpty)+    }++    /// [3.3](requirements.md#33), [3.5](requirements.md#35), [3.6](requirements.md#36):+    /// the Work has no membership for the host, so the heal adds one — with no+    /// URL identity, no Site row where none has arrived, and a creation date+    /// that keeps it out of the primary slot.+    @Test("A heal with no membership for the host mints one that is not the primary")+    func wrongHostURLMintsTheDestinationMembership() throws {+        let store = try ReconcileStore()+        store.insertSite(hostname: "a.example")+        let work = store.insertWork(hostname: "a.example", memberships: [])+        let source = store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .rule,+            identity: "42", ruleID: UUID(), workURL: "https://b.example/work")+        try store.save()+        let primaryBefore = try #require(work.primaryMembership?.id)++        #expect(try store.reconcile().movedWorkURLs == 1)++        let minted = try #require(+            try store.memberships().first { $0.hostname == "b.example" })+        #expect(minted.workURLString == "https://b.example/work")+        #expect(minted.urlIdentityState == .none)+        #expect(minted.urlIdentity == nil)+        #expect(minted.urlIdentityRuleID == nil)+        #expect(minted.workID == work.id)+        #expect(minted.work?.id == work.id)+        // [3.6](requirements.md#36): no Site row is created, and the membership+        // is left unpinned the way one arriving ahead of its Site row already is.+        #expect(minted.site == nil)+        #expect(try store.context.fetch(FetchDescriptor<Site>()).count == 1)+        // [3.5](requirements.md#35): a step past the latest date the Work's+        // memberships held, so the site the Work is presented under is unchanged+        // — quantized, like every date this library writes, because the archive's+        // encoding quantizes and an unquantized one cannot be exported (Req 4.2).+        #expect(minted.createdAt == Self.mintedCreatedAt)+        #expect(MillisecondInstant.isQuantized(minted.createdAt))+        #expect(work.primaryMembership?.id == primaryBefore)+        #expect(source.workURLString == nil)+        #expect(try store.reconcile().isEmpty)+    }++    /// Q51, [3.4](requirements.md#34): a torn group holds the value on more than+    /// one row. Every row on the source hostname loses it, matched on the exact+    /// value so a cycle does not erase what has just landed.+    @Test("A torn group loses the moved value from every row on the source hostname")+    func wrongHostURLClearsEveryTornRow() throws {+        let store = try ReconcileStore()+        let workID = UUID()+        let first = store.insertWork(id: workID, hostname: "a.example", memberships: [])+        let second = store.insertWork(id: workID, hostname: "a.example", memberships: [])+        let onFirst = store.insertMembership(+            work: first, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/work")+        let onSecond = store.insertMembership(+            work: second, hostname: "a.example", createdAt: Self.epoch.addingTimeInterval(30),+            state: .none, workURL: "https://b.example/work")+        try store.save()++        #expect(try store.reconcile().movedWorkURLs == 1)++        #expect(onFirst.workURLString == nil)+        #expect(onSecond.workURLString == nil)+        let placed = try store.memberships().filter { $0.hostname == "b.example" }+        #expect(placed.count == 1)+        #expect(placed.first?.workURLString == "https://b.example/work")+    }++    /// [3.4](requirements.md#34)'s cycle, over rows: two memberships naming each+    /// other's hosts swap in one pass, and neither value is lost on the way.+    @Test("Two off-host values naming each other's hosts swap")+    func wrongHostURLsSwap() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "a.example", memberships: [])+        let a = store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/one")+        let b = store.insertMembership(+            work: work, hostname: "b.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .none, workURL: "https://a.example/two")+        try store.save()++        // Two addresses changed, so the report counts two: `movedWorkURLs` is a+        // count of **values**, not of Works ([3.10](requirements.md#310)).+        #expect(try store.reconcile().movedWorkURLs == 2)++        #expect(a.workURLString == "https://a.example/two")+        #expect(b.workURLString == "https://b.example/one")+        #expect(try store.reconcile().isEmpty)+    }++    /// Q65: a stored hostname keeps the case it was captured with, while the+    /// host a URL names is always lowercase. Matching the two byte for byte left+    /// the `Example.com` row invisible to the value naming `example.com`, so the+    /// heal minted a *second* membership for the same site in a different case —+    /// a pair the dedupe groups by hostname and therefore never folds.+    @Test("A destination stored in another case takes the value rather than a second row")+    func wrongHostURLLandsOnAMixedCaseMembership() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "a.example", memberships: [])+        let source = store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://example.com/work")+        let destination = store.insertMembership(+            work: work, hostname: "Example.com", createdAt: Self.epoch.addingTimeInterval(60),+            state: .none)+        try store.save()++        #expect(try store.reconcile().movedWorkURLs == 1)++        #expect(destination.workURLString == "https://example.com/work")+        #expect(source.workURLString == nil)+        // Nothing was minted: still the two rows the Work started with.+        #expect(try store.memberships().count == 2)+        #expect(try store.reconcile().isEmpty)+    }++    /// Q61: which row of a torn group a minted membership joins. A row holding+    /// **no** membership sorts *first* in representative order — its primary+    /// hostname is the empty string — so minting on the representative would make+    /// the minted row that row's primary and move the site the Work is presented+    /// under, which is exactly what [3.5](requirements.md#35) forbids. The mint+    /// joins the first row that already holds one instead.+    @Test("A mint joins the first row of a torn group that already holds a membership")+    func wrongHostURLMintsOnARowThatAlreadyHasMemberships() throws {+        let store = try ReconcileStore()+        let workID = UUID()+        let bare = store.insertWork(id: workID, hostname: "a.example", memberships: [])+        let held = store.insertWork(id: workID, hostname: "a.example", memberships: [])+        let source = store.insertMembership(+            work: held, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/work")+        try store.save()+        // The membership-less row really is the representative, which is what+        // makes this the case Q61 is about.+        #expect(GroupOrdering.sortedWorkRows(try store.works()).first === bare)++        #expect(try store.reconcile().movedWorkURLs == 1)++        let minted = try #require(try store.memberships().first { $0.hostname == "b.example" })+        #expect(minted.work === held)+        #expect(minted.createdAt == Self.mintedCreatedAt)+        // [3.5](requirements.md#35): neither row's presented site moved. The bare+        // row still holds nothing, and the other row's primary is the one it+        // started with.+        #expect(bare.primaryMembership == nil)+        #expect(held.primaryMembership?.id == source.id)+        #expect(source.workURLString == nil)+        #expect(try store.reconcile().isEmpty)+    }++    /// Q66: both derivations of the diagnosis skip a membership whose hostname is+    /// blank, so the heal must skip it too — repairing a row nothing reported is+    /// the one thing the tolerance does not license.+    @Test("A membership with a blank hostname is left where it is")+    func wrongHostURLSkipsABlankHostname() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "a.example", memberships: [])+        let blank = store.insertMembership(+            work: work, hostname: "   ", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/work")+        try store.save()++        for _ in 0..<2 { #expect(try store.reconcile().isEmpty) }++        #expect(blank.workURLString == "https://b.example/work")+        #expect(try store.memberships().count == 1)+    }++    /// Q66: the destination hostname is already held by a row the resolver never+    /// saw — its `workID` column is nil and only the relationship attaches it+    /// (Q58) — and that row holds a value. Minting beside it would leave the+    /// dedupe to fold the pair, and the survivor it keeps could be the row+    /// *without* the address that had just moved. The move stays instead, and+    /// the Work stays reported until the row is re-attached.+    @Test("A move stays where a row outside the reading already holds the destination")+    func wrongHostURLStaysWhereAnUnnamedRowHoldsTheDestination() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "a.example", memberships: [])+        let source = store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/work")+        let unnamed = store.insertMembership(+            work: work, hostname: "b.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .none, workURL: "https://b.example/confirmed", attachWithoutColumn: true)+        try store.save()++        // Nothing written, so nothing counted: a pass that could not repair the+        // Work must not report a change, or the launch path refreshes for ever.+        for _ in 0..<2 { #expect(try store.reconcile().isEmpty) }++        #expect(source.workURLString == "https://b.example/work")+        #expect(unnamed.workURLString == "https://b.example/confirmed")+        #expect(try store.memberships().count == 2)+    }++    /// [3.11](requirements.md#311): the heal saves on a **Work** boundary, so no+    /// save is a library a value has fallen out of. Three Works with the store's+    /// batch size of two is one save at the boundary and one for the remainder —+    /// which pins both halves: the pass batches, and a batch ends on a Work.+    @Test("The heal saves on Work boundaries")+    func wrongHostURLSavesOnWorkBoundaries() throws {+        let store = try ReconcileStore()+        for name in ["one", "two", "three"] {+            let work = store.insertWork(hostname: "a.example", memberships: [])+            store.insertMembership(+                work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+                workURL: "https://b.example/\(name)")+        }+        try store.save()++        let spy = InstrumentedSaveStrategy()+        let report = try store.reconcile(saveStrategy: spy)++        #expect(report.movedWorkURLs == 3)+        #expect(spy.attemptCount == 2)+        #expect(spy.successCount == 2)+    }++    /// Q34, Q38: a value whose destination holds one this feature does not+    /// touch stays where it is, and the Work stays reported.+    @Test("A value blocked by a malformed one at its destination stays put")+    func wrongHostURLBlockedByAMalformedValueStays() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "a.example", memberships: [])+        let source = store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/work")+        let blocker = store.insertMembership(+            work: work, hostname: "b.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .none, workURL: "not a url at all")+        try store.save()++        #expect(try store.reconcile().isEmpty)++        #expect(source.workURLString == "https://b.example/work")+        #expect(blocker.workURLString == "not a url at all")+    }++    /// [3.7](requirements.md#37), Q40: the phase is gated. A pass whose option+    /// set does not name it leaves the value where it is.+    @Test("The heal does not run when the pass is not asked for it")+    func wrongHostURLPhaseIsGated() throws {+        let store = try ReconcileStore()+        let work = store.insertWork(hostname: "a.example", memberships: [])+        let source = store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/work")+        try store.save()++        #expect(try store.reconcile(heals: .missingMembership).isEmpty)+        #expect(source.workURLString == "https://b.example/work")++        #expect(try store.reconcile(heals: .wrongHostWorkURL).movedWorkURLs == 1)+        #expect(source.workURLString == nil)+    }++    /// Q48: a Work that has not arrived has no row to mint on, so its membership+    /// is left alone — the same tolerance every other phase gives an orphan.+    @Test("A membership whose Work has not arrived is not healed")+    func wrongHostURLSkipsAnUnarrivedWork() throws {+        let store = try ReconcileStore()+        let orphan = store.insertMembership(+            work: nil, workID: UUID(), hostname: "a.example", createdAt: Self.epoch,+            state: .none, workURL: "https://b.example/work")+        try store.save()++        for _ in 0..<2 { #expect(try store.reconcile().isEmpty) }+        #expect(orphan.workURLString == "https://b.example/work")+        #expect(try store.memberships().count == 1)+    }++    /// [2.1](requirements.md#21) at the seam the pass itself re-validates: both+    /// hostnames are touched, and neither is a tuple failure afterwards.+    @Test("Both hostnames are touched and neither is quarantined after the pass")+    func wrongHostURLLeavesNoQuarantine() throws {+        let store = try ReconcileStore()+        store.insertSite(hostname: "a.example")+        store.insertSite(hostname: "b.example")+        let work = store.insertWork(hostname: "a.example", memberships: [])+        store.insertMembership(+            work: work, hostname: "a.example", createdAt: Self.epoch, state: .none,+            workURL: "https://b.example/work")+        store.insertMembership(+            work: work, hostname: "b.example", createdAt: Self.epoch.addingTimeInterval(60),+            state: .none)+        try store.save()++        let report = try store.reconcile()++        #expect(report.touchedHostnames == ["a.example", "b.example"])+        let revalidated = try LibraryValidator.validate(+            hostnames: report.touchedHostnames.sorted(), context: store.context)+        #expect(revalidated.isEmpty)+    }+     // MARK: - The wiring (Req 2.7, Q36)      /// A row a lagging writer syncs in: a Work with no membership at all.@@ -342,6 +798,74 @@ struct MembershipReconcilerTests {         await repository.shutdown()     } +    /// [3.7](requirements.md#37), Q28: a value the **open's** full validation+    /// found is healed on the launch pass, without waiting for a refresh.+    ///+    /// The `worksWithoutMembership` latch above is deliberately false from the+    /// open; copying it here would leave a diagnosed value standing for a whole+    /// session, which is what Decision 1 promises it will not do. The gate is+    /// derived from the published diagnostics instead (Q46), and the open+    /// publishes the validation's.+    @Test("A value the open's validation found is healed on the launch pass")+    func wrongHostURLHealedFromTheOpensValidation() async throws {+        let environment = try ReconcileRoot()+        let workID = UUID()+        let (_, seeded) = try await LibraryRepository.openForApp(environment.configuration)+        try await seeded.seedWrongHostWorkURL(+            source: "source.example", destination: "target.example", workID: workID)+        await seeded.shutdown()++        // A fresh open, so the store's state reaches the repository the way a+        // launch delivers it: through the certification's full validation.+        let (_, repository) = try await LibraryRepository.openForApp(environment.configuration)+        #expect(await repository.healsWrongHostWorkURL)++        let outcome = try await repository.reconcileAfterSync()++        #expect(outcome.memberships.movedWorkURLs == 1)+        // [3.9](requirements.md#39): a pass that only moved a URL still counts as+        // having changed the library, so the launch path refreshes after it.+        #expect(!outcome.isEmpty)+        #expect(try await repository.workURLFacts()+            == ["source.example": nil, "target.example": "https://target.example/work"])+        #expect(await repository.quarantineReason(hostname: "source.example") == nil)+        #expect(await repository.quarantineReason(hostname: "target.example") == nil)++        // And the diagnosis is gone on the next foreground pass, without a+        // relaunch.+        try await repository.refreshDiagnostics()+        #expect(!(await repository.healsWrongHostWorkURL))+        await repository.shutdown()+    }++    /// Q18's one-refresh lag, stated as a test: a value that arrives *after* the+    /// last derivation is healed on the pass that follows the next one.+    @Test("A value arriving after the last refresh is healed on the pass after the next")+    func wrongHostURLHealedOnThePassAfterTheNextRefresh() async throws {+        let environment = try ReconcileRoot()+        let (_, repository) = try await LibraryRepository.openForApp(environment.configuration)+        try await repository.seedWrongHostWorkURL(+            source: "source.example", destination: "target.example", workID: UUID())++        // Nothing has derived the state, so the pass does not pay for the walk.+        let ungated = try await repository.reconcileAfterSync()+        #expect(ungated.memberships.movedWorkURLs == 0)+        #expect(try await repository.workURLFacts()["source.example"]+            == "https://target.example/work")++        try await repository.refreshDiagnostics()+        #expect(await repository.healsWrongHostWorkURL)++        let outcome = try await repository.reconcileAfterSync()++        #expect(outcome.memberships.movedWorkURLs == 1)+        #expect(!outcome.isEmpty)+        #expect(try await repository.workURLFacts()+            == ["source.example": nil, "target.example": "https://target.example/work"])+        #expect(await repository.quarantineReason(hostname: "source.example") == nil)+        await repository.shutdown()+    }+     /// The other half: a Work with no hostname column either, reachable only     /// through its Entry, healed from that and its hostname re-validated even     /// though nothing had diagnosed it.@@ -389,10 +913,12 @@ private final class ReconcileStore {     func save() throws { try context.save() }      func reconcile(+        heals: MembershipReconciler.Heals = .all,+        saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy(),         sourceLocation: SourceLocation = #_sourceLocation     ) throws -> MembershipReconcileReport {         let report = try MembershipReconciler.run(-            context: context, batchSize: 2, saveStrategy: ModelContextSaveStrategy())+            context: context, batchSize: 2, saveStrategy: saveStrategy, heals: heals)         try context.save()         // Q59's standing guard, on the seam every test here goes through: a         // phase that added, re-attached or removed a membership must leave the@@ -456,12 +982,17 @@ private final class ReconcileStore {         createdAt: Date,         state: WorkURLIdentityState,         identity: String? = nil,-        ruleID: UUID? = nil+        ruleID: UUID? = nil,+        workURL: String? = nil,+        attachWithoutColumn: Bool = false     ) -> WorkSiteMembership {         let membership = WorkSiteMembership(             id: id, hostname: hostname, createdAt: createdAt, urlIdentity: identity,-            urlIdentityState: state, urlIdentityRuleID: ruleID,+            urlIdentityState: state, urlIdentityRuleID: ruleID, workURLString: workURL,             workID: workID ?? work?.id, work: work)+        // Q58's shape: the relationship is set and the column is not, which the+        // initialiser cannot produce on its own.+        if attachWithoutColumn { membership.workID = nil }         context.insert(membership)         return membership     }@@ -565,6 +1096,60 @@ extension LibraryRepository {         }     } +    /// A Work on two sites whose membership for the **first** holds an address+    /// on the second — the residue an older build left, and the only state this+    /// feature repairs.+    fileprivate func seedWrongHostWorkURL(+        source: String, destination: String, workID: UUID+    ) async throws {+        try await withLockedContext(+            mode: .exclusive, operation: "seeding a wrong-host Work URL"+        ) { context in+            let timestamp = Date(timeIntervalSince1970: 1_800_000_000)+            var sites: [String: Site] = [:]+            for hostname in [source, destination] {+                let site = Site(hostname: hostname, displayName: hostname)+                context.insert(site)+                sites[hostname] = site+            }+            let work = Work(id: workID, displayTitle: "A Misfiled Work", timestamp: timestamp)+            context.insert(work)++            for (offset, hostname) in [source, destination].enumerated() {+                let url = "https://\(hostname)/read/1"+                let entry = Entry(+                    captureTitle: "A Capture", captureTitleSource: .host, rawURLString: url,+                    hostname: hostname, entryIdentityKey: url,+                    timestamp: timestamp.addingTimeInterval(TimeInterval(offset)))+                entry.conservativeIdentityKey = url+                entry.editCitations { $0.workAssignment = .manual }+                context.insert(entry)+                entry.site = sites[hostname]+                entry.work = work++                let membership = WorkSiteMembership(+                    hostname: hostname, createdAt: timestamp.addingTimeInterval(TimeInterval(offset)),+                    // The wrong-host value sits on the **source** membership.+                    workURLString: hostname == source ? "https://\(destination)/work" : nil,+                    workID: workID, work: work, site: sites[hostname])+                context.insert(membership)+            }+            try context.save()+        }+    }++    /// Every membership's Work URL, by hostname — read inside the actor so no+    /// model crosses out.+    fileprivate func workURLFacts() async throws -> [String: String?] {+        try await withLockedContext(mode: .shared, operation: "reading the Work URLs") { context in+            var facts: [String: String?] = [:]+            for membership in try context.fetch(FetchDescriptor<WorkSiteMembership>()) {+                facts[membership.hostname] = membership.workURLString+            }+            return facts+        }+    }+     fileprivate func laggingRowFacts() async throws -> LaggingRowFacts {         try await withLockedContext(mode: .shared, operation: "reading the seeded row") { context in             var facts = LaggingRowFacts()
Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift Modified +59 / -8
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swiftindex a75e972..0b829ed 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift@@ -126,9 +126,58 @@ struct MultiSiteReviewFixTests {         #expect(urls["a.example"] == [nil, nil])     } +    /// `wrong-host-work-url-heal` [1.5](../../../../specs/wrong-host-work-url-heal/requirements.md#15):+    /// propagation copies a membership Work URL per hostname, and an older build+    /// may have left one whose host is another site's. Copying it across the+    /// torn group would multiply the residue the heal exists to move — and the+    /// heal runs first (Q13), so a value still here is one it could not place.+    @Test("Torn-group propagation does not copy an off-host Work URL")+    func propagationSkipsAnOffHostWorkURL() throws {+        let store = try DuplicateStore()+        let siteA = store.addSite(hostname: "a.example")+        let siteB = store.addSite(hostname: "b.example")+        let id = DuplicateStore.rankedID(1)+        let carrier = store.addWork(+            id: id, title: "A Serial", hostname: "a.example", createdAt: 0,+            notes: "One note", site: siteA)+        let other = store.addWork(+            id: id, title: "A Serial", hostname: "a.example", createdAt: 60,+            notes: "One note", site: siteA)+        for (index, row) in [carrier, other].enumerated() {+            let membership = WorkSiteMembership(+                hostname: "b.example",+                createdAt: Self.epoch.addingTimeInterval(120 + TimeInterval(index)),+                // The carrier's b.example membership holds a c.example address:+                // the exact residue this feature diagnoses.+                workURLString: row === carrier ? "https://c.example/serial" : nil,+                workID: row.id, work: row, site: siteB)+            store.seed.insert(membership)+        }+        try store.commit()++        try store.reconcile()++        let urls = try store.read { context -> [String: [String?]] in+            var byHostname: [String: [String?]] = [:]+            for membership in try context.fetch(FetchDescriptor<WorkSiteMembership>()) {+                byHostname[membership.hostname, default: []].append(membership.workURLString)+            }+            return byHostname+        }+        // The carrier keeps what it holds — propagation moves nothing off a row+        // — and the sibling is not given a copy of it.+        #expect(urls["b.example"]?.sorted { ($0 ?? "") < ($1 ?? "") }+            == [nil, "https://c.example/serial"])+        #expect(urls["a.example"] == [nil, nil])+    }+     // MARK: - C7 / Q66: a confirmed Work URL is an address on its own site -    @Test("A Work URL on another site is diagnosed, not tolerated")+    /// Still reported, and by its own case naming both hosts — but as the+    /// Work-keyed *tolerated* state, not the hostname's tuple failure+    /// (`wrong-host-work-url-heal` Decision 1). Quarantining a site over one+    /// Work's link took rule application off every capture on it.+    @Test("A Work URL on another site is reported without quarantining the site")     func offHostWorkURLIsDiagnosed() throws {         let store = try DuplicateStore()         let site = store.addSite(hostname: "a.example")@@ -139,15 +188,17 @@ struct MultiSiteReviewFixTests {         membership.workURLString = "https://b.example/serial"         try store.commit() -        let diagnosis = try store.diagnose().quarantineMap()["a.example"]-        // T-2289: its own case, naming both hosts.-        guard case .wrongHostWorkURL(_, let hostname, let actualHost, _, _) = diagnosis else {-            Issue.record("expected a wrong-host Work URL diagnosis, got \(String(describing: diagnosis))")+        let diagnostics = try store.diagnose()+        #expect(diagnostics.quarantineMap()["a.example"] == nil)+        guard case .wrongHostWorkURL(let workID, _, let memberships)?+            = diagnostics.wrongHostDiagnoses.first+        else {+            Issue.record("expected a wrong-host Work URL diagnosis, got \(diagnostics.diagnoses)")             return         }-        #expect(hostname == "a.example")-        #expect(actualHost == "b.example")-        #expect(diagnosis?.description.contains("a.example") == true)+        #expect(workID == DuplicateStore.rankedID(1).uuidString)+        #expect(+            memberships == [WrongHostMembership(hostname: "a.example", actualHost: "b.example")])     }      @Test("The host derivation is one derivation, and it is case-insensitive")
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift Modified +223 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swiftnew file mode 100644index 0000000..9b44e2a--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift@@ -0,0 +1,223 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Req 4: the repair costs a reader nothing but the repair.+///+/// Two claims, and they fail in different ways:+///+/// - **4.1**, that nothing about the store's shape or the file's shape moved.+///   The byte pin on the archive lives in `BackupGoldenExportTests` and the+///   entity-list pin in `ModelContractTests`; both pass unchanged, so what is+///   left to state here is the pair of version numbers and the one shape this+///   feature actually introduces — a **heal-minted** membership, which must+///   serialise with the keys 7/8 already declared and no others.+/// - **4.2**, the round trip. The heal can mint a membership for a hostname the+///   library has no `Site` row for (Req 3.6), and that row is the one an archive+///   has no obvious place for: the codec wants every hostname to name a Site.+///   `BackupArchiveProjection` synthesises an untaught wire Site for a dangling+///   membership hostname (Q40), so the archive carries the row, and the import+///   materialises the Site the export never wrote to the store.+///+/// Hostnames are lowercase throughout, which is what `WorkURLPlanner.host(of:)`+/// produces and therefore what the heal mints (Q65).+@Suite("Wrong-host Work URL compatibility", .serialized)+struct WrongHostWorkURLCompatibilityTests {++    static let source = "source.example"+    static let destination = "elsewhere.example"+    static let movedURL = "https://elsewhere.example/work"+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    /// The eight keys a 7/8 membership object has ever had. A minted row that+    /// needed a ninth would be a wire-format change, which is the thing+    /// [4.1](requirements.md#41) forbids.+    static let membershipKeys: Set<String> = [+        "id", "workID", "hostname", "createdAt", "urlIdentity", "urlIdentityState",+        "urlIdentityRuleID", "workURLString",+    ]++    // MARK: - Req 4.1++    @Test("The heal moves neither the store's schema version nor the archive's")+    func versionsAreUnchanged() {+        // The store: V9 is still the live schema and V8 the one frozen snapshot,+        // so a library this feature has healed opens on every build that opened+        // it before. No migration stage was added.+        #expect(AsterismSchemaV9.versionIdentifier == Schema.Version(9, 0, 0))+        #expect(AsterismSchemaV8.versionIdentifier == Schema.Version(8, 0, 0))+        // The file: format 7 over schema 8, the pair every archive since+        // `multi-site-works` carries.+        #expect(BackupV7Document.formatVersion == 7)+        #expect(BackupV7Document.schemaVersion == 8)+    }++    @Test("A healed library's archive uses only the membership keys 7/8 declared")+    func healedArchiveAddsNoKey() async throws {+        let environment = try CompatibilityRoot()+        let archive = try await environment.healedArchive(workID: UUID())++        let document = try #require(+            try JSONSerialization.jsonObject(with: archive) as? [String: Any])+        let payload = try #require(document["payload"] as? [String: Any])+        let memberships = try #require(payload["memberships"] as? [[String: Any]])++        #expect(memberships.count == 2)+        for membership in memberships {+            #expect(Set(membership.keys).isSubset(of: Self.membershipKeys))+        }+        // The minted row, as the file spells it: no identity, no rule, the+        // address that moved onto it.+        let minted = try #require(+            memberships.first { $0["hostname"] as? String == Self.destination })+        #expect(minted["workURLString"] as? String == Self.movedURL)+        #expect(minted["urlIdentityState"] as? String == "none")+        #expect(minted["urlIdentity"] == nil)+        #expect(minted["urlIdentityRuleID"] == nil)+    }++    // MARK: - Req 4.2++    @Test("A heal-minted membership for a host with no Site row survives export and re-import")+    func mintedMembershipRoundTrips() async throws {+        let environment = try CompatibilityRoot()+        let workID = UUID()+        let archive = try await environment.healedArchive(workID: workID)++        // The archive: the minted row is there, and so is the untaught wire Site+        // the projection synthesised for its hostname — without which the+        // reference checks would refuse a membership naming no Site.+        let decoded = try BackupV7Codec.decode(archive)+        let minted = try #require(+            decoded.payload.memberships.first { $0.hostname == Self.destination })+        #expect(minted.workID == workID)+        #expect(minted.workURLString == Self.movedURL)+        #expect(+            decoded.payload.sites.contains {+                $0.hostname == Self.destination && $0.mode == .untaught+            })++        // The restore, into a library that has never seen any of it.+        let target = try CompatibilityRoot()+        let (_, repository) = try await LibraryRepository.openForApp(target.configuration)+        _ = try await repository.confirmImport(plan: try BackupImporter.plan(from: archive))++        #expect(+            try await repository.membershipFacts() == [+                MembershipFacts(+                    hostname: Self.destination, workID: workID, workURLString: Self.movedURL),+                MembershipFacts(hostname: Self.source, workID: workID, workURLString: nil),+            ])+        // The Site the export synthesised is a real row here, which is how the+        // restored membership is pinned to a site at all.+        #expect(try await repository.siteHostnames() == [Self.destination, Self.source])+        // Nothing to repair on the far side: every value is on the host it names.+        #expect(!(await repository.healsWrongHostWorkURL))+        await repository.shutdown()+    }+}++// MARK: - Fixtures++/// A membership as this suite compares them, read inside the actor so no model+/// crosses out.+private struct MembershipFacts: Equatable, Comparable, Sendable {+    let hostname: String+    let workID: UUID?+    let workURLString: String?++    static func < (lhs: Self, rhs: Self) -> Bool { lhs.hostname < rhs.hostname }+}++private struct CompatibilityRoot {+    let directory: URL+    let configuration: LibraryConfiguration++    init() throws {+        directory = FileManager.default.temporaryDirectory.appending(+            path: "WrongHostCompatibilityTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+    }++    /// Seed the wrong-host value, heal it, and export the result — the bytes a+    /// reader would have on disk after the repair ran.+    func healedArchive(workID: UUID) async throws -> Data {+        let (_, seeded) = try await LibraryRepository.openForApp(configuration)+        try await seeded.seedWrongHostURLWithNoDestinationSite(workID: workID)+        await seeded.shutdown()++        // A fresh open, so the open's full validation publishes the diagnosis the+        // launch pass's heal is gated on (Q46).+        let (_, repository) = try await LibraryRepository.openForApp(configuration)+        let outcome = try await repository.reconcileAfterSync()+        #expect(outcome.memberships.movedWorkURLs == 1)++        let exporter = BackupV7Exporter(+            repository: repository, stagingDirectory: directory.appending(path: "staging"))+        let result = try await exporter.export(+            metadata: BackupV7Metadata(+                appBuild: "test-1.0",+                exportedAt: WrongHostWorkURLCompatibilityTests.epoch))+        let data = try Data(contentsOf: result.fileURL)+        exporter.cleanup(result)+        await repository.shutdown()+        return data+    }+}++extension LibraryRepository {++    /// One Work, on one site, whose membership holds an address on a hostname+    /// this library has no `Site` row and no membership for — so the heal has to+    /// mint the destination rather than write to it.+    fileprivate func seedWrongHostURLWithNoDestinationSite(workID: UUID) async throws {+        try await withLockedContext(+            mode: .exclusive, operation: "seeding a wrong-host Work URL"+        ) { context in+            let hostname = WrongHostWorkURLCompatibilityTests.source+            let timestamp = WrongHostWorkURLCompatibilityTests.epoch+            let site = Site(hostname: hostname, displayName: hostname)+            context.insert(site)+            let work = Work(id: workID, displayTitle: "A Misfiled Work", timestamp: timestamp)+            context.insert(work)++            let url = "https://\(hostname)/read/1"+            let entry = Entry(+                captureTitle: "A Capture", captureTitleSource: .host, rawURLString: url,+                hostname: hostname, entryIdentityKey: url, timestamp: timestamp)+            entry.conservativeIdentityKey = url+            entry.editCitations { $0.workAssignment = .manual }+            context.insert(entry)+            entry.site = site+            entry.work = work++            let membership = WorkSiteMembership(+                hostname: hostname, createdAt: timestamp,+                workURLString: WrongHostWorkURLCompatibilityTests.movedURL,+                workID: workID, work: work, site: site)+            context.insert(membership)+            try context.save()+        }+    }++    fileprivate func membershipFacts() async throws -> [MembershipFacts] {+        try await withLockedContext(mode: .shared, operation: "reading memberships") { context in+            try context.fetch(FetchDescriptor<WorkSiteMembership>())+                .map {+                    MembershipFacts(+                        hostname: $0.hostname, workID: $0.resolvedWorkID,+                        workURLString: $0.workURLString)+                }+                .sorted()+        }+    }++    fileprivate func siteHostnames() async throws -> [String] {+        try await withLockedContext(mode: .shared, operation: "reading sites") { context in+            try context.fetch(FetchDescriptor<Site>()).map(\.hostname).sorted()+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLDiagnosisTests.swift Modified +34 / -10
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLDiagnosisTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLDiagnosisTests.swiftindex 7d87bb5..7dbe786 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLDiagnosisTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLDiagnosisTests.swift@@ -9,6 +9,12 @@ import Testing /// per-hostname Work URLs. Re-teaching rewrites rules and citations, never a /// Work URL, so the diagnosis must not offer it — and must say enough for the /// reader to find the Work.+///+/// `wrong-host-work-url-heal` Decision 1 changed what class it is: it was the+/// hostname's tuple failure, which took the site's rules off every capture on+/// it, and it is now a Work-keyed tolerated state that quarantines nothing and+/// that the reconciler repairs. What T-2289 established survives that — the row+/// is not re-teachable, and it names the Work and both hosts. @Suite("Wrong-host Work URL diagnosis (T-2289)", .serialized) struct WrongHostWorkURLDiagnosisTests { @@ -33,21 +39,31 @@ struct WrongHostWorkURLDiagnosisTests {         return (fixture, diagnosis)     } -    @Test("The reason names the host the address is actually on")+    @Test("The diagnosis names the Work and the host the address is actually on")     func reasonNamesTheActualHost() throws {         let (_, diagnosis) = try seed()-        guard case .siteTuple(_, let reason) = diagnosis,-              case .wrongHostWorkURL(_, let hostname, let actualHost, let workID, let workTitle) = reason else {+        guard case .wrongHostWorkURL(_, let workTitle, let memberships) = diagnosis else {             Issue.record("expected a wrong-host Work URL diagnosis, got \(diagnosis)"); return         }-        #expect(hostname == UnreadableRuleFixture.hostname)-        #expect(actualHost == "m.tth.example")-        // The Work is named for the screen, and kept out of the logged text.-        #expect(workID != nil)+        #expect(+            memberships == [+                WrongHostMembership(+                    hostname: UnreadableRuleFixture.hostname, actualHost: "m.tth.example")+            ])+        // The Work is named for the screen…         #expect(workTitle == "A Story")-        #expect(!reason.description.contains("A Story"))-        #expect(reason.description.contains("it is on m.tth.example"))-        #expect(!reason.description.contains("/fiction/a-story"))+        // …and the address itself never is: the payload carries hosts, not URLs+        // ([3.10](../../../../specs/wrong-host-work-url-heal/requirements.md#310)).+        #expect(!"\(diagnosis)".contains("/fiction/a-story"))+    }++    /// Decision 1: one Work's wrong link may not take the site's rules away from+    /// every capture on it. This is the disproportion the whole feature removes.+    @Test("The hostname is not quarantined")+    func theHostnameIsNotQuarantined() throws {+        let (fixture, _) = try seed()+        let diagnostics = try LibraryValidator.validate(context: fixture.freshContext())+        #expect(diagnostics.quarantineMap()[UnreadableRuleFixture.hostname] == nil)     }      @Test("A membership diagnosis does not offer re-teaching")@@ -56,6 +72,10 @@ struct WrongHostWorkURLDiagnosisTests {         #expect(!diagnosis.clearableByReteaching)     } +    /// The manual repair the Check Library row offers (Q11) still clears the+    /// row, and it clears it on **both** derivations — the foreground pass is+    /// what a refresh republishes, so a state only the full validator dropped+    /// would come back on the next refresh.     @Test("Confirming an address on the site clears the diagnosis")     func confirmingAnAddressOnTheSiteClears() async throws {         let (fixture, _) = try seed()@@ -72,5 +92,9 @@ struct WrongHostWorkURLDiagnosisTests {         #expect(await repository.quarantineReason(hostname: UnreadableRuleFixture.hostname) == nil)         let diagnostics = try LibraryValidator.validate(context: fixture.freshContext())         #expect(diagnostics.quarantineMap()[UnreadableRuleFixture.hostname] == nil)+        #expect(diagnostics.wrongHostDiagnoses.isEmpty)+        let scan = try LibraryToleranceScan.scan(context: fixture.freshContext())+        #expect(scan.diagnoses.wrongHostOnly.isEmpty)+        #expect(scan.wrongHostWorkURLCount == 0)     } }
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift Modified +504 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swiftnew file mode 100644index 0000000..d01b78d--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift@@ -0,0 +1,504 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Backup import's half of `wrong-host-work-url-heal` (Req 1.1–1.4, 1.7, 1.8).+///+/// Two things are under test and they are deliberately separate:+///+/// - the **pre-pass**, a pure function over the archive's membership records,+///   which applies the heal's precedence wherever this import is the thing that+///   decides what the destination record ends up holding (Q30, Q32);+/// - the **plan gate**, which no longer refuses an archive for a tolerated+///   off-host value and, when it does refuse for a membership finding, names the+///   membership record rather than calling the Site illegal (Q20, Q55).+///+/// The precedence cells are asserted on the pure function because the cell is+/// the *combination* of "is the destination written" and "what will it hold",+/// and building sixteen live stores to vary two booleans says nothing the+/// function does not. Two end-to-end imports pin the wiring.+@Suite("Wrong-host Work URL at import", .serialized)+struct WrongHostWorkURLImportTests {++    // MARK: - Req 1.8: the destination will be written and will hold nothing++    @Test("An inserted destination record takes the off-host URL")+    func destinationInsertedTakesTheURL() {+        let work = UUID()+        let source = record(work: work, hostname: "a.example", workURL: "https://b.example/w")+        let destination = record(work: work, hostname: "b.example", workURL: nil)++        let normalized = LibraryRepository.normalizedMemberships(+            [source, destination], existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, source.id) == nil)+        #expect(url(of: normalized, destination.id) == "https://b.example/w")+    }++    @Test("A destination record this import updates takes the off-host URL")+    func destinationUpdatedForAnAppliedWorkTakesTheURL() {+        let work = UUID()+        let source = record(work: work, hostname: "a.example", workURL: "https://b.example/w")+        let destination = record(work: work, hostname: "b.example", workURL: nil)++        // Both rows are already in the library and the import applies their Work,+        // so the update branch writes the archive's values over them.+        let normalized = LibraryRepository.normalizedMemberships(+            [source, destination],+            existingMembershipIDs: [source.id, destination.id], appliedWorkIDs: [work])++        #expect(url(of: normalized, source.id) == nil)+        #expect(url(of: normalized, destination.id) == "https://b.example/w")+    }++    // MARK: - Req 1.1, 1.3: the outcome is not this import's to decide++    @Test("A destination record whose update this import skips leaves both unchanged")+    func destinationUpdateSkippedLeavesBothUnchanged() {+        let work = UUID()+        let source = record(work: work, hostname: "a.example", workURL: "https://b.example/w")+        let destination = record(work: work, hostname: "b.example", workURL: nil)++        // The Work's record did not pass the recency gate, so `commitMemberships`+        // skips the update: what the destination row holds afterwards is the+        // library's value, which this pass cannot see (Q32).+        let normalized = LibraryRepository.normalizedMemberships(+            [source, destination],+            existingMembershipIDs: [source.id, destination.id], appliedWorkIDs: [])++        #expect(url(of: normalized, source.id) == "https://b.example/w")+        #expect(url(of: normalized, destination.id) == nil)+    }++    @Test("An archive with no membership for the host leaves the source unchanged")+    func destinationAbsentLeavesTheSourceUnchanged() {+        let work = UUID()+        let source = record(work: work, hostname: "a.example", workURL: "https://b.example/w")++        let normalized = LibraryRepository.normalizedMemberships(+            [source], existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, source.id) == "https://b.example/w")+    }++    @Test("A destination holding its own off-host value leaves both unchanged")+    func destinationOffHostLeavesBothUnchanged() {+        let work = UUID()+        let source = record(work: work, hostname: "a.example", workURL: "https://b.example/w")+        let destination = record(work: work, hostname: "b.example", workURL: "https://c.example/w")++        // Q25: import has no minting step, so overwriting `b`'s own off-host+        // value would delete it. The heal places every link of the chain.+        let normalized = LibraryRepository.normalizedMemberships(+            [source, destination], existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, source.id) == "https://b.example/w")+        #expect(url(of: normalized, destination.id) == "https://c.example/w")+    }++    @Test("Two off-host values naming each other are both left for the heal")+    func mutualOffHostValuesAreLeftForTheHeal() {+        let work = UUID()+        let first = record(work: work, hostname: "a.example", workURL: "https://b.example/w")+        let second = record(work: work, hostname: "b.example", workURL: "https://a.example/w")++        let normalized = LibraryRepository.normalizedMemberships(+            [first, second], existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, first.id) == "https://b.example/w")+        #expect(url(of: normalized, second.id) == "https://a.example/w")+    }++    // MARK: - Req 1.2: the destination's own address wins++    @Test("A destination holding an on-host value keeps it and the source is stripped")+    func destinationOnHostStripsTheSource() {+        let work = UUID()+        let source = record(work: work, hostname: "a.example", workURL: "https://b.example/w")+        let destination = record(work: work, hostname: "b.example", workURL: "https://b.example/own")++        let normalized = LibraryRepository.normalizedMemberships(+            [source, destination], existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, source.id) == nil)+        #expect(url(of: normalized, destination.id) == "https://b.example/own")+    }++    // MARK: - Q30: the gate is on the destination's write, not the source's++    @Test("A source whose own update is skipped still normalises onto an inserted destination")+    func sourceUpdateSkippedStillNormalisesTheDestination() {+        let work = UUID()+        let source = record(work: work, hostname: "a.example", workURL: "https://b.example/w")+        let destination = record(work: work, hostname: "b.example", workURL: nil)++        // The source row is already in the library and its Work is not applied,+        // so the source's own write is skipped; the destination row is new, and+        // an insert is unconditional. The design accepts the consequence: the+        // library row keeps the off-host value while the destination holds it+        // too, which is the heal's 3.2 case on the next pass.+        let normalized = LibraryRepository.normalizedMemberships(+            [source, destination],+            existingMembershipIDs: [source.id], appliedWorkIDs: [])++        #expect(url(of: normalized, source.id) == nil)+        #expect(url(of: normalized, destination.id) == "https://b.example/w")+    }++    // MARK: - Q31: a record naming no Work takes no part++    @Test("A record naming no Work is neither a source nor a destination")+    func recordsWithoutAWorkTakeNoPart() {+        let work = UUID()+        let orphan = record(work: nil, hostname: "a.example", workURL: "https://b.example/w")+        let orphanDestination = record(work: nil, hostname: "b.example", workURL: nil)+        let source = record(work: work, hostname: "c.example", workURL: "https://b.example/w")++        let normalized = LibraryRepository.normalizedMemberships(+            [orphan, orphanDestination, source],+            existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, orphan.id) == "https://b.example/w")+        #expect(url(of: normalized, orphanDestination.id) == nil)+        // The Work's own source finds no destination of *its* Work, so it stays.+        #expect(url(of: normalized, source.id) == "https://b.example/w")+    }++    // MARK: - Req 1.7: several off-host values on one Work, in candidate order++    @Test("Two off-host values naming one host place the first and strip the second")+    func twoValuesNamingOneHostPlaceTheFirst() {+        let work = UUID()+        let first = record(work: work, hostname: "a.example", workURL: "https://b.example/first")+        let second = record(work: work, hostname: "c.example", workURL: "https://b.example/second")+        let destination = record(work: work, hostname: "b.example", workURL: nil)++        // Candidate order is source hostname ascending (Q24), so `a.example`'s+        // value lands and `c.example`'s is removed — the same winner on every+        // device, and the same one the heal would pick.+        let normalized = LibraryRepository.normalizedMemberships(+            [second, destination, first], existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, destination.id) == "https://b.example/first")+        #expect(url(of: normalized, first.id) == nil)+        #expect(url(of: normalized, second.id) == nil)+    }++    @Test("The pre-pass is independent of the order the records arrive in")+    func normalisationIsOrderIndependent() {+        let work = UUID()+        let first = record(work: work, hostname: "a.example", workURL: "https://b.example/first")+        let second = record(work: work, hostname: "c.example", workURL: "https://b.example/second")+        let destination = record(work: work, hostname: "b.example", workURL: nil)++        let forward = LibraryRepository.normalizedMemberships(+            [first, second, destination], existingMembershipIDs: [], appliedWorkIDs: [work])+        let reversed = LibraryRepository.normalizedMemberships(+            [destination, second, first], existingMembershipIDs: [], appliedWorkIDs: [work])++        for id in [first.id, second.id, destination.id] {+            #expect(url(of: forward, id) == url(of: reversed, id))+        }+    }++    /// Q64: the pre-pass is one sequential reading, not a fixpoint. Each+    /// candidate reads its destination as it stands at that moment, so in the+    /// chain `a → b, b → c` with `c` empty, `a` is considered while `b` still+    /// holds an off-host value of its own — the "leave both unchanged" cell —+    /// and only `b` moves. `a` is left for the launch heal, which resolves the+    /// whole chain in one reading and can mint the row this pass cannot.+    @Test("A chain of off-host values moves only its last link")+    func aChainMovesOnlyItsLastLink() {+        let work = UUID()+        let first = record(work: work, hostname: "a.example", workURL: "https://b.example/w")+        let middle = record(work: work, hostname: "b.example", workURL: "https://c.example/w")+        let last = record(work: work, hostname: "c.example", workURL: nil)++        let normalized = LibraryRepository.normalizedMemberships(+            [first, middle, last], existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, first.id) == "https://b.example/w")+        #expect(url(of: normalized, middle.id) == nil)+        #expect(url(of: normalized, last.id) == "https://c.example/w")+    }++    // MARK: - Q63: one emptiness rule++    /// A blank destination value is **held**, not empty: the column is non-nil,+    /// so the validator reports it and Check Library shows it. Treating `""` as+    /// free space let the import write over a value the reader can still see.+    @Test("A destination holding a blank value is left for the heal", arguments: ["", " "])+    func blankDestinationIsNotEmptySpace(blank: String) {+        let work = UUID()+        let source = record(work: work, hostname: "a.example", workURL: "https://b.example/w")+        let destination = record(work: work, hostname: "b.example", workURL: blank)++        let normalized = LibraryRepository.normalizedMemberships(+            [source, destination], existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, source.id) == "https://b.example/w")+        #expect(url(of: normalized, destination.id) == blank)+    }++    /// And a blank *source* is not a candidate: it is not an absolute HTTP(S)+    /// URL, so there is nothing to move and nothing to strip.+    @Test("A blank source value is never a candidate", arguments: ["", " "])+    func blankSourceIsNotACandidate(blank: String) {+        let work = UUID()+        let source = record(work: work, hostname: "a.example", workURL: blank)+        let other = record(work: work, hostname: "b.example", workURL: nil)++        let normalized = LibraryRepository.normalizedMemberships(+            [source, other], existingMembershipIDs: [], appliedWorkIDs: [work])++        #expect(url(of: normalized, source.id) == blank)+        #expect(url(of: normalized, other.id) == nil)+    }++    @Test("An archive with nothing off-host is returned unchanged")+    func onHostArchiveIsUntouched() {+        let work = UUID()+        let records = [+            record(work: work, hostname: "a.example", workURL: "https://a.example/w"),+            record(work: work, hostname: "b.example", workURL: nil),+        ]++        #expect(+            LibraryRepository.normalizedMemberships(+                records, existingMembershipIDs: [], appliedWorkIDs: [work]) == records)+    }++    // MARK: - Req 1.4: the plan gate++    @Test("The plan gate accepts an archive whose only finding is the tolerated shape")+    func planGateAcceptsAToleratedOffHostValue() throws {+        let plan = makePlan(memberships: { work, _ in+            [record(id: work, work: work, hostname: siteHostname, workURL: "https://\(otherHostname)/w")]+        })++        let counts = try LibraryRepository.validateImportPlanPayload(plan.payload)++        #expect(counts.works == 1)+    }++    @Test("The plan gate still refuses an off-host membership that names no Work")+    func planGateRefusesAnOffHostValueWithoutAWork() throws {+        let plan = makePlan(memberships: { work, _ in+            [record(id: work, work: nil, hostname: siteHostname, workURL: "https://\(otherHostname)/w")]+        })+        let membershipID = plan.payload.memberships[0].id++        let error = refusal(of: plan.payload)++        // Q31: outside the tolerance, so it is still a refusal — and Q55's+        // wording, naming the membership record and its hostname.+        guard let error, case .invalidStateTuple(let type, let id, let reason) = error else {+            Issue.record("Expected an invalidStateTuple, got \(String(describing: error))")+            return+        }+        #expect(type == "WorkSiteMembership")+        #expect(id == membershipID.uuidString)+        #expect(reason.contains(siteHostname))+        #expect(reason.contains(otherHostname))+        #expect(!reason.contains("Site is not legal"))+    }++    @Test("A membership refusal names the membership record and its hostname")+    func membershipRefusalNamesTheMembership() throws {+        let plan = makePlan(memberships: { work, _ in+            // A legacy identity state with no identity value: an illegal+            // membership tuple that has nothing to do with the Work URL.+            [+                BackupV7Membership(+                    id: work, workID: work, hostname: siteHostname, createdAt: epoch,+                    urlIdentity: nil, urlIdentityState: .legacyUnverified,+                    urlIdentityRuleID: nil, workURLString: nil)+            ]+        })+        let membershipID = plan.payload.memberships[0].id++        let error = refusal(of: plan.payload)++        guard let error, case .invalidStateTuple(let type, let id, let reason) = error else {+            Issue.record("Expected an invalidStateTuple, got \(String(describing: error))")+            return+        }+        #expect(type == "WorkSiteMembership")+        #expect(id == membershipID.uuidString)+        #expect(reason.contains(siteHostname))+        #expect(reason.contains("legacy identity requires a value and no rule"))+    }++    @Test("A Site finding keeps the Site wrapper")+    func siteRefusalIsUnchanged() throws {+        // A taught Site with no active title rule: the long-standing tuple+        // refusal, and the wrapper it has always carried.+        let plan = makePlan(activePattern: false, memberships: { _, _ in [] })++        let error = refusal(of: plan.payload)++        guard let error, case .invalidStateTuple(let type, let id, let reason) = error else {+            Issue.record("Expected an invalidStateTuple, got \(String(describing: error))")+            return+        }+        #expect(type == "Site")+        #expect(id == siteHostname)+        #expect(reason.contains("imported Site is not legal"))+    }++    // MARK: - The wiring, end to end++    @Test("An import moves an off-host URL onto the archive's own membership")+    func importMovesTheURLOntoItsOwnMembership() async throws {+        let environment = try ImportEnvironment()+        let (_, repository) = try await LibraryRepository.openForApp(environment.configuration)+        let plan = makePlan(memberships: { work, other in+            [+                record(id: work, work: work, hostname: siteHostname,+                       workURL: "https://\(otherHostname)/w"),+                record(id: other, work: work, hostname: otherHostname, workURL: nil),+            ]+        })++        _ = try await repository.confirmImport(plan: plan)++        #expect(+            try await repository.workURLsByHostname() == [+                otherHostname: "https://\(otherHostname)/w",+                siteHostname: nil,+            ])+    }++    @Test("An import the pre-pass cannot place keeps the value and reports it")+    func importLeavesAnUnplaceableValueForTheHeal() async throws {+        let environment = try ImportEnvironment()+        let (_, repository) = try await LibraryRepository.openForApp(environment.configuration)+        let plan = makePlan(memberships: { work, _ in+            [record(id: work, work: work, hostname: siteHostname,+                    workURL: "https://\(otherHostname)/w")]+        })++        _ = try await repository.confirmImport(plan: plan)++        // Req 1.3: imported unchanged, and reported as the tolerated state for+        // the heal to place on the next reconciliation pass.+        #expect(+            try await repository.workURLsByHostname()+                == [siteHostname: "https://\(otherHostname)/w"])+        let diagnoses = await repository.diagnostics.diagnoses+        #expect(diagnoses.contains { if case .wrongHostWorkURL = $0 { true } else { false } })+        #expect(await repository.diagnostics.quarantineMap().isEmpty)+    }+}++// MARK: - Fixtures++private let siteHostname = "source.example"+private let otherHostname = "elsewhere.example"+private let epoch = Date(timeIntervalSince1970: 1_800_000_000)++private func record(+    id: UUID = UUID(), work: UUID?, hostname: String, workURL: String?+) -> BackupV7Membership {+    BackupV7Membership(+        id: id, workID: work, hostname: hostname, createdAt: epoch,+        urlIdentity: nil, urlIdentityState: .none, urlIdentityRuleID: nil,+        workURLString: workURL)+}++private func url(of records: [BackupV7Membership], _ id: UUID) -> String? {+    records.first { $0.id == id }?.workURLString ?? nil+}++/// The archive both gate cells and both import cells are built from: two Sites,+/// one Work, one Entry on `siteHostname`, and whatever memberships the caller+/// wants. The Work id and the second membership id are handed to the closure so+/// a test can address them.+private func makePlan(+    activePattern: Bool = true,+    memberships: (_ workID: UUID, _ otherMembershipID: UUID) -> [BackupV7Membership]+) -> BackupImportPlan {+    let workID = UUID()+    let otherMembershipID = UUID()+    let patternID = UUID()+    let rawURL = "https://\(siteHostname)/chapter/1"++    let entry = BackupV7Entry(+        id: UUID(), captureTitle: "Chapter 1", captureTitleSource: .host,+        rawURL: rawURL, canonicalURL: nil, hostname: siteHostname,+        entryIdentityKey: rawURL, conservativeIdentityKey: rawURL,+        identityBasis: .conservative, urlWorkIdentity: nil, chapterSequence: nil,+        chapterTitle: nil, note: "", rating: nil, firstCapturedAt: epoch,+        lastSharedAt: epoch, modifiedAt: epoch, workID: workID,+        intentionallyUnattached: false,+        citations: EntryCitations(+            workAssignment: .pattern(CitedRule(id: patternID, version: 1))))+    let work = BackupV7Work(+        id: workID, displayTitle: "Imported Work", lastParsedTitle: "Imported Work",+        genericNotes: "", genreTags: [], titleProvenance: .parsed, workTypeID: nil,+        typeName: nil, createdAt: epoch, modifiedAt: epoch)+    // The rule is always in the archive so the Entry's citation resolves; what+    // `activePattern` varies is whether it is *active*, which is what makes the+    // taught Site's tuple legal or not.+    let patterns = [+        BackupV7TitlePattern(+            id: patternID, siteHostname: siteHostname, version: 1, isActive: activePattern,+            createdAt: epoch,+            definition: StoredPatternDefinition(definition: .wholeTitle))+    ]+    let sites = [siteHostname, otherHostname].map {+        BackupV7Site(hostname: $0, displayName: $0, mode: $0 == siteHostname ? .taught : .untaught,+                     junkSuffixRule: nil)+    }+    let payload = BackupImportPayload(+        entries: [entry], works: [work], sites: sites, titlePatterns: patterns,+        urlRules: [], memberships: memberships(workID, otherMembershipID))+    let metadata = BackupImportMetadata(+        formatVersion: 7, schemaVersion: 8, appBuild: "test-1.0", exportedAt: epoch,+        capabilityGate: "multi-site", entryCount: 1, workCount: 1)+    return BackupImportPlan(+        metadata: metadata, payload: payload,+        counts: LibraryRecordCounts(+            entries: 1, works: 1, sites: sites.count, titlePatterns: patterns.count,+            urlRulePatterns: 0))+}++/// The gate's refusal, or nil where it accepted the payload.+private func refusal(of payload: BackupImportPayload) -> LibraryValidationError? {+    do {+        _ = try LibraryRepository.validateImportPlanPayload(payload)+        return nil+    } catch let error as LibraryValidationError {+        return error+    } catch {+        return nil+    }+}++private struct ImportEnvironment {+    let directory: URL+    let configuration: LibraryConfiguration++    init() throws {+        directory = FileManager.default.temporaryDirectory.appending(+            path: "WrongHostImportTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+    }+}++extension LibraryRepository {+    /// The confirmed Work URL each membership hostname holds, read inside the+    /// actor so no model crosses out.+    fileprivate func workURLsByHostname() async throws -> [String: String?] {+        try await withLockedContext(mode: .shared, operation: "reading memberships") { context in+            var values: [String: String?] = [:]+            for row in try context.fetch(FetchDescriptor<WorkSiteMembership>()) {+                values.updateValue(row.workURLString, forKey: row.hostname)+            }+            return values+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLNotGatedTests.swift Modified +115 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLNotGatedTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLNotGatedTests.swiftnew file mode 100644index 0000000..bc634e0--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLNotGatedTests.swift@@ -0,0 +1,115 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// `wrong-host-work-url-heal` [2.1](../../../../specs/wrong-host-work-url-heal/requirements.md#21),+/// the consequence Decision 1 exists for.+///+/// On the real library one Work's stale link quarantined `webtoons` for weeks:+/// every capture on the hostname was left as captured, Merge refused by name,+/// and Settings reported unresolved damage — over a value the app can repair+/// itself. Nothing here needs a code change; these tests pin the consequence, so+/// a later reader who restores the throw finds out what it costs.+@Suite("A wrong-host Work URL gates nothing", .serialized)+struct WrongHostWorkURLNotGatedTests {++    /// A taught site whose only finding is an off-host Work URL on one Work's+    /// membership.+    private func seedTwoWorks() throws -> (UnreadableRuleFixture, source: UUID, target: UUID) {+        let fixture = try UnreadableRuleFixture()+        let ruleID = try fixture.seedSiteHoldingFourRules(corruptCurrent: false)+        let works = try fixture.attachTwoWorksWithIdentities(ruleID: ruleID, ruleVersion: 4)+        let context = fixture.freshContext()+        let source = try #require(+            try context.fetch(FetchDescriptor<Work>()).first { $0.id == works.source })+        let membership = try #require(source.membership(for: UnreadableRuleFixture.hostname))+        membership.workURLString = "https://m.tth.example/fiction/source-story"+        // The fixture attaches its Entries without saying how; record a manual+        // assignment so the off-host value is the library's only finding.+        for entry in try context.fetch(FetchDescriptor<Entry>()) where entry.work != nil {+            entry.editCitations { $0.workAssignment = .manual }+        }+        try context.save()+        return (fixture, works.source, works.target)+    }++    /// The premise the rest of the suite rests on: the finding is reported, and+    /// the hostname is not quarantined by it.+    @Test("The finding is reported and the hostname is clean")+    func theFindingIsReportedWithoutAQuarantine() async throws {+        let (fixture, _, _) = try seedTwoWorks()+        let repository = try fixture.diagnosedRepository()++        #expect(await repository.diagnostics.wrongHostDiagnoses.count == 1)+        // What `SettingsSyncModel.isHealthy` and `WorkMergeModel`'s candidate+        // list both read.+        let quarantined = await repository.diagnostics.quarantineMap()+        #expect(quarantined.isEmpty, "\(quarantined)")+        #expect(await repository.quarantineReason(hostname: UnreadableRuleFixture.hostname) == nil)+    }++    /// Req 2.4 through the repository: a foreground refresh republishes the scan+    /// over the last full validation's tuple set, so a state only one derivation+    /// produced would vanish here — and with it the heal's gate.+    @Test("A refresh keeps the diagnosis and the heal's gate")+    func aRefreshKeepsTheDiagnosisAndTheGate() async throws {+        let (fixture, _, _) = try seedTwoWorks()+        let repository = try fixture.diagnosedRepository()+        let opened = await repository.diagnostics.wrongHostDiagnoses++        try await repository.refreshDiagnostics()++        #expect(await repository.diagnostics.wrongHostDiagnoses == opened)+        #expect(await repository.healsWrongHostWorkURL)+        #expect(await repository.diagnostics.quarantineMap().isEmpty)+    }++    /// The harm Decision 1 removes: a quarantined hostname takes rule+    /// application off **every** capture on it, so one Work's link cost the site+    /// its identities.+    @Test("Capture on the hostname still applies the site's rules")+    func captureAppliesTheSitesRules() async throws {+        let (fixture, _, _) = try seedTwoWorks()+        let repository = try fixture.diagnosedRepository()++        let rawURL = "https://\(UnreadableRuleFixture.hostname)/fiction/another-story"+        let contract = try await repository.projectCapture(+            hostname: UnreadableRuleFixture.hostname, captureTitle: "Another Story",+            captureTitleSource: .host, rawURLString: rawURL, canonicalURLString: nil,+            note: "", rating: nil)+        let outcome = try await repository.commitCapture(contract)+        guard case .committed(let snapshot) = outcome else {+            Issue.record("expected a committed capture, got \(outcome)")+            return+        }++        let entry = try #require(+            try fixture.freshContext().fetch(FetchDescriptor<Entry>())+                .first { $0.id == snapshot.id })+        // The site's URL rule ran: the Work identity was extracted and the rule+        // is cited. On a quarantined hostname both are absent and the capture is+        // left as captured.+        #expect(entry.urlWorkIdentity == "another-story")+        #expect(entry.citationValues.urlWork != nil)+    }++    /// Merge used to refuse the whole hostname by name (Req 4.5's gate), which+    /// is the second thing one stale link took away.+    @Test("Merge lists the Work and is not refused")+    func mergeIsAvailable() async throws {+        let (fixture, source, target) = try seedTwoWorks()+        let repository = try fixture.diagnosedRepository()++        let destinations = try await repository.mergeDestinations(for: source)+        #expect(destinations.map(\.id).contains(target))++        let contract = try await repository.projectMerge(+            sourceWorkID: source, targetWorkID: target)+        let outcome = try await repository.commitMerge(contract)+        if case .invalidated(let reason) = outcome {+            Issue.record("Merge was refused: \(reason)")+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLResolverTests.swift Modified +409 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLResolverTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLResolverTests.swiftnew file mode 100644index 0000000..d262257--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLResolverTests.swift@@ -0,0 +1,409 @@+import Foundation+import Testing++@testable import AsterismCore++/// `wrong-host-work-url-heal` Req 3.1–3.4, 3.8: the pure half of the heal.+///+/// The resolver decides, from one reading of a Work's memberships, where every+/// off-host Work URL goes. It touches no store, so the precedence Q23 states+/// once — an on-host value is never replaced, a value lands only where the+/// field will be empty after the pass, a blocked value blocks the value behind+/// it — is checkable directly, in the cells [3.4](requirements.md#34) names and+/// over generated Works besides (Q42).+@Suite("Wrong-host Work URL resolution")+struct WrongHostWorkURLResolverTests {++    private typealias Resolver = WrongHostWorkURLResolver+    private typealias Move = WrongHostWorkURLResolver.Move++    private static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    /// One membership of the snapshot. `order` places the row inside its+    /// hostname under the survivor comparator — the only thing the resolver+    /// reads it for.+    private static func member(+        _ hostname: String, _ value: String?, order: Int = 0+    ) -> Resolver.Membership {+        Resolver.Membership(+            hostname: hostname, value: value,+            orderKey: WorkSiteMembership.OrderKey(+                .none, epoch.addingTimeInterval(TimeInterval(order)), uuid(order)))+    }++    private static func uuid(_ seed: Int) -> UUID {+        UUID(uuidString: String(format: "00000000-0000-4000-8000-%012d", seed))!+    }++    private static func snapshot(_ memberships: Resolver.Membership...) -> Resolver.WorkSnapshot {+        Resolver.WorkSnapshot(memberships)+    }++    // MARK: - The cells Req 3.4 names++    /// [3.1](requirements.md#31): the destination holds nothing, so the address+    /// moves onto it.+    @Test("An off-host value moves to a membership that holds none")+    func movesToAnEmptyDestination() {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("a.example", "https://b.example/work"),+                Self.member("b.example", nil)))++        #expect(moves == [.land(value: "https://b.example/work", from: "a.example", to: "b.example")])+    }++    /// [3.2](requirements.md#32), Q6: the destination already has a confirmed+    /// address on its own host, so the stale one goes rather than replacing it.+    @Test("An off-host value is removed where the destination holds an on-host address")+    func removedWhereTheDestinationIsAlreadyAddressed() {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("a.example", "https://b.example/stale"),+                Self.member("b.example", "https://b.example/confirmed")))++        #expect(moves == [.remove(value: "https://b.example/stale", from: "a.example")])+    }++    /// [3.3](requirements.md#33): the Work has no membership for the host at+    /// all. The resolver still lands the value — minting the row is the apply+    /// step's job, and it needs to be told to.+    @Test("An off-host value lands on a host the Work holds no membership for")+    func landsOnAHostWithNoMembership() {+        let moves = Resolver.resolve(+            Self.snapshot(Self.member("a.example", "https://b.example/work")))++        #expect(moves == [.land(value: "https://b.example/work", from: "a.example", to: "b.example")])+    }++    /// Q19: two values naming each other's hosts swap in one pass.+    @Test("Two values naming each other's hosts swap")+    func twoCycleSwaps() {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("a.example", "https://b.example/one"),+                Self.member("b.example", "https://a.example/two")))++        #expect(moves == [+            .land(value: "https://b.example/one", from: "a.example", to: "b.example"),+            .land(value: "https://a.example/two", from: "b.example", to: "a.example"),+        ])+    }++    /// A cycle of any length rotates: each value ends on the host it names.+    @Test("A three-hostname cycle rotates")+    func threeCycleRotates() {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("a.example", "https://b.example/one"),+                Self.member("b.example", "https://c.example/two"),+                Self.member("c.example", "https://a.example/three")))++        #expect(moves == [+            .land(value: "https://b.example/one", from: "a.example", to: "b.example"),+            .land(value: "https://c.example/two", from: "b.example", to: "c.example"),+            .land(value: "https://a.example/three", from: "c.example", to: "a.example"),+        ])+    }++    /// Q24: two off-host values naming one host. The first in the candidate+    /// order — source hostname ascending — lands; the second is removed, so the+    /// Work is not left reported forever for a value it can never place.+    @Test("Two values naming one host leave the first in order on it")+    func twoIntoOneHasOneWinner() {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("b.example", "https://z.example/second"),+                Self.member("a.example", "https://z.example/first")))++        #expect(moves == [+            .land(value: "https://z.example/first", from: "a.example", to: "z.example"),+            .remove(value: "https://z.example/second", from: "b.example"),+        ])+    }++    /// Q34: a chain running into a value Q14 leaves alone. The malformed value+    /// blocks the move onto its hostname, and that block propagates back along+    /// the chain — otherwise `a`'s value and `b`'s would both land on `b`.+    @Test("A chain into a value that is not an absolute HTTP(S) URL stays whole")+    func chainIntoAMalformedValueStays() {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("a.example", "https://b.example/one"),+                Self.member("b.example", "https://c.example/two"),+                Self.member("c.example", "not a url at all")))++        #expect(moves == [.stay(hostname: "a.example"), .stay(hostname: "b.example")])+    }++    /// Q49's closure-then-pass shape, seen from the outside: the value at the+    /// end of the chain is removed because its destination is already addressed,+    /// which empties its own membership and lets the value behind it land.+    @Test("A chain into an on-host address removes the last link and lands the first")+    func chainIntoAnOnHostValueRemovesThenLands() {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("a.example", "https://b.example/one"),+                Self.member("b.example", "https://c.example/two"),+                Self.member("c.example", "https://c.example/confirmed")))++        #expect(moves == [+            .land(value: "https://b.example/one", from: "a.example", to: "b.example"),+            .remove(value: "https://c.example/two", from: "b.example"),+        ])+    }++    /// Q35's order, on the resolver rather than on the diagnosis: two torn rows+    /// of one Work hold different off-host values under one hostname. Both are+    /// placed, and the survivor-order row is the candidate that goes first.+    @Test("Torn rows disagreeing on one hostname are both placed, survivor order first")+    func tornRowsOnOneHostnameAreBothPlaced() {+        let moves = Resolver.resolve(+            Self.snapshot(+                // Inserted later-first, so input order is not what decides.+                Self.member("a.example", "https://z.example/second", order: 5),+                Self.member("a.example", "https://z.example/first", order: 1)))++        #expect(moves == [+            .land(value: "https://z.example/first", from: "a.example", to: "z.example"),+            .remove(value: "https://z.example/second", from: "a.example"),+        ])+    }++    /// A Work with nothing off-host resolves to nothing at all — the case every+    /// pass over a healthy library takes.+    @Test("A Work whose values are all on-host or empty produces no moves")+    func nothingToDo() {+        #expect(+            Resolver.resolve(+                Self.snapshot(+                    Self.member("a.example", "https://a.example/work"),+                    Self.member("b.example", nil))+            ).isEmpty)+    }++    /// Q14: the malformed value itself is never a candidate. It is not moved,+    /// not removed, and not reported as a `stay` — it is not this feature's.+    @Test("A value that is not an absolute HTTP(S) URL is left out of the moves")+    func aMalformedValueIsNotACandidate() {+        #expect(+            Resolver.resolve(Self.snapshot(Self.member("a.example", "mailto:someone@example")))+                .isEmpty)+    }++    // MARK: - Q63: one emptiness rule++    /// A blank value is **held**, not empty: the column is non-nil, so the+    /// validator reports it and Check Library shows it. Landing on it would+    /// overwrite something the reader can see, so it blocks the move the way any+    /// other value this feature does not touch does.+    @Test("A blank value at the destination blocks the move", arguments: ["", " "])+    func aBlankValueBlocksTheMove(blank: String) {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("a.example", "https://b.example/work"),+                Self.member("b.example", blank)))++        #expect(moves == [.stay(hostname: "a.example")])+    }++    /// And it is not itself a candidate: it is not an absolute HTTP(S) URL, so+    /// it is neither moved nor removed.+    @Test("A blank value is never itself moved", arguments: ["", " "])+    func aBlankValueIsNotACandidate(blank: String) {+        #expect(Resolver.resolve(Self.snapshot(Self.member("a.example", blank))).isEmpty)+    }++    // MARK: - Q65: a stored hostname is not normalised++    /// A stored hostname keeps whatever case it was captured with, while a host+    /// derived from a URL is always lowercase. Comparing the two byte for byte+    /// made the `B.Example` row invisible: its own confirmed address did not+    /// count as on-host, so the stale value landed *beside* it on a second+    /// membership in a different case, which the dedupe never folds.+    @Test("A destination stored in another case still holds its own address")+    func mixedCaseDestinationKeepsItsAddress() {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("a.example", "https://b.example/stale"),+                Self.member("B.Example", "https://b.example/confirmed")))++        #expect(moves == [.remove(value: "https://b.example/stale", from: "a.example")])+    }++    /// The same on the source side: a move names its source lowercased, so the+    /// apply step has one vocabulary to clear by.+    @Test("A source stored in another case is named lowercased by its move")+    func mixedCaseSourceIsNamedLowercased() {+        let moves = Resolver.resolve(+            Self.snapshot(+                Self.member("A.Example", "https://b.example/work"),+                Self.member("b.example", nil)))++        #expect(moves == [.land(value: "https://b.example/work", from: "a.example", to: "b.example")])+    }++    // MARK: - Generated inputs (Q42)++    /// The four invariants, over Works the cells above do not enumerate.+    ///+    /// A seeded generator rather than a PBT dependency: the seed is the case+    /// argument, so a failure names the shape that produced it and re-runs+    /// identically.+    @Test("Generated Works satisfy the resolver's invariants", arguments: 0..<400)+    func generatedWorksHoldTheInvariants(seed: Int) {+        var generator = SeededGenerator(seed: UInt64(seed) &+ 1)+        let snapshot = Self.generate(&generator)+        let moves = Resolver.resolve(snapshot)++        // Hostnames are keyed lowercased (Q65), and so is this reading of the+        // snapshot: the generator stores some rows in another case.+        let onHost = Set(+            snapshot.memberships+                .filter { row in+                    row.value.map { WorkURLPlanner.isOnHost($0, hostname: row.hostname) } ?? false+                }+                .map { $0.hostname.lowercased() })++        // 1 and 2, in **one pass over the moves in order**: a land goes somewhere+        // the pass leaves empty — no host that already holds its own address,+        // none claimed twice — and a value is dropped only where its host holds+        // an on-host value or was claimed by an *earlier* land. Accumulating the+        // claims as the moves are read is the whole of invariant 2: checking a+        // removal against the finished claim set would accept one justified by a+        // land that has not happened yet, which justifies nothing.+        var claimed: Set<String> = []+        for move in moves {+            switch move {+            case .land(_, _, let destination):+                #expect(+                    !onHost.contains(destination),+                    "landed on a host that already has its own address")+                #expect(claimed.insert(destination).inserted, "two values landed on one host")+            case .remove(let value, _):+                let destination = WorkURLPlanner.host(of: value)+                #expect(destination != nil)+                if let destination {+                    #expect(+                        onHost.contains(destination) || claimed.contains(destination),+                        "a value was dropped with nothing standing on its host")+                }+            case .stay:+                continue+            }+        }++        // 3. Idempotence (Req 3.8): applying the moves leaves a Work the+        //    resolver has nothing left to write to.+        let settled = Self.apply(moves, to: snapshot)+        let second = Resolver.resolve(settled)+        #expect(+            second.allSatisfy { if case .stay = $0 { true } else { false } },+            "a second pass would write again")++        // 4. The answer is a property of the Work, not of the order its rows+        //    were read in — over the reversal and a handful of seeded+        //    permutations, since a reversal only ever exercises one of them.+        #expect(Resolver.resolve(Resolver.WorkSnapshot(snapshot.memberships.reversed())) == moves)+        for _ in 0..<4 {+            let permuted = snapshot.memberships.shuffled(using: &generator)+            #expect(Resolver.resolve(Resolver.WorkSnapshot(permuted)) == moves)+        }+    }++    // MARK: - Generation and application++    /// 2–6 hostnames, each with 1–2 rows (the torn case), each row empty,+    /// on-host, movable, blank or malformed. A movable value can name a host the+    /// Work holds no membership for, which is [3.3](requirements.md#33)'s mint.+    ///+    /// A row's hostname is stored in its captured case or in upper case (Q65):+    /// nothing normalises a stored hostname, so a generated Work has to be able+    /// to hold one a derived host will never equal byte for byte. The blank+    /// value is Q63's: held, not empty, and therefore a blocker.+    private static func generate(_ generator: inout SeededGenerator) -> Resolver.WorkSnapshot {+        let names = (0..<Int.random(in: 2...6, using: &generator)).map { "h\($0).example" }+        let targets = names + ["unheld.example"]+        var memberships: [Resolver.Membership] = []+        var order = 0+        for hostname in names {+            for _ in 0..<Int.random(in: 1...2, using: &generator) {+                order += 1+                let value: String?+                switch Int.random(in: 0...4, using: &generator) {+                case 0:+                    value = nil+                case 1:+                    value = "https://\(hostname)/own"+                case 2:+                    let target = targets.randomElement(using: &generator) ?? hostname+                    value = "https://\(target)/moved-\(order)"+                case 3:+                    value = Bool.random(using: &generator) ? "" : " "+                default:+                    value = "not a url \(order)"+                }+                let stored = Bool.random(using: &generator) ? hostname : hostname.uppercased()+                memberships.append(member(stored, value, order: order))+            }+        }+        return Resolver.WorkSnapshot(memberships)+    }++    /// The store apply's rules, on values: every land writes its destination+    /// before any source is cleared ([3.11](requirements.md#311)), a clear+    /// matches the **exact value** on the source hostname (Q51), and both halves+    /// match the hostname case-insensitively (Q65) — the row is written where it+    /// is, in the case it was stored in.+    private static func apply(+        _ moves: [Move], to snapshot: Resolver.WorkSnapshot+    ) -> Resolver.WorkSnapshot {+        var rows = snapshot.memberships+        var nextOrder = (rows.map(\.orderKey.createdAt).max() ?? epoch)+            .timeIntervalSince(epoch) + 1++        for case .land(let value, _, let destination) in moves {+            if let index = rows.enumerated()+                .filter({ $0.element.hostname.lowercased() == destination })+                .min(by: { $0.element.orderKey < $1.element.orderKey })?.offset {+                rows[index] = Resolver.Membership(+                    hostname: rows[index].hostname, value: value, orderKey: rows[index].orderKey)+            } else {+                rows.append(member(destination, value, order: Int(nextOrder)))+                nextOrder += 1+            }+        }+        for move in moves {+            let cleared: (value: String, hostname: String)+            switch move {+            case .land(let value, let source, _): cleared = (value, source)+            case .remove(let value, let source): cleared = (value, source)+            case .stay: continue+            }+            rows = rows.map {+                $0.hostname.lowercased() == cleared.hostname && $0.value == cleared.value+                    ? Resolver.Membership(+                        hostname: $0.hostname, value: nil, orderKey: $0.orderKey)+                    : $0+            }+        }+        return Resolver.WorkSnapshot(rows)+    }+}++/// A deterministic generator, so a generated case re-runs identically from its+/// seed. `SystemRandomNumberGenerator` would make a failure unreproducible,+/// which is the one thing a generated suite cannot afford.+private struct SeededGenerator: RandomNumberGenerator {+    private var state: UInt64++    init(seed: UInt64) { state = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 }++    mutating func next() -> UInt64 {+        // xorshift64*, which is short enough to read and long enough for this.+        state ^= state >> 12+        state ^= state << 25+        state ^= state >> 27+        return state &* 2_685_821_657_736_338_717+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift Modified +507 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swiftnew file mode 100644index 0000000..3351ab3--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift@@ -0,0 +1,507 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// `wrong-host-work-url-heal` Reqs 2.1–2.4. A membership Work URL on another host+// stops being the hostname's tuple failure and becomes a Work-keyed tolerated+// diagnosis, produced identically by the full validation and by the foreground+// pass — because the heal's gate reads the published diagnoses, and a state only+// one derivation produced would vanish on the first refresh.+//+// The two shapes that stay loud are pinned here too: a value that is not an+// absolute HTTP(S) URL (Q14/Q50) and a membership naming no Work (Q31).++@Suite("Wrong-host Work URL: the full validation", .serialized)+struct WrongHostWorkURLValidatorTests {++    /// Req 2.1, 2.3: one diagnosis, keyed by the Work, naming both hosts — and+    /// no quarantine for the hostname it sits on.+    @Test("An off-host membership Work URL is tolerated and names both hosts")+    func offHostValueIsTolerated() throws {+        let store = try WrongHostStore()+        store.addSite("a.example")+        let workID = WrongHostStore.rankedID(1)+        store.addWork(+            id: workID, title: "A Serial", hostname: "a.example",+            workURL: "https://b.example/serial")+        try store.save()++        let diagnostics = try store.diagnostics()++        #expect(+            diagnostics.wrongHostDiagnoses == [+                .wrongHostWorkURL(+                    workID: workID.uuidString, workTitle: "A Serial",+                    memberships: [+                        WrongHostMembership(hostname: "a.example", actualHost: "b.example")+                    ])+            ])+        // Decision 1: one Work's wrong link takes nothing away from the site.+        #expect(diagnostics.quarantineMap().isEmpty)+        #expect(diagnostics.affectedRecordCount == 1)+    }++    /// Req 2.3: the row list is per **source hostname**, in hostname order, and+    /// one Work holding values on two sites is still one diagnosis.+    @Test("Two source hostnames on one Work are one diagnosis, hostname-ordered")+    func twoSourceHostnamesAreOneDiagnosis() throws {+        let store = try WrongHostStore()+        store.addSite("b.example")+        store.addSite("a.example")+        let workID = WrongHostStore.rankedID(1)+        let work = store.addWork(+            id: workID, title: "A Serial", hostname: "b.example",+            workURL: "https://elsewhere.example/serial")+        store.addMembership(+            work: work, hostname: "a.example", workURL: "https://other.example/serial")+        try store.save()++        #expect(+            try store.diagnostics().wrongHostDiagnoses == [+                .wrongHostWorkURL(+                    workID: workID.uuidString, workTitle: "A Serial",+                    memberships: [+                        WrongHostMembership(hostname: "a.example", actualHost: "other.example"),+                        WrongHostMembership(hostname: "b.example", actualHost: "elsewhere.example"),+                    ])+            ])+    }++    /// Q9: two Works with off-host addresses on one site are two rows, not one.+    @Test("Two Works on one site are two diagnoses")+    func twoWorksOnOneSiteAreTwoDiagnoses() throws {+        let store = try WrongHostStore()+        store.addSite("a.example")+        let first = WrongHostStore.rankedID(1)+        let second = WrongHostStore.rankedID(2)+        store.addWork(+            id: first, title: "First", hostname: "a.example", workURL: "https://b.example/first")+        store.addWork(+            id: second, title: "Second", hostname: "a.example", workURL: "https://c.example/second")+        try store.save()++        let diagnoses = try store.diagnostics().wrongHostDiagnoses+        #expect(diagnoses.count == 2)+        #expect(diagnoses.map(\.id) == ["wrongHostWorkURL:\(first)", "wrongHostWorkURL:\(second)"])+    }++    /// Q35, Q53: a torn group names one title and one host per source hostname —+    /// the title from the `GroupOrdering.sortedWorkRows` representative, the host+    /// from the survivor-order membership. `WrongHostParityCase.tornGroup` seeds+    /// the two so they land on **different** rows, and so neither is the row+    /// seeded first: a fixture where insertion order, the membership survivor+    /// order and the Work representative order all agree pins neither rule.+    @Test("A torn group names the representative row's title and the survivor value's host")+    func tornGroupNamesOneTitleAndOneHost() throws {+        let store = try WrongHostStore()+        try WrongHostParityCase.tornGroup.seed(into: store)++        #expect(+            try store.diagnostics().wrongHostDiagnoses == [+                WrongHostParityCase.tornGroupDiagnosis+            ])+    }++    /// Q48: both producers key on the membership's `workID` column, so a Work+    /// that has not arrived is the same diagnosis with no title.+    @Test("A membership whose Work has not arrived is diagnosed with no title")+    func unarrivedWorkIsDiagnosedWithoutATitle() throws {+        let store = try WrongHostStore()+        store.addSite("a.example")+        let workID = WrongHostStore.rankedID(7)+        store.addMembership(+            workID: workID, work: nil, hostname: "a.example",+            workURL: "https://b.example/serial")+        try store.save()++        #expect(+            try store.diagnostics().wrongHostDiagnoses == [+                .wrongHostWorkURL(+                    workID: workID.uuidString, workTitle: nil,+                    memberships: [+                        WrongHostMembership(hostname: "a.example", actualHost: "b.example")+                    ])+            ])+    }++    /// Q47: the narrowed re-validation returns a **tuple** map, and this is not+    /// a tuple finding. Recording it there would re-quarantine the hostname the+    /// heal had just written to.+    @Test("The narrowed validation records nothing for it")+    func narrowedValidationRecordsNothing() throws {+        let store = try WrongHostStore()+        store.addSite("a.example")+        store.addWork(+            id: WrongHostStore.rankedID(1), title: "A Serial", hostname: "a.example",+            workURL: "https://b.example/serial")+        try store.save()++        #expect(try store.narrowed(["a.example"]).isEmpty)+    }++    /// Q14, Q50: a value that is not an absolute HTTP(S) URL has no host to move+    /// it to and no writer that produced it, so it stays the hostname's tuple+    /// failure in every arm.+    @Test("A malformed value stays a tuple diagnosis in all three arms")+    func malformedValueStaysATupleDiagnosis() throws {+        let store = try WrongHostStore()+        store.addSite("a.example")+        store.addWork(+            id: WrongHostStore.rankedID(1), title: "A Serial", hostname: "a.example",+            workURL: "/serial")+        try store.save()++        let diagnostics = try store.diagnostics()+        #expect(diagnostics.wrongHostDiagnoses.isEmpty)+        let reason = try #require(diagnostics.quarantineMap()["a.example"])+        #expect(reason.isWrongHostWorkURL)+        #expect(try store.narrowed(["a.example"])["a.example"]?.isWrongHostWorkURL == true)+        #expect(try store.strict()["a.example"]?.isWrongHostWorkURL == true)+    }++    /// Q31: the diagnosis is keyed by Work and the heal moves values on a Work,+    /// so a membership with neither has neither.+    @Test("A membership naming no Work stays a tuple diagnosis")+    func nilWorkMembershipStaysATupleDiagnosis() throws {+        let store = try WrongHostStore()+        store.addSite("a.example")+        store.addMembership(+            workID: nil, work: nil, hostname: "a.example", workURL: "https://b.example/serial")+        try store.save()++        let diagnostics = try store.diagnostics()+        #expect(diagnostics.wrongHostDiagnoses.isEmpty)+        #expect(diagnostics.quarantineMap()["a.example"]?.isWrongHostWorkURL == true)+        #expect(try store.strict()["a.example"]?.isWrongHostWorkURL == true)+    }++    /// Q58: both producers key on the **column**, so a lagging writer's row that+    /// set only the relationship is outside the tolerance and stays the+    /// hostname's tuple failure. The initialiser writes `workID ?? work?.id`, so+    /// this shape is not one the app produces — it is pinned so the quarantine+    /// path is explicit rather than implied.+    @Test("A nil workID column with a Work relationship stays a tuple diagnosis")+    func nilWorkIDColumnWithARelationshipStaysATupleDiagnosis() throws {+        let store = try WrongHostStore()+        store.addSite("a.example")+        let work = store.addWork(+            id: WrongHostStore.rankedID(1), title: "A Serial", hostname: "a.example",+            workURL: "https://b.example/serial")+        work.membershipValues.first?.workID = nil+        try store.save()++        let diagnostics = try store.diagnostics()+        #expect(diagnostics.wrongHostDiagnoses.isEmpty)+        #expect(diagnostics.quarantineMap()["a.example"]?.isWrongHostWorkURL == true)+        // The foreground pass agrees, which is what keeps Req 2.4 true.+        #expect(try store.scan().diagnoses.wrongHostOnly.isEmpty)+    }++    /// Req 2.2, Q21: `record()` keeps the first error per hostname in arm order,+    /// so a membership finding stood in front of the Work and Entry arms. With it+    /// no longer recorded, the finding behind it becomes the hostname's tuple+    /// diagnosis — real damage that was hidden.+    @Test("An Entry finding the membership finding masked becomes the tuple diagnosis")+    func maskedEntryFindingIsUnmasked() throws {+        let store = try WrongHostStore()+        let site = store.addSite("a.example")+        let work = store.addWork(+            id: WrongHostStore.rankedID(1), title: "A Serial", hostname: "a.example",+            site: site, workURL: "https://b.example/serial")+        // An Entry holding a Work relationship while its assignment provenance+        // says it holds none — an illegal Entry tuple.+        let entry = store.addEntry(hostname: "a.example", site: site)+        entry.work = work+        try store.save()++        let diagnostics = try store.diagnostics()+        // The tolerated diagnosis is still reported…+        #expect(diagnostics.wrongHostDiagnoses.count == 1)+        // …and the Entry finding it used to hide is now the hostname's.+        let reason = try #require(diagnostics.quarantineMap()["a.example"])+        #expect(!reason.isWrongHostWorkURL)+    }+}++@Suite("Wrong-host Work URL: the foreground pass", .serialized)+struct WrongHostWorkURLScanTests {++    /// Req 2.4: the two derivations must agree, or the diagnosis (and the heal's+    /// gate that reads it) disappears on the first foreground refresh.+    @Test(+        "The scan and the full validation produce the same diagnosis",+        arguments: WrongHostParityCase.all)+    func scanMatchesTheFullValidation(_ shape: WrongHostParityCase) throws {+        let store = try WrongHostStore()+        try shape.seed(into: store)++        let validated = try store.diagnostics().wrongHostDiagnoses+        let scanned = try store.scan()++        #expect(!validated.isEmpty)+        #expect(scanned.diagnoses.wrongHostOnly == validated)+        #expect(scanned.wrongHostWorkURLCount == validated.count)+    }++    /// Q35, Q53 from the other side. The parity test only says the two+    /// derivations agree; this says *what* they agree on, so a scan that took+    /// the title from the first row it met (or the host from the last value it+    /// saw) would fail here rather than drag the validator along with it.+    @Test("The scan names the representative row's title and the survivor value's host")+    func scanNamesTheRepresentativeTitleAndSurvivorHost() throws {+        let store = try WrongHostStore()+        try WrongHostParityCase.tornGroup.seed(into: store)++        #expect(try store.scan().diagnoses.wrongHostOnly == [+            WrongHostParityCase.tornGroupDiagnosis+        ])+    }++    /// Req 2.4 again, through the union the app actually publishes: the scan's+    /// tolerated set *replaces* the previous one on every refresh, so the case+    /// survives only because the scan reproduces it.+    @Test("A refresh keeps the diagnosis")+    func aRefreshKeepsTheDiagnosis() throws {+        let store = try WrongHostStore()+        try WrongHostParityCase.oneWork.seed(into: store)++        let opened = try store.diagnostics()+        let scan = try store.scan()+        let refreshed = LibraryDiagnostics.union(+            tupleDiagnoses: opened.tupleDiagnoses,+            toleratedStates: scan.diagnoses, shape: scan.shape)++        #expect(refreshed.wrongHostDiagnoses == opened.wrongHostDiagnoses)+        #expect(refreshed.quarantineMap().isEmpty)+    }++    /// A library with nothing to report says so, and pays nothing for saying it.+    @Test("A library holding only on-host values reports none")+    func onHostValuesReportNothing() throws {+        let store = try WrongHostStore()+        store.addSite("a.example")+        store.addWork(+            id: WrongHostStore.rankedID(1), title: "A Serial", hostname: "a.example",+            workURL: "https://a.example/serial")+        try store.save()++        let scan = try store.scan()+        #expect(scan.diagnoses.wrongHostOnly.isEmpty)+        #expect(scan.wrongHostWorkURLCount == 0)+        #expect(try store.diagnostics().wrongHostDiagnoses.isEmpty)+    }+}++// MARK: - Shapes both derivations are asked about++/// The store shapes Req 2.4's "same diagnosis" is checked over. They are named+/// rather than inlined so the parity test and the counting test ask about the+/// same libraries.+struct WrongHostParityCase: Sendable, CustomTestStringConvertible {+    let name: String+    private let build: @Sendable (WrongHostStore) throws -> Void++    var testDescription: String { name }++    func seed(into store: WrongHostStore) throws {+        try build(store)+        try store.save()+    }++    static let oneWork = WrongHostParityCase(name: "one Work, one source hostname") { store in+        store.addSite("a.example")+        store.addWork(+            id: WrongHostStore.rankedID(1), title: "A Serial", hostname: "a.example",+            workURL: "https://b.example/serial")+    }++    /// A torn group whose two rows disagree about the title **and** about the+    /// address on their shared hostname, seeded so that no two of the three+    /// orders that could decide the outcome coincide:+    ///+    /// - insertion order puts "Beta" first;+    /// - the Work representative order (`GroupOrdering.sortedWorkRows`, on+    ///   `Work.createdAt`) puts "Alpha" first, so the **title** is Alpha's;+    /// - the membership survivor order (`OrderKey`, on the membership's own+    ///   `createdAt`) puts Beta's row first, so the **host** is Beta's.+    ///+    /// A fixture where the three agree passes whichever rule the code actually+    /// applies, which is what Q35 and Q53 exist to fix in place.+    static let tornGroup = WrongHostParityCase(+        name: "a torn group disagreeing on one hostname"+    ) { store in+        store.addSite("a.example")+        store.addWork(+            id: WrongHostStore.rankedID(1), title: "Beta", hostname: "a.example",+            createdAt: 100, membershipCreatedAt: 0, workURL: "https://y.example/beta")+        store.addWork(+            id: WrongHostStore.rankedID(1), title: "Alpha", hostname: "a.example",+            createdAt: 0, membershipCreatedAt: 100, workURL: "https://x.example/alpha")+    }++    /// The one diagnosis `tornGroup` must produce, in either derivation.+    static let tornGroupDiagnosis = LibraryDiagnosis.wrongHostWorkURL(+        workID: WrongHostStore.rankedID(1).uuidString, workTitle: "Alpha",+        memberships: [WrongHostMembership(hostname: "a.example", actualHost: "y.example")])++    static let all: [WrongHostParityCase] = [+        oneWork,+        WrongHostParityCase(name: "two Works on one site") { store in+            store.addSite("a.example")+            store.addWork(+                id: WrongHostStore.rankedID(1), title: "First", hostname: "a.example",+                workURL: "https://b.example/first")+            store.addWork(+                id: WrongHostStore.rankedID(2), title: "Second", hostname: "a.example",+                workURL: "https://c.example/second")+        },+        WrongHostParityCase(name: "one Work, two source hostnames") { store in+            store.addSite("a.example")+            store.addSite("b.example")+            let work = store.addWork(+                id: WrongHostStore.rankedID(1), title: "A Serial", hostname: "b.example",+                workURL: "https://elsewhere.example/serial")+            store.addMembership(+                work: work, hostname: "a.example", workURL: "https://other.example/serial")+        },+        tornGroup,+        WrongHostParityCase(name: "a Work that has not arrived") { store in+            store.addSite("a.example")+            store.addMembership(+                workID: WrongHostStore.rankedID(9), work: nil, hostname: "a.example",+                workURL: "https://b.example/serial")+        },+        WrongHostParityCase(name: "a hostname with no Site row") { store in+            store.addWork(+                id: WrongHostStore.rankedID(1), title: "A Serial", hostname: "untaught.example",+                workURL: "https://b.example/serial")+        },+    ]+}++extension LibraryDiagnostics {+    /// The wrong-host diagnoses alone, in the order the listing holds them.+    var wrongHostDiagnoses: [LibraryDiagnosis] { diagnoses.wrongHostOnly }+}++extension [LibraryDiagnosis] {+    var wrongHostOnly: [LibraryDiagnosis] {+        filter { if case .wrongHostWorkURL = $0 { true } else { false } }+    }+}++// MARK: - Fixture++/// An on-disk V9 store seeded row by row, because the shapes under test are ones+/// no writer produces — that is the whole point of the diagnosis.+final class WrongHostStore {+    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: "AsterismWrongHostWorkURL-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        let schema = Schema(versionedSchema: AsterismSchemaV9.self)+        let configuration = ModelConfiguration(+            "AsterismV3", schema: schema,+            url: directory.appending(path: "library.store"), cloudKitDatabase: .none)+        container = try ModelContainer(+            for: schema, migrationPlan: AsterismV9MigrationPlan.self,+            configurations: [configuration])+        context = ModelContext(container)+    }++    deinit { try? FileManager.default.removeItem(at: directory) }++    func save() throws { try context.save() }++    /// Every read goes through a fresh context, so nothing under test is+    /// answered out of the seeding context's caches.+    private func fresh() -> ModelContext { ModelContext(container) }++    func diagnostics() throws -> LibraryDiagnostics {+        try LibraryValidator.validate(context: fresh())+    }++    func scan() throws -> LibraryToleranceScan.Result {+        try LibraryToleranceScan.scan(context: fresh())+    }++    func narrowed(_ hostnames: [String]) throws -> [String: LibraryValidationError] {+        try LibraryValidator.validate(hostnames: hostnames, context: fresh())+    }++    func strict() throws -> [String: LibraryValidationError] {+        try LibraryValidator.validateStrict(context: fresh())+    }++    @discardableResult+    func addSite(_ hostname: String) -> Site {+        let site = Site(hostname: hostname, displayName: hostname)+        context.insert(site)+        return site+    }++    @discardableResult+    func addWork(+        id: UUID = UUID(),+        title: String,+        hostname: String,+        site: Site? = nil,+        createdAt: TimeInterval = 0,+        membershipCreatedAt: TimeInterval? = nil,+        workURL: String? = nil+    ) -> Work {+        let work = Work.create(+            in: context, id: id, title: title, hostname: hostname, site: site,+            timestamp: Self.epoch.addingTimeInterval(createdAt))+        work.lastParsedTitle = title+        work.titleProvenance = .parsed+        if let membership = work.membershipValues.first {+            membership.workURLString = workURL+            membership.createdAt = Self.epoch.addingTimeInterval(+                membershipCreatedAt ?? createdAt)+        }+        return work+    }++    @discardableResult+    func addMembership(+        workID: UUID? = nil,+        work: Work? = nil,+        hostname: String,+        workURL: String?,+        createdAt: TimeInterval = 60+    ) -> WorkSiteMembership {+        let membership = WorkSiteMembership(+            hostname: hostname, createdAt: Self.epoch.addingTimeInterval(createdAt),+            workURLString: workURL, workID: workID ?? work?.id, work: work,+            site: nil)+        context.insert(membership)+        return membership+    }++    @discardableResult+    func addEntry(hostname: String, site: Site?) -> Entry {+        let rawURL = "https://\(hostname)/read/\(UUID().uuidString)"+        let entry = Entry(+            captureTitle: "capture", captureTitleSource: .host, rawURLString: rawURL,+            hostname: hostname, entryIdentityKey: rawURL, timestamp: Self.epoch)+        entry.conservativeIdentityKey = rawURL+        entry.site = site+        context.insert(entry)+        return entry+    }++    /// A UUID whose string order follows `rank`, so which row a tie-break picks+    /// is stated rather than drawn.+    static func rankedID(_ rank: Int) -> UUID {+        UUID(uuidString: String(format: "00000000-0000-4000-8000-%012d", rank))!+    }+}
docs/agent-notes/testing.md Modified +7 / -0
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 707a543..c9068d4 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -309,6 +309,13 @@ confusing.   Leaving it `.none` while the relationship exists trips   `LibraryValidator.swift:964` with `none assignment has incompatible   relationship or provenance`, rejecting the whole library on the next write.+  The archive fixtures inherit the same rule one step removed: a+  `BackupV7Entry` written with a `workID` needs real citation bytes carrying+  that assignment (`.manual`, or a pattern/URL-rule arm with its cited+  pattern), because the import materialises the record and validates it. A+  `workID` beside default `.none` citations fails the *import*, which reads as+  a broken archive rather than a fixture mistake. Give the entry a `workID`+  only when you also give it the citation.  ## Two UI-test facts about tabs and Swift Charts 
specs/OVERVIEW.md Modified +16 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 691260f..86e40de 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -30,6 +30,7 @@ | [Multi-Site Works](#multi-site-works) | 2026-08-25 | Done — all tasks implemented and reviewed; `make test-core`, `make test-quick` and `make build` green. The `"7"`→`"8"` migration was confirmed on the real library 2026-08-27. Two open Before-Release items in `prerequisites.md`: the `test-performance-chunks` sweep has not been re-run, and `WorkDetailView`'s three-membership site line has never been eyeballed at the accessibility text sizes (TODO in `siteIdentityRow`). Req 8.6 is not fully met (Q89 amended — three full-tier labels plus the three `diagnosis-refresh` arms stay out of band, all known issues inside regression ceilings) | T-2230. A Work holds a set of site memberships (`WorkSiteMembership`: hostname, URL identity, rule UUID, Work URL) so the same story read on two sites is one Work: cross-site merge with a searchable picker, membership-aware duplicate scan with a dismissable "Not the same work" record, one interleaved chapter spine. Schema V8 is additive with a marker-gated population pass; the superseded columns survive unread until V9 (Decision 3). Rides the TitlePattern definition blob, the Entry citation blob and the `typeRaw` retirement; archive format 7/8 replaces 6/7 outright (Decision 2). Prerequisite `data-model-cleanups` merged. | | [Post-V8 Convergence](#post-v8-convergence) | 2026-08-27 | Done — research; no code | Research outcome of T-2272. Routes the four post-V8 convergence items into three sequenced feature tickets — T-2280 (schema V9: column drop, V5+V6 stage retirement, facade removal, closes the three full-tier performance known issues), T-2281 (cite rules by UUID only, no schema stage, archive 8/9; supersedes T-2055), T-2282 (read-time regime for same-UUID groups; supersedes duplicate-reconciliation Decision 4) — and answers the import gate diff in place: the SwiftData gate stays, six check families have no codec equivalent (Q1). | | [Drop Superseded Columns](#drop-superseded-columns) | 2026-08-27 | Done — the V9 device install and the pre-V9 archive export remain the owner's (`specs/drop-superseded-columns/prerequisites.md`) | Smolspec (T-2280), spec A of Post-V8 Convergence. Schema V9 drops the 38 names V8 retained unread — 36 columns plus the `Work.site`/`Site.works` relationship pair (Work's site/identity/URL columns and `typeRaw`, `Site.works`/`Site.urlIdentityRule`, `Entry.identityKeyVersion` and the citation/provenance columns, TitlePattern's definition columns), deletes `LegacyColumns`, `V8PopulationPass` and the migration chain below V8 (plan `[V8, V9]`, marker `"9"`), inverts the citation write path onto the blob, gates `MembershipReconciler.heal` on the diagnosis, and re-measures the two full-tier known issues. Archive format unchanged. |+| [Wrong-Host Work URL Heal](#wrong-host-work-url-heal) | 2026-08-29 | Done | Full spec (T-2294). A membership Work URL on another host — residue of builds before per-hostname Work URLs — becomes a Work-keyed tolerated diagnosis that quarantines nothing, produced by both the full validation and the foreground scan; a `MembershipReconciler` phase moves each value to the membership for its host (minting the membership, never a Site row), backup import applies the same precedence where the destination write is known to land, and the three membership folds carry a discarded row's URL. Supersedes multi-site-works Q97; amends library-integrity-tolerance Decisions 3 and 4. |  --- @@ -508,3 +509,18 @@ Smolspec (T-2280), spec A of the Post-V8 Convergence research. Schema V9 drops t - [prerequisites.md](drop-superseded-columns/prerequisites.md) - [verification-run.md](drop-superseded-columns/verification-run.md) - [implementation.md](drop-superseded-columns/implementation.md)++## Wrong-Host Work URL Heal++Full spec (T-2294). A Work's site membership can hold a confirmed Work URL whose host is not the membership's hostname; today that quarantines the whole site. The feature tolerates it, heals it by moving the address to the site it is on, and closes backup import as the last way in.++- **Tolerated, not quarantined** (Decision 1): a per-Work diagnosis produced by both derivations so it survives foreground refreshes; Check Library names the Work and the host and keeps the Open-work route.+- **Move, mint the membership, never the Site row** (Decision 2, Q40 of cloudkit-mirroring): a pure resolver computes every Work's moves from one snapshot (cycles rotate, collisions have one winner, a malformed destination blocks the chain) and applies them in one save.+- **Import normalises where it can decide** (Decision 3): the destination write must land under the `appliedWorkIDs` gate; otherwise the record imports unchanged for the heal.+- **Folds carry URLs** ([1.6](wrong-host-work-url-heal/requirements.md#16)): de-duplication, merge collapse and export keep a discarded row's Work URL.++- [requirements.md](wrong-host-work-url-heal/requirements.md)+- [design.md](wrong-host-work-url-heal/design.md)+- [tasks.md](wrong-host-work-url-heal/tasks.md)+- [decision_log.md](wrong-host-work-url-heal/decision_log.md)+- [explanation.md](wrong-host-work-url-heal/explanation.md)
specs/library-integrity-tolerance/decision_log.md Modified +34 / -0
diff --git a/specs/library-integrity-tolerance/decision_log.md b/specs/library-integrity-tolerance/decision_log.mdindex e62e63e..ebd703f 100644--- a/specs/library-integrity-tolerance/decision_log.md+++ b/specs/library-integrity-tolerance/decision_log.md@@ -180,6 +180,23 @@ Import is separately blocked: three commit paths reject any non-empty diagnosis  Both move to phase 2. Phase 1 changes neither the archive format nor the import path. +> **Amended 2026-08-30 (`wrong-host-work-url-heal` Decision 3).** The import+> gate this decision left strict — an imported library must be *wholly* legal,+> and the open paths' tolerance does not extend to import — has exactly one+> named exception. An off-host membership Work URL is **normalised** by the+> import rather than refused: the pre-pass in `commitMemberships` moves the+> value to the archive's own membership for the host it names when that+> destination is in the archive and will be written, and otherwise imports the+> record unchanged for the heal to place on the next pass. The plan gate+> (`validateImportPlanPayload`) no longer refuses an archive on this ground+> alone. The three states this spec's Decision 4 tolerates are **still refused+> at import**, as are the shapes outside the fourth state's tolerance: a Work+> URL that is not an absolute HTTP(S) URL, and a membership naming no Work+> (that spec's Q14, Q31, Q55). The reason for the exception is that the archive+> is legal apart from one field with a deterministic repair, so refusing it+> protected nothing the heal would not fix a pass later while making old+> backups unusable for a defect the reader did not cause.+ ### Rationale  Making export work while degraded means widening what a 4/4 archive means, and representing duplicate Sites means giving Site an identity in the payload — a format bump with a fourth codec to maintain beside 2/2, 3/3, and 4/4. That is a project, not a clause.@@ -222,6 +239,23 @@ A requirement phrased as "the library opens whatever state the store can hold" s  Tolerance covers exactly three states: an Entry or Work whose hostname matches no Site row; more than one Site row for one hostname; and two records of one type sharing an application UUID. Everything else the validator rejects today continues to fail closed. +> **Amended 2026-08-30 (`wrong-host-work-url-heal` Decision 1).** The set is now+> **four**. The fourth is a `WorkSiteMembership` whose confirmed Work URL is an+> absolute HTTP(S) URL on a host other than the membership's hostname, reported+> as `LibraryDiagnosis.wrongHostWorkURL(workID:workTitle:memberships:)` — keyed+> by the **Work**, like `workWithoutMembership`, not by hostname, so it+> quarantines nothing. It was added under this decision's own terms rather than+> around them: the Negative consequence below anticipated "a fourth+> sync-producible state, which would need this decision revisited", and the+> state is residue an older build wrote and mirroring carried, not corruption.+> What keeps the set closed is that the state ships with its repair — the+> reconciliation heal moves the address to the Work's membership for the host it+> names (that spec's Decision 2) — and that the tolerance is narrow: a value+> that is not an absolute HTTP(S) URL (its Q14) and a membership naming no Work+> (its Q31) are still refused, so the malformed shapes stay loud. This+> supersedes `multi-site-works` Q97, which made the shape a thrown tuple+> failure.+ ### Rationale  The three named states are the ones CloudKit's indeterminate delivery order and lack of cross-device uniqueness actually produce. An unrecognised enum raw value is not among them — mirroring transports the value the writer stored, so a bad raw means a bug or a damaged file, and failing closed on it is correct. Widening tolerance to cover genuine corruption would make the app quietly carry on over states that indicate something is wrong, which is the opposite of what the diagnosis surface is for.
specs/multi-site-works/decision_log.md Modified +1 / -1
diff --git a/specs/multi-site-works/decision_log.md b/specs/multi-site-works/decision_log.mdindex f5d0e8c..15159c3 100644--- a/specs/multi-site-works/decision_log.md+++ b/specs/multi-site-works/decision_log.md@@ -105,7 +105,7 @@ parallel and were numbered from Q90 and Q110 respectively. | Q94 | 2026-08-26 | Torn-group Work URL propagation is **per hostname**: each of the carrier's membership URLs lands on the row's membership for that hostname, and a hostname the row is not on is skipped | A Work URL is a site-specific address (Q8). The propagation read the carrier's *primary* membership and wrote the row's *primary* membership, so on any Work with more than one site it published a `b.example` address as the `a.example` link. Skipping an absent hostname keeps propagation about authored content: it carries values across a torn group, it does not mint site presence | | Q95 | 2026-08-26 | `GroupOrdering.canonicalDefinition(_ pattern:)` falls back to the canonically-encoded **legacy columns** for a nil-blob row, not to a constant | The `"raw:"` constant gave every column-only row whose ten columns form no legal arm (Q42) one ordering key, so two rows broken in two different ways compared *converged*: the validator stopped reporting the group and the reconciler read it as needing nothing. The columns are what the row actually holds, so they are what orders it. The encoder lives in `LegacyColumns` because that enum is the only sanctioned reader of those columns (Q34, Q40) | | Q96 | 2026-08-26 | The export's `requireCitationsResolve` checks the **site** on the Entry arm as well as the membership arm, over title patterns as well as URL rules | Q81 already said both the archive validator and the export carry the same-site check; only the membership arm had it. An Entry citing another site's rule therefore exported cleanly and failed the import's decode-validation instead — a library-shape problem surfacing as a broken file, which is exactly what this gate exists to name first. The gate now takes the pattern *records* rather than their ids, because a hostname cannot be read off a `Set<UUID>` |-| Q97 | 2026-08-26 | A confirmed Work URL whose host is not the membership's hostname is **refused** by the validator (a thrown tuple failure, becoming the hostname's diagnosis), not recorded as a tolerated state | Every other arm of the membership tuple throws, and matching them is the whole of the choice: the value is malformed rather than merely un-reconciled, no writer produces it (`commitWorkURL` refuses one, Q66), and nothing repairs it — a tolerated diagnosis would leave the Work detail linking to the wrong site indefinitely. `WorkURLPlanner.isOnHost(_:hostname:)` is the one host derivation, shared with `commitWorkURL` |+| Q97 | 2026-08-26 | *(**superseded by `wrong-host-work-url-heal` Decision 1**, 2026-08-30)* A confirmed Work URL whose host is not the membership's hostname is **refused** by the validator (a thrown tuple failure, becoming the hostname's diagnosis), not recorded as a tolerated state | Every other arm of the membership tuple throws, and matching them is the whole of the choice: the value is malformed rather than merely un-reconciled, no writer produces it (`commitWorkURL` refuses one, Q66), and nothing repairs it — a tolerated diagnosis would leave the Work detail linking to the wrong site indefinitely. `WorkURLPlanner.isOnHost(_:hostname:)` is the one host derivation, shared with `commitWorkURL`. **Superseded on its own terms:** the "nothing repairs it" clause no longer holds — `wrong-host-work-url-heal` ships the repair (its Decision 2: the reconciler moves the address to the Work's membership for the host it names), so the value is tolerated and Work-keyed rather than quarantining the hostname. On the real library the quarantine took rule application off every capture on a site for weeks over one Work's link (T-2289). What survives of Q97: a Work URL that is **not** an absolute HTTP(S) URL is still malformed and still refused (that spec's Q14), as is a membership naming no Work (its Q31), and `isOnHost` is still the one host derivation. Do not restore the throw for the on-host-mismatch shape | | Q98 | 2026-08-26 | `hostnameWorks` gains `groupingMemberships:`; `worksOn` and the validator's per-hostname pass take the works-only arm | The third fetch re-reads every membership of every Work on the hostname — including the ones on its *other* sites — for callers that want the Work rows and nothing else. Two fetches instead of three on the pass that runs at every commit gate | | Q99 | 2026-08-26 | The merge commit runs `DuplicateReconciler.collapseMemberships` rather than its own membership move and pair re-point | A merge is a reader-chosen collapse (Q32), and the two spellings had already drifted once. `collapseMemberships` now takes the dismissal table from its caller (it fetched the whole table per deletion plan) and reads memberships by chunked id set, so the shared version is also the cheaper one | | Q100 | 2026-08-26 | Every commit gate refuses only a diagnosis it **introduced**, through one `LibraryRepository.introducedDiagnosis(across:in:)`; the merge and duplicate-resolution gates change behaviour to match | Q67 established the rule for the deletion and membership-removal gates and left the other two refusing on any diagnosis, so a reader could not merge or resolve a duplicate on a site that was already quarantined — the site most likely to be holding duplicates. One helper, so the rule cannot be half-applied again |
specs/wrong-host-work-url-heal/decision_log.md Modified +182 / -0
diff --git a/specs/wrong-host-work-url-heal/decision_log.md b/specs/wrong-host-work-url-heal/decision_log.mdnew file mode 100644index 0000000..6789d15--- /dev/null+++ b/specs/wrong-host-work-url-heal/decision_log.md@@ -0,0 +1,182 @@+# Decision Log: Wrong-Host Work URL Heal++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-29 | Feature name `wrong-host-work-url-heal` | From the branch `T-2294/feature-wrong-host-work-url-heal` |+| Q2 | 2026-08-29 | Import normalises an off-host membership URL and then accepts the archive; the plan gate stops refusing this one shape | An archive that is legal apart from one field with a deterministic repair should import; refusing it left old backups unusable. `library-integrity-tolerance` Decision 3's "import stays strict" boundary is amended for this shape alone — see Decision 3 below |+| Q3 | 2026-08-29 | The heal is silent; the Check Library row disappears when the value has moved | Matches every existing reconciliation pass (log and refresh only); the Work's site line shows the new site. A persisted, dismissible notice like the drain report was considered and not built |+| Q4 | 2026-08-29 | Devices on an older build keep quarantining the hostname until they upgrade | The healed value syncs down; no cross-version work. The alternative — leaving the value where old builds accept it — is not moving it at all |+| Q5 | 2026-08-29 | No writer *creates* an off-host Work URL; two paths *copy* an existing one and are guarded | Work detail refuses before save (`hostMismatchMessage`, Q66 of `multi-site-works`); `commitWorkURL` refuses at the repository; the post-teaching flow confirms only site-derived candidates. Backup import copies the value verbatim ([1.1](requirements.md#11)–[1.3](requirements.md#13)) and torn-group propagation copies a membership URL per hostname without checking its host ([1.5](requirements.md#15)); both gain the check. The in-app refusals are listed under Preserved Behaviour rather than as acceptance criteria, since the feature does not change them |+| Q6 | 2026-08-29 | When the destination membership already holds an on-host Work URL, the heal keeps the destination's value and clears the source ([3.2](requirements.md#32)); the same holds for the second of two off-host values naming one host ([3.4](requirements.md#34)) | The source value is redundant: the destination has, or has just received, a confirmed address on the right site. These are the only places the heal removes a value; overwriting a confirmed on-host address with a stale one was rejected, and leaving the loser reported forever would keep the diagnosis standing against Decision 1 |+| Q7 | 2026-08-29 | No Recent attention label for the tolerated state; Check Library is its only surface | Consistent with `workWithoutMembership`, which is also Check-Library-only; a per-Work note on Recent rows would be a new attention cause for a state the app repairs itself. Recent's site-level label is derived from the Site row's own rule state, so a membership finding produces none by construction — recorded under Preserved Behaviour |+| Q8 | 2026-08-29 | Import applies the same destination precedence as the heal ([1.1](requirements.md#11)–[1.2](requirements.md#12) mirror [3.1](requirements.md#31)–[3.2](requirements.md#32)) | The first draft had import place the URL unconditionally, which would let an archive overwrite a confirmed on-host address the heal would keep |+| Q9 | 2026-08-29 | The diagnosis is keyed per Work (its id), like `workWithoutMembership`, not per hostname like a tuple diagnosis ([2.3](requirements.md#23)) | A tuple diagnosis keeps one finding per hostname; two Works with off-host URLs on one site, or a torn group holding the value on several rows, must not collapse into one row or count as several. The Check Library row opens a Work, so the Work is the natural key |+| Q10 | 2026-08-29 | The tolerated state is derived by the foreground pass, not only by the full validator | `refreshDiagnostics` carries forward only tuple diagnoses and replaces tolerated states with the foreground pass's output; a state only the full validator produced would vanish on the first refresh and the heal's gate would have no source. The foreground pass already walks every membership row and reads its identity, so the URL's host is one more scalar read; naming the Work's title ([2.3](requirements.md#23)) means reading the Work relationship for the diagnosed rows only, which is proportional to findings, not to the library |+| Q11 | 2026-08-29 | The Check Library row carries both the heal promise and the T-2289 instruction ([2.5](requirements.md#25)) | Until the pass runs, confirming or clearing the address by hand is the faster fix, and the row should not withdraw it |+| Q12 | 2026-08-29 | Membership de-duplication carries a discarded row's on-host Work URL onto the kept row when the kept row has none ([1.6](requirements.md#16)) | The heal mints a membership in identity state `none`; a later membership for the same host with a rule identity ranks above it in the survivor order, and de-duplication deleting the minted row would delete the URL the heal just preserved |+| Q13 | 2026-08-29 | The heal runs before torn-group propagation and applies its writes to every row of the Work ([3.1](requirements.md#31)–[3.3](requirements.md#33), [3.7](requirements.md#37)) | Propagation copies membership values per hostname across a torn group; run after it, the heal's clears would be undone on the next pass, breaking idempotence. With [1.5](requirements.md#15) propagation also stops copying an off-host value |+| Q14 | 2026-08-29 | A membership Work URL that is not an absolute HTTP(S) URL stays an illegal tuple, out of scope | It is malformed rather than residue: no known build wrote one (`isValidHTTPURL` has guarded the field since the Work URL feature), nothing repairs it, and `library-integrity-tolerance` Decision 4 keeps malformed values loud. Tolerating it would have widened the tolerated set for a shape with no producer and no repair |+| Q15 | 2026-08-29 | The two supporting amendments — `multi-site-works` Q97 and `library-integrity-tolerance` Decision 4 — are written as part of this feature's tasks | Without them the next reader of Q97 restores the throw |+| Q16 | 2026-08-29 | Minting the destination membership is confirmed with its cost accepted: a standing `.siteMissing` row and one extra affected record until a capture from that host, which the reader may never make | Preserving the address on the site it belongs to was preferred to deleting it; the row is informational and reads as such ([3.6](requirements.md#36)). Clearing when no destination exists was the alternative |+| Q17 | 2026-08-29 | [1.6](requirements.md#16) covers every place rows for a pair are folded to one — membership de-duplication, merge and duplicate-resolution collapse, and backup export — and carries any Work URL, off-host included | The export projection folds a `(Work, hostname)` group to its survivor by the same order as de-duplication; a heal-minted row (identity state `none`) folded away behind a later rule-identity row would drop the URL from the archive silently. [4.2](requirements.md#42) is only true with the export fold covered |+| Q18 | 2026-08-29 | The heal's gate and its clearing are stated with their one-refresh lag ([3.7](requirements.md#37), [3.9](requirements.md#39)) | The gate is set by the foreground pass and read by the reconciliation pass that follows it, and the diagnosis clears on the foreground pass after the heal — the same lag `workWithoutMembership` has. Stating it makes [3.9](requirements.md#39) testable |+| Q19 | 2026-08-29 | When a Work holds off-host values on more than one membership, one pass leaves each value on the membership for its host; two values naming each other's hosts swap ([3.4](requirements.md#34)) | The first draft defined only "destination empty" and "destination on its host", leaving a destination whose own value is off-host undefined — a loop candidate for idempotence. Each value's destination is a fixed point (the host it names), so one ordered pass converges |+| Q20 | 2026-08-29 | The import plan gate's refusal message names the membership's Work and hostname rather than the Site for membership findings ([1.4](requirements.md#14)) | Today every strict-gate refusal reads "imported Site is not legal", which misdirects the reader for a membership finding; the message is touched by this feature anyway |+| Q21 | 2026-08-29 | Removing the membership finding from the tuple map un-masks any Site, Work or Entry tuple finding it stood in front of on the same hostname ([2.2](requirements.md#22)) | The tuple map keeps one finding per hostname in arm order (sites, memberships, works, entries), so a wrong-host URL hid later findings. Reporting them is correct; the consequence is that a commit gate comparing against the hostname's prior diagnosis may now refuse where it passed before — accepted, since the hidden finding was real damage |+| Q22 | 2026-08-29 | The heal never leaves a moving value on no row after a save ([3.11](requirements.md#311)) | Reconciliation saves per chunk and may be interrupted; writing the destination before clearing the source, in one save, keeps every chunk boundary a library that still holds the value |+| Q23 | 2026-08-29 | Precedence for a Work with several off-host values is stated once and shared by heal and import ([3.4](requirements.md#34), [1.7](requirements.md#17)) | An on-host value is never replaced; an off-host value moves to its host's membership or, if that holds an on-host value, is removed; mutual off-host values swap |+| Q24 | 2026-08-29 | The heal processes values in a fixed order — source hostnames ascending, rows in survivor order — so two off-host values naming one host resolve the same way on every device ([3.4](requirements.md#34)) | Without an order, two devices healing concurrently could keep different values and, after sync, both would remove the other's — a deletion neither chose. Row order also settles torn rows that disagree on one source hostname |+| Q25 | 2026-08-29 | Import applies the precedence only where the destination is in the archive and holds no value or an on-host value; a chain (destination's own value off-host, its destination absent) is imported unchanged for the heal ([1.7](requirements.md#17)) | Import has no minting step, so overwriting a destination whose value is off-host would delete that value; the heal mints ([3.3](requirements.md#33)) and places every link of the chain |+| Q26 | 2026-08-29 | The multi-value case is computed from one snapshot of the Work and applied in one save; candidates are ordered on synced fields ([3.4](requirements.md#34)) | A sequential mutation rule cannot produce the pinned swap and leaves longer cycles undefined; a row-order tie-break falls back to fetch order, which differs between devices. Ordering candidates by source hostname then the membership survivor comparator (identity state, creation time, id) gives every device the same winner |+| Q27 | 2026-08-29 | A destination holding a value that is neither on-host nor an off-host absolute HTTP(S) URL blocks a move onto it ([3.4](requirements.md#34)) | Otherwise the move would overwrite a malformed value Q14 promises to leave alone |+| Q28 | 2026-08-29 | The open's full validation arms the heal's gate, and a pass that healed counts as having changed the library ([3.7](requirements.md#37), [3.9](requirements.md#39)) | The existing `worksWithoutMembership` gate is deliberately false from the open, and the launch pass only refreshes when its outcome is non-empty; copying either would leave a diagnosed value unhealed at launch and its diagnosis standing after the repair — against Decision 1's promise |+| Q29 | 2026-08-29 | [2.6](requirements.md#26)'s no-double-count is scoped to `.siteMissing` diagnoses for the hostnames this diagnosis names; a `.siteMissing` for another of the Work's hostnames may still count the Work once more | `.siteMissing` carries counts, not Work ids, so the counter can only reason about hostnames the two diagnoses share; widening the payload for a headline number was not worth it |+| Q30 | 2026-08-29 | Import's precedence is decided on the value the destination record will hold *after* the import — the archive's where the record is written, the library's otherwise ([1.1](requirements.md#11), [1.8](requirements.md#18), [1.2](requirements.md#12)) | The import applies an archive record's Work URL unconditionally, nil included, so the pre-import library value is not what the destination ends up holding; deciding on the pre-import value would move a URL onto a record the same import then blanks. Where the destination's outcome is not known to the import, the source imports unchanged and the heal decides |+| Q31 | 2026-08-29 | A membership naming no Work is outside the tolerance and stays a tuple failure | The diagnosis is keyed by Work and the heal moves values on a Work; an orphan membership has neither. Phase 0 of reconciliation re-attaches such rows when their Work arrives, after which they are ordinary |+| Q32 | 2026-08-29 | Import normalises only when the destination record will actually be written by that import, and never imports a source record without its URL otherwise ([1.1](requirements.md#11), [1.7](requirements.md#17)) | `commitMemberships` skips updating an existing row whose Work the import does not apply; moving the URL onto a record whose write is then skipped, while importing the source without it, would leave the value on no row. Where the write will not land, both records import unchanged and the heal places the value |+| Q33 | 2026-08-29 | A fold carries a discarded row's Work URL onto the survivor whether or not it is on the hostname ([1.6](requirements.md#16)) | De-duplication runs ungated on every pass while the heal is gated with a one-derivation lag, and import deliberately leaves duplicate pairs to de-duplication; carrying only on-host values would delete an off-host value before the heal ever saw it — including one of two torn rows disagreeing on a source hostname |+| Q34 | 2026-08-29 | A move in [3.4](requirements.md#34) lands only where the destination field will be empty after the pass; a blocked value blocks the value behind it | A block stated only for the immediate destination let a chain (`a`→`b`→`c`, `c` malformed) put two values on `b`; stating it as a fixpoint keeps cycles rotating and collisions single-winner while a blocked chain stays put and reported |+| Q35 | 2026-08-29 | The diagnosis names, per hostname, the host on the value first in the heal's order ([2.3](requirements.md#23)) | Torn rows can disagree on one source hostname; without a selection rule the two derivations could name different hosts and [2.4](requirements.md#24) would have no test |+| Q36 | 2026-08-29 | Design note: the import's source and destination records may straddle a chunk save, so an interrupted import can leave the value on no library row until re-run | Unlike the heal ([3.11](requirements.md#311)) the archive is a durable second copy and re-running the import re-normalises from it; recoverable, so not a requirement, but the design should batch the pair or accept it explicitly |+| Q37 | 2026-08-29 | The heal runs before membership de-duplication as well as before propagation ([3.7](requirements.md#37)); a fold discarding more than one value carries the first by the heal's order ([1.6](requirements.md#16)) | De-duplication runs ungated on every pass and deletes losers whole; run first, it would fold two torn rows holding different off-host values before the heal could place them. A survivor already holding an on-host value keeps it over a discarded one, which is de-duplication's existing behaviour and unchanged here |+| Q38 | 2026-08-29 | [3.9](requirements.md#39)'s clearing is scoped to a Work whose off-host values were all placed or removed; a Work with a blocked value stays reported | One diagnosis per Work ([2.3](requirements.md#23)) plus a blocked value that "stays reported" ([3.4](requirements.md#34)) made the post-heal state undeterminable otherwise |+| Q39 | 2026-08-29 | The tolerated state is a new `LibraryDiagnosis.wrongHostWorkURL(workID:workTitle:memberships:)` case; `LibraryValidationError.wrongHostWorkURL` stays as the validator's internal throw and the strict gate's report | The diagnosis set is a closed enum with per-case behaviour (id, key, count); a Work-keyed case with a hostname list cannot be expressed as a `.siteTuple`. Keeping the error case avoids touching the strict gate's reporting |+| Q40 | 2026-08-29 | `MembershipReconciler.run`'s `heals: Bool` becomes a `Heals` option set | Two independently gated heals on one pass; a second Bool parameter would invite argument-order mistakes at the single call site |+| Q41 | 2026-08-29 | One `carryWorkURL(to:from:)` helper serves all three folds ([1.6](requirements.md#16)) | Three sites folding by the same survivor order should share the one carry rule, or they drift as the propagation and collapse rules once did |+| Q42 | 2026-08-29 | The heal's resolution is a pure function over a snapshot (`WrongHostWorkURLResolver`) tested with generated inputs | [3.4](requirements.md#34)'s precedence has enough cells (cycles, collisions, blocks, torn rows) that example tests alone would miss combinations; a pure resolver makes the invariants (no value lost except by rule; idempotent; order-independent) checkable over generated Works without a PBT dependency |+| Q43 | 2026-08-29 | The scan faults `membership.work` only for diagnosed rows to read the title | Proportional to findings, not to the library; the walk itself stays on `WorkMembershipBasis` |+| Q44 | 2026-08-29 | Import normalisation is a pre-pass over the archive records in `commitMemberships`, mutating record values only | `apply`/`makeMembership` stay a verbatim copy; the pre-pass has the `rowsByID`/`appliedWorkIDs` view it needs to know whether a destination write lands (Q32) |+| Q45 | 2026-08-29 | Check Library row copy is the plain form: "'<title>' has a link that is on <host>, not this site." / "Asterism will move the link to <host> on its next check. To fix it now, open the work and, under Work URL, confirm an address on <site> or clear it." | Chosen over a terse form; matches the sentence style of the other rows |+| Q46 | 2026-08-29 | The heal gate is derived from the published diagnostics, not a stored latch | Three publishers (open, refresh, import completion) would each have to set a latch; the `worksWithoutMembership` latch is deliberately unset at open, which is the wrong precedent here (Q28). A derived property cannot be forgotten |+| Q47 | 2026-08-29 | Both validator arms — `run(graph:)` and the narrowed `validate(hostnames:)` — route the finding away from `record`; the narrowed arm drops it | The narrowed re-validation runs over the hostnames the heal touched and feeds `recordPostCommitDiagnosis`; recording the case there would re-quarantine a hostname for a neighbouring blocked value the heal correctly left |+| Q48 | 2026-08-29 | Both producers key the diagnosis on the membership's `workID` column, and the heal skips Works that have not arrived | The relationship and the column disagree for a membership whose Work has not arrived; keying on the column makes the two derivations equal ([2.4](requirements.md#24)), and a Work with no row cannot be minted on |+| Q49 | 2026-08-29 | The resolver is a blocked-closure (least fixpoint from *other* values) followed by one ordered pass with a per-destination claim | "Fixpoint" alone admits a greatest fixpoint under which every cycle is blocked; the closure-then-pass form is an algorithm the property tests can pin, and it makes a chain into an on-host value resolve as remove-then-land |+| Q50 | 2026-08-29 | The validator arms divert `wrongHostWorkURL` to the tolerated case only when `actualHost != nil` and the membership's `workID` column is non-nil; otherwise the finding stays recorded | `validate(membership:)` raises the same error for a value that is not an absolute HTTP(S) URL (`actualHost == nil`); diverting it unconditionally would tolerate the malformed shape Q14 keeps loud, and drop it from the strict gate |+| Q51 | 2026-08-29 | A `land` move carries its source hostname and the apply step clears by exact value on the source hostname across the `workID` group | A cycle lands a value on a hostname that also held one; clearing by hostname alone would erase the landed value. The destination membership is found over the whole group (`Work.membership(in:for:)`), not one row |+| Q52 | 2026-08-29 | A backup archive cannot hold two records for one destination `(Work, host)` pair — the archive-level checks refuse that payload — so the import pre-pass treats the destination as unique | Recorded so the pre-pass does not implement a branch no archive can reach |+| Q53 | 2026-08-29 | Both producers take the diagnosis title from the `workID` group's representative row (`GroupOrdering.sortedWorkRows` first); the scan reads it in its existing `Work` enumerate rather than faulting a relationship | `indexByWorkID` yields value snapshots with no row handle, and a torn group's rows can carry different titles; picking the representative in both producers is what makes [2.4](requirements.md#24)'s "same diagnosis" true, and reading it in the Work walk keeps the scan's no-fault invariant (supersedes Q43) |+| Q54 | 2026-08-29 | The fold-carry helper is value-returning; the two store folds assign its result, the export projection puts it in the record | The export fold is a read-only projection and must not dirty its context |+| Q55 | 2026-08-29 | The plan gate's refusal for a membership finding names the membership record id and hostname ([1.4](requirements.md#14) narrowed accordingly); a nil-Work off-host membership still refuses | The generic membership error carries the row id, not a Work id, and the finding it wraps is keyed by hostname; naming the Work would need a payload change for a message. The nil-Work case is Q31's and is outside the tolerance |+| Q56 | 2026-08-29 | The import chunk straddle is accepted explicitly: a Work's source and destination records sit in unrelated chunks under the archive's id order, and an interrupted import is repaired by re-running it | The archive is a durable second copy; batching the pair would mean re-sorting the archive's records, for a window the re-import already closes |+| Q57 | 2026-08-29 | Each sentence of the Check Library `problem` names both hostnames: "'<title>' has a link saved under <hostname> that is on <actualHost>." — never "not this site" | The row's `site` is the first source hostname only; a Work with off-host values under two hostnames would otherwise attribute the second pair to the wrong site ([2.5](requirements.md#25) needs each host named). Phase 1 review, issue 5 |+| Q58 | 2026-08-29 | A membership whose `workID` column is nil but whose `work` relationship is set is outside the tolerance (stays a hostname-keyed `.siteTuple`), as a consequence of Q50's column check | Both producers agree, so [2.4](requirements.md#24) holds; the initialiser writes `workID ?? work?.id`, so the shape is not expected in practice. Recorded so the quarantine path is explicit rather than implied |+| Q59 | 2026-08-29 | `distinctRecordCount` skips a Work's count only for a `.siteMissing` with `workCount > 0` on one of its source hostnames | A `.siteMissing` with `workCount == 0` counts no Work, so nothing is double-counted; narrower than Q29's wording, and pinned by a test cell |+| Q60 | 2026-08-29 | The survivor order comparator (`OrderKey`) lives beside `Work.membershipPrecedes` in `Models.swift`, keeps `id` as a `UUID` and compares its bytes only as the final tie-break (`UUID.bytesPrecede`, the same order as `uuidString`); both `indexByWorkID` and `MembershipReconciler.survivorFirst` decorate-sort-undecorate on it, and `indexByWorkID` builds keys only for groups with more than one row | The key is built on the foreground scan and the arrival duplicate phase (Q84 paths); a per-membership `uuidString` allocation there is the cost this repo already documents against in `sortedByKey`. The scan captures the representative's title and order inside its `enumerate` closure so no `Work` reference outlives a batch |+| Q61 | 2026-08-29 | The heal mints a membership on the first row in representative order (`GroupOrdering.sortedWorkRows`) that already holds a membership; only a group with no memberships at all mints on the representative | `representativeComponents` sorts a membership-less row first (empty primary hostname), so minting there would make the minted row that row's primary and change the presented site, against [3.5](requirements.md#35). Heal review, issue 3 |+| Q62 | 2026-08-29 | The merge-collapse carry resolves the keeper over the whole survivor group (survivor order across every survivor row's membership on that hostname), not the target row alone | A hostname can be in the survivor set because a sibling row holds it; looking only at `target` dropped the loser's URL, the loss [1.6](requirements.md#16) exists to prevent. Heal review, issue 5 |+| Q63 | 2026-08-29 | One emptiness rule everywhere: a membership holds a value iff `workURLString != nil`. A non-nil value that is not an absolute HTTP(S) URL (blank included) is `.other` in the resolver — it blocks a land and stays in place (Q27) — and counts as held for the fold carry and the import pre-pass | The validator records that value as a tuple failure with `actualHost == nil`; the resolver landing on it, or the import treating `""` as empty, would overwrite a reported value. The shared classifier lives in `WorkURLPlanner`. Heal review, issues 2 and 8 |+| Q64 | 2026-08-29 | The import pre-pass reads a destination's value at the moment the candidate is considered (one pass, source hostname ascending); a chain `a→b, b→c` therefore moves `b` and leaves `a` for the heal | The archive is a durable copy and the launch heal closes the remainder; a fixpoint at import would re-implement the resolver for a case the heal already handles. The docstring's "converges" claim is withdrawn and a chain cell pins the behaviour. Heal review, issue 6 |+| Q65 | 2026-08-29 | The heal compares hostnames case-insensitively: the resolver's snapshot, on-host, blocked and claimed sets are keyed on the lowercased hostname, and the apply step finds the destination membership by case-insensitive match before minting | Stored hostnames are not normalised (`isOnHost` lowercases for that reason) while `WorkURLPlanner.host(of:)` always lowercases, so a `Example.com` membership was invisible to the resolver and would be minted a second time in a different case, which the dedupe never folds. Heal review, issue 1 |+| Q66 | 2026-08-29 | The heal skips memberships with a blank hostname, and before minting looks for an existing membership on the destination hostname over the `resolvedWorkID` group; if one exists and holds a value, the move stays | Both producers skip blank hostnames, so the heal must too or it repairs an unreported row. A Q58 row (nil column, set relationship) on the destination is outside the resolver's population but still a real row; minting over it and letting the dedupe fold the pair could discard the moved URL. Heal review, issue 4 |+| Q67 | 2026-08-30 | `MembershipReconciler.healCreatedAt` quantizes its result through `MillisecondInstant`; both heals mint on it | `latest.addingTimeInterval(0.001)` is one ULP away from the same instant built through `MillisecondInstant`, and the archive's date encoding quantizes on the way out — so the value decoded back is a different `Date` and `BackupV7Exporter`'s decode-validation refused the file it had just written. A library holding *any* heal-minted membership could not be exported, which [4.2](requirements.md#42) cannot survive. Pre-existing for the phase-1 heal; found by the task 17 round trip |+| Q68 | 2026-08-30 | Both diagnosis producers key a Work's pairs on the lowercased source hostname, and the import pre-pass orders candidates on the lowercased hostname, matching the heal (Q65) | A Work with `Example.com` and `example.com` rows was two pairs to Check Library and one hostname to the heal, and the Q66 mint could sit beside a differently-cased empty row nothing folds. Pre-push review |+| Q69 | 2026-08-30 | The import pre-pass orders candidates by source hostname only; the survivor tie-break of [3.4](requirements.md#34) is not applied | Q52 makes a per-hostname tie unreachable for a legal archive; a hand-built payload with one gets an unspecified winner and is merely not normalised. Recorded as a narrowing of [1.7](requirements.md#17) |+| Q70 | 2026-08-30 | The resolver de-duplicates candidates on `(hostname, value)`; torn rows holding one value on one hostname are one candidate and count once in `movedWorkURLs` | The apply step clears by exact value across the group (Q51), so the end state is identical; counting rows would report moves that were one move |+| Q71 | 2026-08-30 | A destination hostname whose rows hold both an on-host address and a malformed value blocks the land (`stay`), rather than removing the source under [3.2](requirements.md#32) | The malformed value is a recorded finding Q27 keeps in place; removing the source while it stands would lose the one copy that names the right host. Conservative and idempotent |+| Q72 | 2026-08-30 | `indexByWorkID` skips memberships whose hostname is blank (`M2Unicode.isBlank`), not only empty; `DuplicateScan` inherits the change | Matches `validate(membership:)`, which rejects a blank hostname before the URL check, so the scan and the validator disagree on nothing. A whitespace-only hostname was never a valid key for `.siteMissing` either |+| Q73 | 2026-08-30 | When two torn rows compare equal under `GroupOrdering.representativeComponents`, both producers name the row met first in fetch order | `sortedWorkRows` is a stable sort over the graph's fetch order and the scan keeps the least key seen with ties to the earlier row over the same fetch; a fixture separating rows by `createdAt` cannot reach the tie, which is recorded rather than tested |+| Q74 | 2026-08-30 | The Check Library resolution names each pair — "Asterism will move the a.example link to b.example on its next check." — rather than listing the destinations and the sources as two comma lists (amends Q45) | Q57 made every *problem* sentence name both hostnames of its pair for exactly this reason; the resolution kept two parallel lists the reader had to align by position to learn which link was going where. The single-pair sentence, which is every real row, is Q45's wording with the source named. Pre-push review |+| Q75 | 2026-08-30 | The heal saves every `batchSize` **Works** rather than once per Work; [3.11](requirements.md#311) is a "no Work straddles a save" rule, not a "one save per Work" one | 3.11 asks that a Work's moves land together with the destination written before the source is cleared — a batch boundary on a Work edge gives that, while a save per Work made a settling pass repairing hundreds of Works pay a round trip each. Phase 1 already batches its mints the same way. The test that pinned two saves for two Works was pinning the implementation; it now uses three Works against a batch size of two, which pins both the batching and the Work edge. Pre-push review |++## Decision 1: Tolerate a Wrong-Host Work URL Instead of Quarantining the Hostname++**Date**: 2026-08-29+**Status**: accepted++### Context++`multi-site-works` Q97 made a confirmed Work URL on the wrong host a thrown tuple failure — the hostname's quarantine — on the reasoning that the value is malformed, no writer produces it, and "nothing repairs it — a tolerated diagnosis would leave the Work detail linking to the wrong site indefinitely". On the real library (T-2289) that quarantine took rule application off every webtoons capture for weeks over one Work's link, and the only repair was a manual edit the app did not point at.++### Decision++Report a wrong-host membership Work URL — an absolute HTTP(S) URL on another host — as a tolerated diagnosis that quarantines nothing, and ship the heal (Decision 2) in the same release, so the "indefinitely" in Q97's rationale no longer holds. A value that is not an absolute HTTP(S) URL is unchanged (Q14). This supersedes `multi-site-works` Q97 and adds a fourth tolerated state to `library-integrity-tolerance` Decision 4's closed set.++### Rationale++A quarantine exists for damage that nothing arriving can repair. This value is residue an older build left, and it has a deterministic repair: the address belongs on the membership for the host it names. The nearest precedent is `workWithoutMembership` (`multi-site-works` Q30): a per-record tolerated state keyed by the Work, reported in Check Library and cleared by a reconciliation heal. It is not copied wholesale: that state is produced by the full validation only — the foreground pass counts it but does not diagnose it, and the union carries forward only tuple diagnoses — so it is dropped on the first foreground refresh until relaunch. This feature's state is produced by both derivations ([2.4](requirements.md#24)) so the diagnosis, and the heal's gate that reads it, survive a refresh. Q97's own condition for refusal ("nothing repairs it") is removed by building the repair, so the decision is amended on its terms rather than reversed on the merits. The disproportion — one link disabling a site's rules for every capture — is the harm this removes.++### Alternatives Considered++- **Keep the quarantine, add the heal**: the heal clears it within one pass anyway — Rejected because until that pass every capture on the hostname is conservative, and a device that never runs the pass (an older build, or one whose scan gate never arms) keeps the disproportion.+- **Relax `isOnHost` to accept a sibling host**: preserves the value in place — Rejected because it breaks the property `commitWorkURL` relies on (the hostname is derivable from the URL) and "same site modulo one label" is a guess with no public-suffix logic behind it.++### Consequences++**Positive:**+- A single Work's link can no longer take a site's rules away.+- The tolerated set stays closed by enumeration: one named state, with a named repair.++**Negative:**+- Until the heal runs, the Work's link points at the other site; Check Library says so and offers the Work.+- `library-integrity-tolerance` Decision 4 and `multi-site-works` Q97 need amending entries so a later reader does not restore the throw.++---++## Decision 2: Move the Address to the Membership for Its Host; Mint the Membership, Never the Site++**Date**: 2026-08-29+**Status**: accepted++### Context++The repair has to put the value somewhere legal. Three shapes were discussed: blank it, treat `m.` and `www.` as one site, or move it to the membership for the host it names — minting that membership when the Work has none. Two existing decisions constrain the third: `cloudkit-mirroring` Decision 6 / Q40 (reconciliation creates no rows, because minting Sites mid-hydration produced one synthetic Site per hostname) and `multi-site-works` Q90/Q94 (a transient state must not trigger an irreversible delete; propagation moves values, it does not mint site presence or drop it).++### Decision++The heal moves the value to the Work's membership for the URL's host, adding that membership when absent — with no URL identity, a creation timestamp that cannot make it the primary membership, and no Site row. A value that is not an absolute HTTP(S) URL is left in place and reported.++### Rationale++A Work URL is a site-specific address (`multi-site-works` Q8): the value is correct data on the wrong row, and a move records the truth — the Work is reachable at that address on that site. Minting a membership without its Site row is the shape `MembershipReconciler.heal` already produces (`site: nil`, completed by `SiteReconciler.heal` when the row arrives), so no new row-creation policy is needed and Decision 6/Q40 stands. Not clearing an unplaceable value keeps Q90's posture: the app does not delete reader-confirmed content it cannot place elsewhere, and the value was already unusable, so leaving it reported costs nothing. The one removal the heal makes is of a value the destination already holds a confirmed on-host address for (Q6), where nothing the reader authored is lost.++### Alternatives Considered++- **Blank the value**: simplest, always legal — Rejected because it deletes reader-confirmed content the move can preserve, against Q90.+- **Mint the Site row too, so the site appears at once**: the reader's first suggestion — Rejected because reconciliation creating Sites is exactly what Q40 removed after it minted one synthetic Site per hostname during hydration; the Site arrives on capture, and `.siteMissing` already covers the gap.+- **Hostname aliasing**: see Decision 1.++### Consequences++**Positive:**+- No reader-confirmed value is deleted except one whose destination already holds, or in the same pass receives, an on-host address (Q6); the Work links to the right place.+- Reuses the existing heal shape, timestamps and Site-arrival path.++**Negative:**+- Until something is captured from the new host, Check Library shows `.siteMissing` for it, the affected-record count includes the minted membership, and the Work's site line names a site with no Site row. The reader who never captures from that host cannot clear that row except by clearing the URL.+- A backup export synthesises a wire Site for a membership hostname with no row (`cloudkit-mirroring` Q40), so an export/import round-trip materialises the Site the heal declined to create ([4.2](requirements.md#42) pins that the membership and URL survive the round-trip).++---++## Decision 3: Import Normalises the Shape Rather Than Refusing the Archive++**Date**: 2026-08-29+**Status**: accepted++### Context++`library-integrity-tolerance` Decision 3 keeps the import gate strict: an imported library must be wholly legal, and the open paths' tolerance does not extend to import. Because the membership arm records the same finding in strict and tolerant mode, an archive holding one off-host membership URL is refused outright today — the reader cannot restore an old backup until they repair the source library.++### Decision++Import applies the same rule the heal applies: an off-host membership URL is moved to the archive's own membership for that host when one exists; otherwise the record imports unchanged and the library reports the tolerated state. The plan gate no longer refuses an archive on this ground alone. Every other strict refusal is unchanged.++### Rationale++The archive is legal apart from one field with a deterministic repair; refusing it protects nothing the heal would not repair a pass later, and it makes old backups unusable for a defect the reader did not cause. Normalising at import rather than importing as-is keeps the heal's work off the import path where the archive itself holds the destination; where only the target library holds it ([1.3](requirements.md#13)), the heal completes the move on the next pass. Import is a union that leaves duplicate `(Work, hostname)` rows to membership de-duplication, which is why [1.6](requirements.md#16) is part of this feature.++### Alternatives Considered++- **Keep refusing** (Decision 3 unchanged): simplest — Rejected because old archives with this residue stay un-importable.+- **Import as-is, no normalisation**: fewer import changes — Rejected because it leaves a known-wrong value in a fresh library for one heal round-trip when the archive itself holds the right destination.++### Consequences++**Positive:**+- Old archives import; the library is legal or tolerated-and-healable afterwards.++**Negative:**+- The strict gate's contract gains one named exception, recorded here and in the gate's own comment.++---
specs/wrong-host-work-url-heal/design.md Modified +156 / -0
diff --git a/specs/wrong-host-work-url-heal/design.md b/specs/wrong-host-work-url-heal/design.mdnew file mode 100644index 0000000..f2f7a7d--- /dev/null+++ b/specs/wrong-host-work-url-heal/design.md@@ -0,0 +1,156 @@+# Design: Wrong-Host Work URL Heal++## Overview++A membership Work URL on another host becomes a Work-keyed tolerated diagnosis produced by both derivations, a `MembershipReconciler` phase moves each such value to the membership for its host, and backup import applies the same precedence where it can decide the outcome. No schema or archive change.++## Architecture++### Where the pieces go++| Concern | Location | Integration point |+|---|---|---|+| Tolerated state | `LibraryDiagnostics.swift` — new `LibraryDiagnosis.wrongHostWorkURL` | `union`, `distinctRecordCount`, `id`/`hostname`/`recordCount`/`caseRank`/`payloadKey`/`clearableByReteaching`; `quarantineMap` unchanged (projects `.siteTuple` only) |+| Validator producers | `LibraryValidator.run(graph:)` membership arm (`:424-430`) **and** `validate(hostnames:context:)`'s membership arm (`:172-240`) | both arms route `.wrongHostWorkURL` with `actualHost != nil` and a non-nil `workID` away from `record` — `run` appends to `tolerated`, the narrowed arm drops it (it returns a tuple map only); a finding with `actualHost == nil` (a value that is not an absolute HTTP(S) URL) stays `record`ed in both arms |+| Foreground producer | `LibraryToleranceScan.scan` | `WorkMembershipBasis` gains `workURLString`, populated by `indexByWorkID`; new `wrongHostWorkURLCount` on the result |+| Gate | `LibraryRepository` | derived: `diagnostics.diagnoses.contains { if case .wrongHostWorkURL = $0 }` — no stored latch, so the open (`Certification.diagnostics` → `makeRepository`/`init`), `refreshDiagnostics`, and import completion (`ConfirmImport.swift:106`) all arm it by publishing |+| Heal | `MembershipReconciler.run` — new phase between `heal` and `dedupeMemberships` | `heals: Heals` option set; `report.movedWorkURLs`; `isEmpty` includes it |+| Fold carry | `MembershipReconciler.dedupeMemberships`, `DuplicateReconciler.collapseMemberships`, `BackupArchiveProjection.mapMembershipRecords` | one shared value-returning `carriedWorkURL(survivor:losers:)` |+| Propagation guard | `DuplicateReconciler` propagation (`:1317-1323`) | `isOnHost` check before copy |+| Import | `LibraryRepository+ConfirmImport.commitMemberships` | pre-pass over the archive's membership records |+| Import gate | `BackupImportGates.validateImportPlanPayload` | unchanged validation; the case is tolerated so it does not reach it; refusal wrapper switches on the error's record type |+| Surface | `MaintenanceViewModels` row builders | new case in `problem`/`resolution`/`detail`/`workNamed(by:)`/`recordCountText` |++### Diagnosis derivation (Req 2.3–2.4)++```swift+case wrongHostWorkURL(+    workID: String,                          // the membership's workID column; id = "wrongHostWorkURL:\(workID)"+    workTitle: String?,                      // nil until the Work has arrived+    memberships: [WrongHostMembership])      // one per source hostname, hostname-sorted+```++- **Both producers key on the `workID` column**, not the `work` relationship, so a membership whose Work has not arrived is the same diagnosis in both, with `workTitle == nil`. A membership with a nil column stays a recorded `.siteTuple` (Q31).+- **Title** (2.3): both producers take `displayTitle` from the representative row of the `workID` group — `GroupOrdering.sortedWorkRows(rows).first` over the Work rows sharing that id — so a torn group with differing titles names one title in both.+- **Full validation** (`run(graph:)`): the membership loop catches `.wrongHostWorkURL` and, only when `actualHost != nil` and the membership's `workID` column is non-nil, accumulates `(workID → [pair])`; after the loop it appends one tolerated diagnosis per Work, with the title from the group's representative row among `graph.works`.+- **Narrowed validation** (`validate(hostnames:)`): the same catch with the same two conditions, and the case is dropped — it returns a tuple map and this is not a tuple finding. Without this the post-heal re-validation over `touchedHostnames` would re-quarantine the hostname for a neighbouring blocked value.+- **Foreground pass**: `indexByWorkID` walks every membership as today and `WorkMembershipBasis` now carries `workURLString`; for each `workID`, memberships whose `WorkURLPlanner.host(of:)` is non-nil and ≠ hostname yield the pairs. The diagnosed `workID` set is known before the scan's existing `Work` enumerate, which collects those Works' rows and takes the representative's `displayTitle` there — no relationship is faulted, so the scan's own invariant holds. `wrongHostWorkURLCount` = Works diagnosed.+- **Union**: the scan's tolerated set *replaces* the previous one (it is not carried forward), which is why the scan must reproduce the case (2.4).+- **Within-hostname selection** (Q35): when torn rows disagree for one source hostname, the pair names the host on the value first in the heal's candidate order; both producers apply it.+- **Sort tier**: `hostname` = the first source hostname, so the row lists under that site; `payloadKey` = the joined pairs.+- **`distinctRecordCount`** (2.6): the Work counts once in `wrongHostWorkURLWorks: Set<String>` and is skipped when a `.siteMissing` in the same set names one of its **source** hostnames (a membership certainly exists there; the actual host may have none yet).++### Un-masking (Req 2.2, Q21)++`record()` keeps the first error per hostname; with the membership finding no longer recorded, a Work- or Entry-arm finding on that hostname becomes the tuple diagnosis. Consumers whose comparison changes: `recordPostCommitDiagnosis` after teaching commits and after `reconcileAfterSync`'s narrowed re-validation, and `introducedDiagnosis` on the Work URL, merge, duplicate-resolution, deletion and membership-removal commits. A hostname can therefore become quarantined on the pass that heals it, for a finding that was real and hidden.++### The heal (Req 3)++New phase in `MembershipReconciler.run`, after phase 1 (`heal`) and before phase 2 (`dedupeMemberships`), gated by `heals.contains(.wrongHostWorkURL)`.++**Inputs.** The membership rows `run` already holds, grouped by `workID` column; Site winners fetched by the phase itself (the same fetch phase 1 does, shared as a helper — `rowsByHostname` is nil at the production call site and phase 1 may not have run). Works in `unarrivedWorkIDs` (phase 0) are skipped: there is no `Work` to mint on.++**Resolver** — `WrongHostWorkURLResolver.resolve(_ snapshot: WorkSnapshot) -> [Move]`, a pure function:++1. Snapshot every membership of the Work across its rows as `(rowID, hostname, value)`; classify each value with the shared `WorkURLPlanner.classifyWorkURL(_:on:)` (Q63): *empty* (the column is nil), *on-host*, *offHost* (absolute HTTP(S) URL on another host), *other* (anything else held, a blank string included). Every hostname is keyed on its lowercased spelling, since a stored one is not normalised (Q65).+2. **Blocked closure** (least fixpoint): a hostname is blocked iff any of its memberships holds an *other* value, or holds a *movable* value whose destination hostname is blocked. Computed by iterating from the *other*-holding hostnames along reverse edges until no change; a cycle with no *other* value is not blocked.+3. Candidates = every *movable* value; a candidate whose **destination** hostname is blocked is `stay`. Order: `(sourceHostname ASC, survivorFirst order of the membership row)`.+4. One ordered pass with a per-destination `claimed` flag: a candidate `lands` iff its destination is not blocked, holds no on-host value on any row, and is not yet claimed (then mark claimed); otherwise it is `removed`. A destination that held movable values is empty after the pass because every such value lands or is removed, so a cycle rotates and two-to-one has one winner.+5. Output `[Move]`: `land(value, from: sourceHostname, to: destinationHostname)`, `remove(value, from: sourceHostname)`, `stay(sourceHostname)`.++**Apply** (per Work, one save — 3.11): for each `land`, set the value on the survivor membership of the destination hostname across every row of the `workID` group (`Work.membership(in:forHostMatching:)` over the group's memberships, not one row's, matched case-insensitively — Q65), or mint one — on the first row in `GroupOrdering.sortedWorkRows` order that already holds a membership, falling back to the representative only where the group holds none (Q61; a membership-less row sorts first, and minting there would move the site that row is presented under), `urlIdentityState: .none`, `site: winners[host]` (nil-tolerant), `createdAt: healCreatedAt(for:)` computed over that row's own memberships — primary is per row and the Work is presented from that row, so sibling rows' timestamps are irrelevant to 3.5 — and append the minted row to the row list `dedupeMemberships` sees. A membership with a blank hostname takes no part, and a `land` whose destination is already held by a row *outside* the `workID` reading that holds a value is abandoned, so the source keeps its value and the Work stays reported (Q66). Then, for every applied `land` and `remove`, clear that exact value from every membership of the group on the *source* hostname (matching on value, so a value that landed on a hostname in a cycle is not cleared from it). `report.movedWorkURLs` counts **values** placed or cleared, not Works, and a plan that wrote nothing counts nothing; source and destination hostnames into `touchedHostnames`.++**Save cadence.** One `save` per Work written; no Work straddles a save.++**Idempotence** (3.8): after a pass every value is on-host, removed, or `stay`; a re-run produces no `land`/`remove` and writes nothing. A Work with a `stay` remains diagnosed (3.9, Q38).++**Gate and clearing** (3.7, 3.9): the derived gate is true whenever the published `diagnostics` contain the case, so the open, a refresh, and an import completion all arm it. `MembershipReconcileReport.isEmpty` includes `movedWorkURLs`, and `ReconciliationOutcome.isEmpty` already includes `memberships`, so a pass that only moved URLs makes the launch path refresh.++**Logging** (3.10): the existing report `debug` line gains the count; nothing else is logged.++### Fold carry (Req 1.6, Q17, Q33, Q37)++`MembershipReconciler.carriedWorkURL(survivor: String?, losers: [WorkSiteMembership]) -> String?` returns the survivor's value where it holds one — `WorkURLPlanner.holdsWorkURL`, non-nil and blank included (Q63) — else the first held `workURLString` among losers in `survivorFirst` order; the two store folds assign its result to the survivor row, the export projection puts it in the projected record.++| Fold | Site | Carry |+|---|---|---|+| `dedupeMemberships` | `MembershipReconciler.swift:306-346` | in the grouping loop, where survivor and losers are still paired (the loser list is flattened before deletion) |+| `collapseMemberships` | `DuplicateReconciler.swift:774-783`, the delete arm | survivor = the moved row, else survivor order over **every** survivor row's membership on the hostname (Q62) |+| `mapMembershipRecords` | `BackupArchiveProjection.swift:678` | projected record carries the URL; no store write |++Propagation (`DuplicateReconciler.swift:1317-1323`) adds `WorkURLPlanner.isOnHost(url, hostname: carried.hostname)` to its guard (1.5).++### Import (Req 1.1–1.3, 1.7, 1.8)++A pre-pass in `commitMemberships`, before the chunk loop, over the archive's records grouped by `workID` (`rowsByID` and `appliedWorkIDs` are in scope there):++- Off-host records = `classifyWorkURL(workURLString, on: hostname)` is `.offHost` (Q63), so a blank value is held rather than empty and is neither moved nor written over.+- Records with a nil `workID` take no part, as source or destination (they are the recorded tuple failure of Q31).+- Destination record = the archive record of the same Work for that host (the archive-level checks already refuse a Work with two memberships on one hostname, so it is unique). **Written** iff no library row has its id, or its Work ∈ `appliedWorkIDs`; its post-import value is then the archive's. Otherwise the outcome is unknown and the source is left unchanged (Q30, Q32).+- Precedence over a Work's off-host records in the resolver's candidate order (1.7): destination empty → move (1.8); destination on-host → strip source (1.2); destination off-host, absent, or not written → leave unchanged (1.3).+- **One sequential pass, not a fixpoint** (Q64): each candidate reads its destination as it stands at that moment, so a chain `a → b, b → c` with `c` empty moves `b` and leaves `a` — when `a` was considered, `b` still held an off-host value of its own. The launch heal, which resolves the whole chain from one reading and can mint, closes the remainder.+- The pre-pass rebuilds the affected `BackupV7Membership` values through the memberwise initialiser (its properties are `let`) into a new records array; `apply`/`makeMembership` are unchanged.+- The **source** record's own write may also be skipped by the gate; the library row then keeps the off-host value while the destination holds it too, which is the heal's 3.2 case on the next pass.++The plan gate runs `validateStrict`, which reports tuple diagnoses only; the tolerated case does not reach it (1.4). Its refusal wrapper switches on the map's error value: `invalidStateTuple(type: "WorkSiteMembership", id: …)` or `wrongHostWorkURL(membershipID: …)` (nil-Work case) is rethrown as `invalidStateTuple(type: "WorkSiteMembership", id: <membership id>, reason: "…on <hostname>: <reason>")`; everything else keeps the `type: "Site"` wrapper. The stale "three call sites" comment at `LibraryValidator.swift:317` is corrected to the one remaining caller.++Q36: the pre-pass does not change chunking, and the archive's `id`-sorted record order puts a Work's source and destination in unrelated chunks, so an interrupted import can leave the value on no library row. Accepted: the archive is a durable copy and re-running the import re-normalises from it.++### Check Library (Req 2.5)++`MaintenanceViewModels.row(_:)` gains the case: `site` = first source hostname; `problem` = "'<title>' has a link saved under <hostname> that is on <actualHost>." (or "A work" + id prefix while the Work has not arrived; one sentence per pair, each naming both hostnames — Q57); `resolution` = "Asterism will move the link to <actualHost> on its next check. To fix it now, open the work and, under Work URL, confirm an address on <hostname> or clear it."; `detail` = the pairs; `reteachHostname` nil; `workID`/`workTitle` set so the existing Open-work button (T-2289) shows when the Work has arrived; `recordCountText` = "1 work record affected". The existing `.siteTuple` handling of `LibraryValidationError.wrongHostWorkURL` stays for the nil-Work case (Q31): not re-teachable, names the hostname and host, no Work to open.++## Components and Interfaces++```swift+// LibraryDiagnostics.swift+public struct WrongHostMembership: Equatable, Sendable, Comparable {   // ordered by hostname+    public let hostname: String; public let actualHost: String+}+case wrongHostWorkURL(workID: String, workTitle: String?, memberships: [WrongHostMembership])+struct LibraryToleranceScan.Result { …; public let wrongHostWorkURLCount: Int }++// Models.swift+struct WorkMembershipBasis { hostname; urlIdentity; workURLString: String? }++// MembershipReconciler.swift+struct Heals: OptionSet { static let missingMembership; static let wrongHostWorkURL; static let all }+static func run(context:rowsByHostname:batchSize:saveStrategy:heals: Heals = .all)   // tests keep the default+struct MembershipReconcileReport { …; public var movedWorkURLs: Int }   // isEmpty includes it+static func carriedWorkURL(survivor: String?, losers: [WorkSiteMembership]) -> String?++// WrongHostWorkURLResolver.swift (new, internal, pure)+struct WorkSnapshot { memberships: [(rowID: UUID, hostname: String, value: String?)] }+enum Move { case land(value: String, from: String, to: String), remove(value: String, from: String), stay(hostname: String) }+static func resolve(_ snapshot: WorkSnapshot) -> [Move]++// LibraryRepository+var healsWrongHostWorkURL: Bool { diagnostics.diagnoses.contains { if case .wrongHostWorkURL = $0 { true } else { false } } }+```++`reconcileAfterSync` passes `heals: [worksWithoutMembership ? .missingMembership : [], healsWrongHostWorkURL ? .wrongHostWorkURL : []]`.++## Decision-log amendments (Q15)++Tasks write: `multi-site-works` Q97 → "superseded by `wrong-host-work-url-heal` Decision 1"; `library-integrity-tolerance` Decision 4 → amendment note adding the fourth tolerated state; Decision 3 → note on the import exception (this spec's Decision 3).++## Testing Strategy++Core (`make test-core`), Swift Testing, `.serialized` suites over on-disk temp stores as `MembershipReconcilerTests` and `WrongHostWorkURLDiagnosisTests` do.++| Area | Tests | ACs |+|---|---|---|+| Diagnosis | full validation and scan produce equal diagnoses for the same store (one Work; two Works on one site; torn rows disagreeing; Work not arrived → title nil in both); union after refresh keeps it; quarantine map empty; narrowed validation over the hostname records nothing for it; nil-`workID` membership stays a tuple failure; a value that is not an absolute HTTP(S) URL stays a tuple diagnosis in the full arm, the narrowed arm and the strict gate; un-masked Entry finding becomes the tuple diagnosis | 2.1–2.4, Q14, Q31 |+| Counting | `affectedRecordCount` with and without a `.siteMissing` on a source hostname | 2.6 |+| Not quarantined | over a diagnosed hostname: capture applies the site's rules; Merge is not refused; `WorkMergeModel`'s candidate list (reads `quarantineMap()`) includes the Work; `SettingsSyncModel.isHealthy` true | 2.1 |+| Report hygiene | `MembershipReconcileReport` and the log line after a heal carry counts and hostnames only | 3.10 |+| Compatibility | schema version and archive format unchanged (existing golden export test passes); a heal-minted membership for a host with no Site row survives export → import (the export's synthesised untaught wire Site lets the archive checks accept it) | 4.1, 4.2 |+| Resolver (pure) | move to empty; destination on-host (removed); mint; 2-cycle; 3-cycle; two-to-one (winner by order, loser removed); chain into an *other* value (all `stay`); chain into an on-host value (`removed` then `land`); torn rows disagreeing | 3.1–3.4 |+| Heal (store) | mint is `site` nil, not primary, visible to dedupe in the same run; every row's source cleared; saves on Work boundaries (Q75); second run writes nothing; report count; both hostnames touched and the hostname not quarantined afterwards | 3.3, 3.5–3.11 |+| Gate | launch pass heals when only the open's validation reported it; import completion arms it; arrival after refresh healed on the pass after the next refresh; launch pass refreshes after a URL-only heal | 3.7, 3.9 |+| Folds | dedupe, collapse, export each carry a loser's URL (on-host and off-host) to an empty survivor; survivor with a value unchanged | 1.6 |+| Propagation | off-host carrier value not copied | 1.5 |+| Import | each cell: destination inserted / updated-applied / update-skipped / absent / off-host / on-host; source update skipped; nil-`workID` record untouched; multi-value order; archive with the shape passes the plan gate; a nil-Work off-host membership still refuses; refusal message names the membership and hostname | 1.1–1.4, 1.7, 1.8 |+| Surface (`make test-quick`) | `LibraryDiagnosticsModelTests`: row fields, wording, Open-work route, no re-teach; nil-Work `.siteTuple` row unchanged | 2.5 |++Property-based, on the resolver: Swift Testing parameterised cases over a seeded generator (2–6 hostnames, random empty/on-host/movable/other values, random torn duplication), asserting: every `land` destination is unblocked and unclaimed; no value is dropped except by `remove` with an on-host or earlier-landed destination; `resolve(apply(resolve(x)))` yields no `land`/`remove`; output is independent of input order. No external PBT dependency.
specs/wrong-host-work-url-heal/explanation.md Modified +21 / -0
diff --git a/specs/wrong-host-work-url-heal/explanation.md b/specs/wrong-host-work-url-heal/explanation.mdnew file mode 100644index 0000000..bba8fbd--- /dev/null+++ b/specs/wrong-host-work-url-heal/explanation.md@@ -0,0 +1,21 @@+# Explanation: Wrong-Host Work URL Heal++Self-validation of `design.md` at three levels (explain-like), 2026-08-29.++## Beginner++A *Work* is a story you follow. It can live on more than one site — `www.webtoons.com` and `m.webtoons.com` are two sites — and for each site the app keeps a link to the story (the Work URL). Some old links point at the sibling site. Today the app treats that as the whole site being broken and stops applying its rules to every new chapter you save from it. After this change it is a to-do item: the app lists it in Check Library, and on its next tidy-up moves the link to the site it belongs to — adding that site to the story if it was not on it. Restoring a backup with the same problem gets the same treatment.++## Intermediate++Six touch points. The validator emits a per-Work tolerated diagnosis instead of recording a per-hostname tuple failure. The foreground scan emits the same diagnosis from one extra column on the membership walk and arms a gate. `MembershipReconciler` gains a gated phase that resolves each Work's off-host values against a snapshot (pure resolver) and writes the Work in one save. The three folds that discard duplicate membership rows share a carry helper so a discarded URL lands on the survivor. Torn-group propagation refuses to copy an off-host value. Import pre-normalises where the destination write is known to land, otherwise leaves the value for the heal. Trade-offs: pure resolver over in-place mutation (testable, convergent); no Site minting (Q40); deletion only in two named cases (Q6).++## Expert++Ordering and atomicity carry the risk. The heal must run before `dedupeMemberships` (deletes losers whole) and before propagation (copies per hostname); per-Work single-save keeps every chunk boundary a legal library; the gate must be armed by the open's full validation — the existing `worksWithoutMembership` gate is deliberately not — or the launch pass never heals; un-masking shifts `priorDiagnosis` comparisons on the commit gates. The resolver distinguishes *blocked* (stay, still diagnosed) from *removed* (destination holds an on-host value or an earlier winner), and rotates cycles because each member's destination empties.++## Validation findings++- **Gap (fixed):** a membership naming no Work still reaches the `.siteTuple` path with `LibraryValidationError.wrongHostWorkURL`; reverting `clearableByReteaching` to "`.siteTuple` ⇒ true" would have offered re-teach for it. The existing not-re-teachable handling stays for that residual case.+- **Clarified:** the strict import gate reports tuple diagnoses only, which is why an archive with the tolerated shape passes; a minted membership goes on the torn group's representative row.+- No contradictions or circular dependencies found; every requirement maps to a design element.
specs/wrong-host-work-url-heal/implementation.md Modified +372 / -0
diff --git a/specs/wrong-host-work-url-heal/implementation.md b/specs/wrong-host-work-url-heal/implementation.mdnew file mode 100644index 0000000..fe22a2a--- /dev/null+++ b/specs/wrong-host-work-url-heal/implementation.md@@ -0,0 +1,372 @@+# Implementation: Wrong-Host Work URL Heal++Three-level explanation of the branch `T-2294/feature-wrong-host-work-url-heal`+(merge-base `c3c720a`). Written after the code landed; the design-phase+self-validation is in `explanation.md`.++The branch also carries the merged T-2295 fix (`duplicateRowsAwaitReconciliation`+and the `ToleratedStateFixture` rename). That work is not this feature and is+not described here.++---++## Beginner Level++### What Changed++A *Work* is a story you follow. It can be on more than one site — `www.example.com`+and `m.example.com` count as two different sites — and Asterism keeps, per site,+a link to where that story lives on that site. That link is the **Work URL**.++Old builds of the app sometimes saved the link for site A onto the record for+site B. The library used to treat that as proof the whole site was broken, so it+**quarantined** the site: every new chapter captured from it stopped getting the+site's rules applied, and Merge refused to touch its Works. One stale link+disabled a site.++Three things changed:++1. **It is no longer a quarantine.** A link on the wrong site is now a to-do+   item — the app calls it a *tolerated diagnosis* — listed in Check Library.+   The site keeps working normally.+2. **The app repairs it by itself.** On its next tidy-up pass, Asterism moves+   the link to the site it actually points at. If the story was not yet on that+   site, it adds the story to that site (without inventing the site itself).+3. **Restoring a backup no longer fails because of it.** Backups holding this+   old residue used to be refused outright. Now the import fixes the link where+   it can work out the answer, and leaves it for the tidy-up pass where it+   cannot.++The Check Library entry says both things: that Asterism will move the link, and+that you can fix it yourself right now by opening the work and confirming or+clearing the address.++### Why It Matters++The real-world trigger (ticket T-2289) was a single link on the user's own+library that took rule application away from every webtoons capture for weeks.+The cost was wildly out of proportion to the fault, and the only repair was a+manual edit the app never pointed at. This makes the fault small and self-healing.++### Key Concepts++- **Membership** — the record joining one Work to one site. It holds that site's+  Work URL. One Work on three sites has three memberships.+- **Hostname** — `www.example.com`. Sites are keyed on the exact hostname;+  `m.` and `www.` are deliberately *not* treated as the same site.+- **Quarantine** — the library's "this site is damaged, be conservative"+  switch. Turning it off for this fault is the whole point of the change.+- **Tolerated diagnosis** — a fault the library reports but works around. There+  were three; this adds a fourth.+- **Torn group** — one Work stored as several rows sharing its id, a transient+  state sync can produce. The repair has to consider every row of the group.+- **Reconciliation pass** — the background tidy-up that runs at launch and after+  sync. The repair is a new phase inside it.+- **Heal** — a repair the app performs silently, with no reader involvement.++---++## Intermediate Level++### Changes Overview++| File | What it gained |+|---|---|+| `LibraryDiagnostics.swift:69` | `LibraryDiagnosis.wrongHostWorkURL(workID:workTitle:memberships:)` plus its `id`/`hostname`/`recordCount`/`caseRank`/`payloadKey` arms |+| `LibraryDiagnostics.swift:196` | `WrongHostMembership` (a `(hostname, actualHost)` pair) and `pairs(from:)` — the *one* derivation both producers call |+| `LibraryDiagnostics.swift:392` | `distinctRecordCount` counts the Work once, and not at all when a `.siteMissing` with `workCount > 0` already counts it on a source hostname |+| `LibraryDiagnostics.swift:627-726` | The foreground scan producer, plus `wrongHostWorkURLCount` |+| `LibraryValidator.swift:912` | `isToleratedWrongHostWorkURL` — the shared narrowing both validator arms apply |+| `LibraryValidator.swift:250, :461-495` | The narrowed arm drops the finding; the full arm accumulates per Work and appends one tolerated diagnosis |+| `WorkURLPlanner.swift:128-169` | `StoredWorkURL`, `holdsWorkURL`, `classifyWorkURL(_:on:)`, `offHost(_:on:)` — one emptiness/host rule for four callers |+| `WrongHostWorkURLResolver.swift` (new, 175 lines) | The pure resolver: blocked closure, then one ordered pass with a per-destination claim |+| `MembershipReconciler.swift:92` | `Heals` option set; `run(… heals:)` |+| `MembershipReconciler.swift:392-573` | Phase 1b, `healWrongHostWorkURLs` — snapshot, resolve, apply, save |+| `MembershipReconciler.swift:685, :700` | `carriedWorkURL` / `carryWorkURL`, the shared fold-carry rule |+| `Models.swift:1075` | `WorkSiteMembership.OrderKey`; `indexByWorkID` now returns each Work's memberships in survivor order and carries `workURLString` |+| `Models.swift:448-461` | `Work.memberships(in:forHostMatching:)` — case-insensitive hostname match |+| `Models.swift:1277` | `UUID.bytesPrecede` |+| `GroupOrdering.swift:359` | `keepRepresentative` — fold a row into a running representative without retaining it |+| `DuplicateReconciler.swift:755-822` | Collapse carries a discarded row's Work URL to the keeper, resolved over the whole survivor group |+| `DuplicateReconciler.swift:1345` | Propagation copies only an on-host value, classified once per carrier |+| `BackupArchiveProjection.swift:678-707` | The export fold carries the value into the projected record |+| `LibraryRepository+ConfirmImport.swift:666, :732` | `normalizedMemberships` pre-pass and `willWriteMembership` |+| `LibraryRepository+BackupImportGates.swift:77` | `refusal(hostname:error:)` — a membership finding is reported as the membership's own tuple |+| `LibraryRepository.swift:295, :367` | The derived `healsWrongHostWorkURL` gate, wired into `reconcileAfterSync` |+| `MaintenanceViewModels.swift:291-434` | The Check Library row: problem, resolution, detail, record count, Open-work route |++### Implementation Approach++**One rule, one spelling.** The recurring shape of this change is a rule that+already existed in two or three slightly different forms being collapsed to a+single function every caller reads:++- `WorkURLPlanner.classifyWorkURL` replaces three spellings of "is this+  membership empty" (`== nil`, `== nil || isEmpty`, "nil or blank"). Q63 makes+  the answer "held iff non-nil", so a blank string — a value the validator+  *reports* — can never be written over.+- `WorkSiteMembership.OrderKey` (`Models.swift:1075`) is the survivor order as+  three scalars, so `indexByWorkID`'s value snapshots and+  `MembershipReconciler.survivorFirst`'s live rows sort by one comparator.+- `WrongHostMembership.pairs` is called by both diagnosis producers, which is+  what makes Req 2.4's "the same diagnosis" true rather than merely intended.+- `MembershipReconciler.carriedWorkURL` is called by all three folds+  (de-duplication, merge collapse, export projection).++**Diagnosis: two producers, one output.** The full validation+(`LibraryValidator.run(graph:)`) and the cheap foreground scan+(`LibraryToleranceScan.scan`) both emit the case. That is not redundancy:+`refreshDiagnostics` carries forward tuple diagnoses only and *replaces* the+tolerated set with the scan's output, so a state only the validator produced+would vanish on the first foreground refresh — taking the heal's gate with it.+Both key on the membership's `workID` **column**, never the `work` relationship,+so a Work that has not arrived is the same diagnosis in both, with a nil title.++**Repair: pure resolver, then apply.** `WrongHostWorkURLResolver.resolve` takes a+snapshot of every membership of one Work (hostname, value, order key) and returns+`[Move]` — `land`, `remove`, `stay`. It reads no store and writes nothing. The+algorithm is a least fixpoint marking hostnames *blocked* (they hold a malformed+value, or point at a blocked hostname), then one pass in `(source hostname,+survivor order)` with a per-destination `claimed` flag. That is what makes cycles+rotate: a destination holding only movable values is empty after the pass,+because every one of them lands or is removed.++`MembershipReconciler.healWrongHostWorkURLs` only carries the answer to the rows:+lands first (writing the destination, minting the membership where absent), then+clears each moved value from every row of the group on its *source* hostname,+matching on the exact value — because a cycle lands a value on a hostname that+also held one.++**Gate.** `LibraryRepository.healsWrongHostWorkURL` is derived from the published+diagnoses rather than latched. Three things publish diagnostics (the open's full+validation, `refreshDiagnostics`, import completion) and a derived property cannot+be forgotten by any of them. This is deliberately *not* the+`worksWithoutMembership` pattern, which is false at open by design (Q28, Q46).++**Import.** `normalizedMemberships` is a pure static over the archive's records+plus two facts about the target library — which membership ids it holds and which+Works this import applied. It applies the heal's precedence, but only where it+knows what the destination will hold *after* the import; everything else imports+unchanged for the heal. One sequential pass, not a fixpoint: a chain `a→b, b→c`+moves `b` and leaves `a`, and the launch heal (which can also mint) closes the+remainder.++### Trade-offs++- **Pure resolver over in-place mutation.** Buys generated-input testing (400+  seeded cases at `WrongHostWorkURLResolverTests.swift:253`) and a convergence+  argument; costs a snapshot type and a second traversal.+- **Two producers of one diagnosis.** Buys survival across a foreground refresh;+  costs a duplicated derivation that only stays honest because both call+  `pairs(from:)`.+- **Mint the membership, never the Site row** (Decision 2). Preserves the reader's+  address on the site it belongs to; costs a standing `.siteMissing` row and one+  extra affected record until something is captured from that host — possibly+  never.+- **Un-masking** (Q21). Removing the membership finding from the hostname-keyed+  tuple map exposes Site/Work/Entry findings it used to stand in front of. Those+  findings are real damage, so reporting them is correct — but a commit gate+  comparing against the hostname's prior diagnosis can now refuse where it passed+  before, and a hostname can become quarantined on the very pass that heals it.+- **Import chunk straddle accepted** (Q36, Q56). A Work's source and destination+  records sit in unrelated chunks under the archive's id order, so an interrupted+  import can leave the value on no library row. The archive is a durable second+  copy and re-running the import re-normalises; batching the pair would mean+  re-sorting the archive.+- **Save on a Work boundary, not per Work** (Q75). Req 3.11 asks that a Work's+  moves land together, which a batch boundary on a Work edge gives; a save per+  Work made a settling pass over hundreds of Works pay a round trip each.++---++## Expert Level++### Technical Deep Dive++**Resolver semantics.** `resolve` (`WrongHostWorkURLResolver.swift:117`)+classifies every membership, keyed on the **lowercased** hostname (Q65 — stored+hostnames are not normalised, `WorkURLPlanner.host(of:)` always lowercases, so+keying on the stored spelling left an `Example.com` row invisible to a value+naming `example.com` and would mint a second row in a different case that+de-duplication never folds).++- `blocked` starts as the hostnames holding an `.other` value (held, not an+  absolute HTTP(S) URL — a blank string included) and grows by least fixpoint+  along candidate edges. Iteration, not recursion, is what keeps a cycle with no+  malformed value *unblocked*, which is what makes it rotate.+- Candidates are de-duplicated on `(hostname, value)` (Q70), so torn rows holding+  one value under one hostname are one candidate and count once in+  `movedWorkURLs` — the apply step clears by value across the group anyway.+- The ordered pass emits `remove` when the destination holds an on-host address+  or is already claimed, `land` otherwise. A chain into an on-host address+  therefore resolves as remove-then-land, not as a block.++**Apply-step edge cases** (`MembershipReconciler.swift:464-570`):++- *Mint target* (Q61): the membership joins the first row in+  `GroupOrdering.sortedWorkRows` order that **already holds a membership**, not+  the representative. `representativeComponents` sorts a membership-less row+  first (empty primary hostname), so minting there would make the minted row that+  row's primary and move the site the Work is presented under, breaking Req 3.5.+  Only a group with no memberships at all falls back to the representative.+- *Abandoned land* (Q66): a row outside the `workID` reading — nil column, live+  relationship (Q58) — can already hold the destination hostname *and* a value.+  Minting beside it would leave de-duplication to fold the pair and possibly keep+  the row without the moved address, so the move is dropped: the source keeps its+  value and the Work stays reported.+- *Counting*: `moved` and `touched` are local until `wrote` is known true. A plan+  every one of whose lands was abandoned writes nothing and counts nothing —+  otherwise `report.isEmpty` would be false forever and the launch path would+  refresh on every pass.+- *Determinism*: plans are iterated in `UUID.bytesPrecede` order, because+  `Dictionary` iteration is per-process seeded and two devices must write the+  same library.++**Timestamp quantization** (Q67). `healCreatedAt` now runs its result through+`MillisecondInstant.quantize`. `latest.addingTimeInterval(0.001)` lands one ULP+from the same instant built through `MillisecondInstant`, and the archive's date+encoding quantizes on the way out — so the decoded value was a *different* `Date`+and `BackupV7Exporter`'s decode-validation refused the file it had just written.+This was pre-existing for the phase-1 missing-membership heal and was found by+the Req 4.2 round-trip test; a library holding **any** heal-minted membership+could not be backed up.++**No `Work` reference outlives an `enumerate` batch** (Q60, Q53). The scan needs+the representative row's `displayTitle`, and `GroupOrdering` reads a Work's+primary membership — so holding rows until after `enumerate` and sorting them+there would fault `siteMemberships` on models the batch has already released.+`GroupOrdering.keepRepresentative` (`GroupOrdering.swift:359`) folds each row into+a running `(orderKey, value)` inside the batch, keeping the value from the least+row seen, which is what `sortedWorkRows(_:).first` answers over a group held+whole. Ties fall to the row met first in fetch order (Q73).++**Performance.**++- `indexByWorkID` now reads three more scalars per membership and sorts groups —+  but only groups with more than one row build an `OrderKey`, so the common+  single-membership Work pays nothing beyond the extra column reads. This is on+  the Req 10.1 foreground-refresh budget.+- `UUID.bytesPrecede` avoids two `uuidString` allocations per comparison in+  `OrderKey.<`, which `sorted(by:)` calls O(n log n) times on the foreground scan+  and the arrival duplicate phase.+- The scan faults `Work` rows only for diagnosed `workID`s — proportional to+  findings, not to the library.+- The heal resolves first and fetches `Work` rows only for Works with something+  to write, chunked at `batchSize`.+- `DuplicateReconciler` propagation classifies the carrier's URLs **once** for the+  whole fan-out instead of re-parsing per row.++### Architecture Impact++- The tolerated set grows from three states to four. Every doc comment counting+  them was updated (`LibraryRepository.swift`, `+Bootstrap.swift`,+  `+RecentPresentation.swift`, `LibraryValidator.swift`).+- `library-integrity-tolerance` Decision 3's "import stays strict" boundary gains+  one named exception, recorded in the gate's own header comment+  (`LibraryRepository+BackupImportGates.swift:13-23`) as well as in the decision+  log. `multi-site-works` Q97 is superseded by this spec's Decision 1.+- `MembershipReconciler.run`'s `heals` parameter changes type from `Bool` to an+  `OptionSet`, so the two gated repairs are independently armed at the single+  production call site.+- No schema change, no archive-format change (Req 4.1, pinned at+  `WrongHostWorkURLCompatibilityTests.swift:44` and `:57`).+- `LibraryValidationError.wrongHostWorkURL` survives as the validator's internal+  throw and the strict gate's report; the tolerated case is a separate+  `LibraryDiagnosis`. Both surfaces exist in `MaintenanceViewModels` because a+  membership naming no Work stays the tuple failure (Q31).++### Potential Issues++1. **Un-masking is a live behaviour change beyond this feature's tests.** Q21+   accepts that `recordPostCommitDiagnosis` and `introducedDiagnosis` comparisons+   shift on hostnames where a real Site/Work/Entry finding was hidden behind the+   membership one. One test pins the un-masking itself+   (`WrongHostWorkURLValidatorTests.swift:206`); nothing pins the commit gates'+   new refusals, because they were already correct behaviour for a real finding.+2. **Hostname case is normalised in the heal and the diagnosis, not in the+   store.** Q65/Q68 lowercase every key the resolver, the pairs and the import+   pre-pass use, and `Work.memberships(in:forHostMatching:)` matches+   case-insensitively — but a minted membership is created with the **lowercased**+   destination hostname while a pre-existing row keeps its stored spelling.+   De-duplication still groups on the exact stored hostname+   (`MembershipReconciler.Key`), so a `Example.com` row and a lowercase minted row+   would not fold. The apply step avoids creating that pair (it finds the existing+   row case-insensitively first), but nothing repairs a mixed-case pair that+   arrives some other way.+3. **A blocked Work stays diagnosed indefinitely.** By design (Q38): a malformed+   value at a destination blocks the chain behind it, and Q14 keeps malformed+   values out of scope. The Check Library row will promise "Asterism will move+   the link on its next check" for a value it will never move. The reader's manual+   route is offered in the same sentence, so this is a wording risk rather than a+   correctness one.+4. **Import's sequential pass is order-sensitive by construction** (Q64). A chain+   `a→b, b→c` normalises `b` only. Pinned by+   `WrongHostWorkURLImportTests.swift:212`, and the heal closes the rest — but an+   import into a library that never runs a reconciliation pass keeps the residue.+5. **`willWriteMembership` now gates the whole commit loop**, not just the update+   branch (`LibraryRepository+ConfirmImport.swift:592`). It reads+   `existingMembershipIDs = Set(rowsByID.keys)` where the old code read+   `if let rows = rowsByID[record.id], !rows.isEmpty`. `Dictionary(grouping:)`+   never yields an empty value array, so the two agree — but the equivalence rests+   on that invariant rather than on the same expression.+6. **`UUID.bytesPrecede` replaces Q60's stated "stringify only inside `<`".** The+   orders are equivalent (uppercase hex digits sort in byte order, dashes are at+   fixed positions), and the code comment says why, but the decision log still+   describes the earlier shape.++---++## Completeness Assessment++Requirement text is in `requirements.md`. Paths below are repo-relative.++| Req | State | Evidence |+|---|---|---|+| 1.1 | fully | `LibraryRepository+ConfirmImport.swift:666` `normalizedMemberships` + `:732` `willWriteMembership`; tests `WrongHostWorkURLImportTests.swift:58`, `:131` |+| 1.2 | fully | `…+ConfirmImport.swift:719` (`.onHost` arm); test `WrongHostWorkURLImportTests.swift:116` |+| 1.3 | fully | `…+ConfirmImport.swift:721` (`.offHost`/`.other` → continue) and the destination-absent guard `:704`; tests `:75`, `:86`, `:101`, `:232`, `:374` |+| 1.4 | fully | `LibraryRepository+BackupImportGates.swift:77` `refusal`; `LibraryValidator.swift:912` divert; tests `WrongHostWorkURLImportTests.swift:275`, `:286`, `:308`, `:334` |+| 1.5 | fully | `DuplicateReconciler.swift:1345` `carriedURLs` with `WorkURLPlanner.isOnHost`; test `MultiSiteReviewFixTests.swift` "Torn-group propagation does not copy an off-host Work URL" |+| 1.6 | fully | `MembershipReconciler.swift:685` `carriedWorkURL` / `:700` `carryWorkURL`; dedupe `:603-615`; collapse `DuplicateReconciler.swift:808-813`; export `BackupArchiveProjection.swift:695-705`; tests in `MembershipReconcilerTests` (3), `CrossSiteDuplicateWorkloadTests` (3), `BackupV7ArchiveTests` (1) |+| 1.7 | fully | `…+ConfirmImport.swift:700` candidate sort; tests `WrongHostWorkURLImportTests.swift:171`, `:189`, `:212`. Narrowed by Q69: the survivor tie-break is not applied at import, because Q52 makes a per-hostname tie unreachable for a legal archive |+| 1.8 | fully | `…+ConfirmImport.swift:716` (`.empty` arm); tests `WrongHostWorkURLImportTests.swift:27`, `:40` |+| 2.1 | **partially (evidence)** | Code complete: `LibraryValidator.swift:250`, `:469`, `:912`; `quarantineMap` untouched. Tests `WrongHostWorkURLNotGatedTests.swift:40`, `:72`, `:100` and `LibraryDiagnosticsTests` "A wrong-host Work URL quarantines nothing". **But** the AC names the Work Merge candidate list and the Settings health line, and tasks.md task 6 asked for `WorkMergeModel` and `SettingsSyncModel.isHealthy` directly; the suite lives in Core and asserts `quarantineMap().isEmpty` as a proxy (`WrongHostWorkURLNotGatedTests.swift:46-49`) |+| 2.2 | fully | The finding no longer reaches `record()`; test `WrongHostWorkURLValidatorTests.swift:206` |+| 2.3 | fully | `LibraryDiagnostics.swift:228` `pairs`; validator `LibraryValidator.swift:483-495`; scan `LibraryDiagnostics.swift:635-642`, `:721-726`; tests `WrongHostWorkURLValidatorTests.swift:21`, `:48`, `:73`, `:96`, `:109`, `:233`, `:252` |+| 2.4 | fully | Both producers call `pairs`; tests `WrongHostWorkURLValidatorTests.swift:233`, `:265`, `WrongHostWorkURLNotGatedTests.swift:56` |+| 2.5 | fully | `MaintenanceViewModels.swift:291`, `:317`, `:370`, `:386`, `:427`; tests `Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift:13`, `:52` |+| 2.6 | fully | `LibraryDiagnostics.swift:392-425`; test `LibraryDiagnosticsTests` "A wrong-host Work URL counts one work record, and not twice" |+| 3.1 | fully | Resolver `.land`; apply `MembershipReconciler.swift:496`; tests `WrongHostWorkURLResolverTests.swift:46`, `MembershipReconcilerTests` "An off-host Work URL lands on the membership for its own host" |+| 3.2 | fully | Resolver `.remove`; tests `WrongHostWorkURLResolverTests.swift:58`, `MembershipReconcilerTests` "…removed where the destination is already addressed" |+| 3.3 | fully | Mint at `MembershipReconciler.swift:512-522`; tests `WrongHostWorkURLResolverTests.swift:71`, `MembershipReconcilerTests` "…mints one that is not the primary", "A mint joins the first row of a torn group…" |+| 3.4 | fully | `WrongHostWorkURLResolver.swift:117-173` whole; tests `WrongHostWorkURLResolverTests.swift:80`, `:94`, `:112`, `:128`, `:142`, `:159`, plus 400 generated cases at `:253` |+| 3.5 | fully | `MembershipReconciler.swift:476` (mint host selection) and `:336` `healCreatedAt`; tests `MembershipReconcilerTests` "…not the primary", "A mint joins the first row…" |+| 3.6 | fully | `MembershipReconciler.swift:522` `site: winners[destination]` (nil-tolerant); tests `MembershipReconcilerTests` (asserts `site` nil) and `WrongHostWorkURLCompatibilityTests.swift:83` |+| 3.7 | fully | Gate `LibraryRepository.swift:295`, wiring `:367`; phase order `MembershipReconciler.swift:127-141`; tests `MembershipReconcilerTests` "The heal does not run when the pass is not asked for it", "A value the open's validation found is healed on the launch pass", "A value arriving after the last refresh is healed on the pass after the next" |+| 3.8 | fully | `#expect(try store.reconcile().isEmpty)` after every heal case in `MembershipReconcilerTests`; resolver invariant `resolve(apply(resolve(x)))` in the generated suite |+| 3.9 | fully | `movedWorkURLs` in `MembershipReconcileReport.isEmpty` (`MembershipReconciler.swift:52`); tests "A value the open's validation found is healed on the launch pass", "Both hostnames are touched and neither is quarantined after the pass" |+| 3.10 | **partially (evidence)** | The report carries counts and hostnames only (`MembershipReconciler.swift:39`, `:44`) and the log line at `:155-160` interpolates only counts — structurally there is no title or URL to leak. No test asserts the log line's text; tests assert the report fields |+| 3.11 | fully | Destinations-before-sources in the apply loop (`MembershipReconciler.swift:491-555`); save cadence `:564-570`; test `MembershipReconcilerTests` "The heal saves on Work boundaries". Q75 relaxed "one save per Work" to "no Work straddles a save" |+| 4.1 | fully | Tests `WrongHostWorkURLCompatibilityTests.swift:44`, `:57`; the golden export test is unchanged |+| 4.2 | fully | Test `WrongHostWorkURLCompatibilityTests.swift:83`; enabled by Q67's `MillisecondInstant.quantize` in `healCreatedAt` |++**Nothing is missing.** Two requirements are marked partial on *evidence* rather+than on code: 2.1 (two of its five named consequences are pinned by a proxy+assertion rather than by exercising the app-layer models the AC names) and 3.10+(the log line's content is unasserted).++### Divergences from `design.md`++All of these are covered by a decision-log entry except where noted.++| Divergence | Covered by |+|---|---|+| Save cadence: `batchSize` Works per save, not one save per Work | Q75 |+| Resolver snapshot carries `OrderKey`, not `rowID` | Q24, Q60 |+| Import candidates ordered on the lowercased hostname only, no survivor tie-break | Q68, Q69 |+| `keepRepresentative` in `GroupOrdering` instead of holding rows past `enumerate` | Q60, Q53 |+| `distinctRecordCount` skip narrowed to `.siteMissing` with `workCount > 0` | Q59 |+| The import pre-pass is an internal **pure static** (`normalizedMemberships`) taking `existingMembershipIDs`/`appliedWorkIDs`, rather than an inline pre-pass reading `rowsByID` | Not in the decision log. Q44 fixes the *location*; the extraction for testability is explained in the function's own doc comment |+| `carriedWorkURL`/`carryWorkURL` gained an `inSurvivorOrder` flag | Not in the decision log; explained in the doc comment as an "already sorted" optimisation |+| `UUID.bytesPrecede` compares bytes rather than stringifying inside `<` | Q60 states the stringify form; the code comment states the byte form and its equivalence |+| `willWriteMembership` gates the whole `commitMemberships` record loop, not only the update branch | Not in the decision log; explained in a code comment at `…+ConfirmImport.swift:592` |
specs/wrong-host-work-url-heal/requirements.md Modified +85 / -0
diff --git a/specs/wrong-host-work-url-heal/requirements.md b/specs/wrong-host-work-url-heal/requirements.mdnew file mode 100644index 0000000..994d113--- /dev/null+++ b/specs/wrong-host-work-url-heal/requirements.md@@ -0,0 +1,85 @@+# Requirements: Wrong-Host Work URL Heal++## Introduction++A Work's site membership can hold a confirmed Work URL whose host is not the membership's hostname — a value written by builds that predate per-hostname Work URLs, or carried in by a backup or sync from such a build. Today the library treats it as an illegal site tuple: the hostname is quarantined, every capture on it skips rule application, and the only repair is a manual edit. This feature closes the remaining ways such a value can enter (backup import and torn-group propagation), stops it from quarantining a site, and repairs it automatically by moving the address to the membership for the site it is actually on.++Terms: a *torn group* is a Work stored as more than one row sharing the Work's id (`multi-site-works`); *membership de-duplication* keeps one row per `(Work, hostname)` pair; the *full validation* is the whole-library check that runs on open; the *foreground pass* is the cheaper derivation of tolerated states that runs on foreground and after the reconciliation passes.++## Preserved Behaviour++These hold today and are relied on; they are not changed by this feature.++- The Work URL field refuses an address whose host is not the selected site's hostname before saving, naming both hosts and leaving the text in place (`multi-site-works` Q66).+- The library refuses a Work URL commit whose address is not on the membership's host and writes nothing.+- The post-teaching Work URL flow confirms only addresses derived from the site's own entries.+- Recent derives its site-level attention labels from the Site row's own rule state, so a membership-level finding produces none.++## Non-Goals++- Treating `m.<domain>` and `www.<domain>` as one site: hostnames stay exact keys everywhere.+- Creating a Site row from a Work URL: the Site row for a newly named host arrives the way it always has (on capture).+- A new reader-facing notice for repairs: the heal is silent, as every existing reconciliation pass is.+- Running the heal inline on every sync arrival: it runs within the existing reconciliation passes.+- Behaviour of devices still on an older build: they keep their quarantine until upgraded.+- A Check Library button that performs the repair: the row keeps the existing Open-work route and tells the reader how to fix it by hand.+- A new Recent attention label for this state: it is counted in Recent's affected-record banner like every diagnosis, and Check Library is where it is described.+- A membership Work URL that is not an absolute HTTP(S) URL: it stays the illegal tuple it is today.+- A membership that names no Work: it stays the tuple failure it is today, whatever its Work URL.++## Requirements++### 1. No Writer Creates or Loses an Off-Host Work URL++**User Story:** As a reader, I want no path into or through my library to produce a Work URL on the wrong site's membership or to drop one that was preserved, so that the repair below is only ever for values that arrived from older builds.++**Acceptance Criteria:**++1. <a name="1.1"></a>Import normalises an off-host membership Work URL only where this import will write the destination record for that host (inserting it, or updating it because its Work is one the import applies) so that the value that record will hold after the import (the archive's) is known; wherever that is not so, import SHALL import the source record unchanged for the heal to place, and never import it without its URL+2. <a name="1.2"></a>WHEN a backup archive holds a membership whose Work URL is on another host AND the destination record for that host will hold a Work URL on its own host after the import, THEN import SHALL keep the destination's value and import the source membership without a URL  +3. <a name="1.3"></a>WHEN a backup archive holds a membership whose Work URL is on another host AND neither the archive nor the library holds a membership of the same Work for that host, THEN import SHALL import the record unchanged and the library SHALL report it as the state in Requirement 2 after the import  +4. <a name="1.4"></a>The import plan gate SHALL NOT refuse an archive on the sole ground of an off-host membership Work URL on a membership that names a Work, and WHEN it refuses an archive for a membership finding, THEN its message SHALL name the membership record and its hostname rather than calling the Site illegal  +5. <a name="1.5"></a>WHEN torn-group propagation would copy a membership Work URL onto another row's membership for the same hostname, THEN it SHALL copy only a value whose host is that hostname  +6. <a name="1.6"></a>WHEN rows for one `(Work, hostname)` pair are folded to one survivor — by membership de-duplication, by a merge or duplicate resolution collapsing memberships, or by backup export — AND a discarded row holds a Work URL while the survivor holds none, THEN the survivor SHALL carry that URL whether or not it is on that hostname — the first by [3.4](#34)'s order where more than one discarded row holds one — and an off-host value so carried remains diagnosed for the heal  +7. <a name="1.7"></a>WHEN an archive holds off-host values on more than one membership of one Work, THEN import SHALL apply [1.8](#18) and [1.2](#12) only to a value whose destination record will hold, after the import, no value or an on-host value; taking the values in [3.4](#34)'s order so that two naming one host place the first and strip the second; every other such value SHALL be imported unchanged for the heal to place  +8. <a name="1.8"></a>WHEN a backup archive holds a membership whose Work URL is on another host AND the destination record for that host will hold no Work URL after the import, THEN import SHALL place the URL on that record and import the source membership without a URL  ++### 2. An Off-Host Work URL Never Quarantines a Site++**User Story:** As a reader, I want one Work's wrong link to have no effect on how the app treats the site's captures, so that a single stale value cannot take the site's rules away from every capture.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHEN a membership that names a Work holds a Work URL that is an absolute HTTP(S) URL on a host other than the membership's hostname, THEN the library SHALL report a diagnosis for it AND the hostname SHALL NOT be quarantined: capture and re-parse apply the site's rules, Merge is available for its Works, the Work Merge candidate list includes them, the Settings health line does not count it as unresolved damage, and no reader action on the hostname is refused on the ground of this diagnosis  +2. <a name="2.2"></a>WHEN a hostname also carries another membership, Work or Entry tuple finding that this diagnosis previously stood in front of, THEN that finding SHALL be reported as the hostname's tuple diagnosis  +3. <a name="2.3"></a>There SHALL be one such diagnosis per Work, identified by the Work's id, whichever and however many of the Work's rows and memberships hold such a value; it SHALL name each affected membership's hostname with the host its URL is on — one pair per hostname, in hostname order, naming for each hostname the host on the value that is first in [3.4](#34)'s order — and the Work's id, and SHALL name the display title of the row the Work is presented from where the Work has arrived, so that both derivations produce the same diagnosis for the same store  +4. <a name="2.4"></a>The diagnosis SHALL be produced both by the full validation and by the foreground pass, and combining the two SHALL never drop it, so that it is reported identically after open, after a foreground refresh, and after a relaunch, until the value is repaired  +5. <a name="2.5"></a>The Check Library row for the diagnosis SHALL name the Work by its display title, or by "a work" and its id where the Work has not arrived, SHALL name each host an address is on, SHALL offer the existing "Open work" route (where the Work has arrived) and no re-teach route, and SHALL say both that Asterism will move the address to its own site and that the reader can confirm an address on this site or clear it now  +6. <a name="2.6"></a>The diagnosis SHALL count as one Work record in the Check Library headline and in Recent's affected-record banner; a Work already counted by a `.siteMissing` diagnosis for one of the hostnames this diagnosis names SHALL NOT be counted again for it  ++### 3. The Address Is Moved to the Site It Is On++**User Story:** As a reader, I want a Work URL that is on another site to end up on that site's membership by itself, so that the Work links to the right place without my intervention.++**Acceptance Criteria:**++1. <a name="3.1"></a>WHEN the diagnosed Work has a membership for the host the URL is on AND it holds no Work URL, THEN the heal SHALL set that membership's Work URL to the value and remove the value from every row of the Work that holds it on the source hostname  +2. <a name="3.2"></a>WHEN the diagnosed Work has a membership for the host the URL is on AND it already holds a Work URL on its own host, THEN the heal SHALL keep the destination's value and remove the value from every row of the Work that holds it on the source hostname  +3. <a name="3.3"></a>WHEN the diagnosed Work has no membership for the host the URL is on, THEN the heal SHALL add one membership for that host on the Work, holding the value with no URL identity, and SHALL remove the value from every row of the Work that holds it on the source hostname  +4. <a name="3.4"></a>WHEN a Work holds off-host Work URLs on more than one membership or row, THEN the heal SHALL compute every move from one reading of the Work taken before any write and apply them together in the one save of [3.11](#311), with a single precedence: a membership's on-host value is never replaced; a value moves onto a membership only where that membership's field will be empty after this pass — so a membership holding a value that is neither on-host nor an off-host absolute HTTP(S) URL, or holding an off-host value that is itself blocked, blocks the move onto it, and the blocked source stays in place and reported; otherwise an off-host value moves to the membership for the host it names, or is removed where that membership holds an on-host value. Under that reading a cycle of off-host values of any length SHALL rotate so each ends on its host, and two off-host values naming one host SHALL leave on it the one that is first by source hostname ascending, then by the same order membership de-duplication already keeps a survivor by (identity state, creation time, id) — an order that is the same on every device — and SHALL remove the other from its source  +5. <a name="3.5"></a>A membership added by the heal SHALL NOT become the Work's primary membership: the site the Work is presented under SHALL be the same before and after the heal  +6. <a name="3.6"></a>The heal SHALL NOT create a Site row; a membership added for a host with no Site row SHALL be reported and completed the way a membership arriving ahead of its Site row already is  +7. <a name="3.7"></a>The heal SHALL run within the existing post-sync, launch and duplicate reconciliation passes, before membership de-duplication and before torn-group propagation, and only when the most recent derivation of tolerated states — the full validation on open included — reported at least one such diagnosis; a value that arrives after that derivation is healed on the reconciliation pass that follows the next one  +8. <a name="3.8"></a>Running the reconciliation passes twice over the same library SHALL write nothing the second time  +9. <a name="3.9"></a>WHEN the heal has moved or removed a value, THEN that pass SHALL count as having changed the library, and for a Work every one of whose off-host values was placed or removed the diagnosis SHALL no longer be reported after the foreground pass that follows it, without a relaunch — on the launch pass as on the others; a Work with a blocked value stays reported  +10. <a name="3.10"></a>The heal's log and report SHALL carry counts and hostnames only — no Work titles and no URLs  +11. <a name="3.11"></a>At no point observable after a save SHALL a value the heal is moving be absent from every row of the Work: a Work's moves land in one save, with the destination holding each value before the source loses it ([2.6](#26) relies on this)  ++### 4. Compatibility++**User Story:** As a reader with the app on several devices and old backups, I want this repair to need no migration, so that nothing else about my library changes.++**Acceptance Criteria:**++1. <a name="4.1"></a>The feature SHALL introduce no schema change and no change to the backup archive format  +2. <a name="4.2"></a>WHEN a library holds a membership the heal added for a host with no Site row AND that library is exported and re-imported, THEN the result SHALL hold a membership for that host on that Work with the same Work URL  
specs/wrong-host-work-url-heal/tasks.md Modified +164 / -0
diff --git a/specs/wrong-host-work-url-heal/tasks.md b/specs/wrong-host-work-url-heal/tasks.mdnew file mode 100644index 0000000..bc6615f--- /dev/null+++ b/specs/wrong-host-work-url-heal/tasks.md@@ -0,0 +1,164 @@+---+references:+    - specs/wrong-host-work-url-heal/requirements.md+    - specs/wrong-host-work-url-heal/design.md+    - specs/wrong-host-work-url-heal/decision_log.md+---+# Wrong-Host Work URL Heal++## Diagnosis++- [x] 1. Add the tolerated diagnosis case, basis column, option set and report counter <!-- id:31jdrmw -->+  - LibraryDiagnostics.swift: `WrongHostMembership` (Comparable by hostname) and `LibraryDiagnosis.wrongHostWorkURL(workID:workTitle:memberships:)`; extend id (`wrongHostWorkURL:<workID>`), hostname (first source), clearableByReteaching (false), recordCount (1), caseRank, payloadKey (joined pairs), distinctRecordCount (`wrongHostWorkURLWorks` set, skipped when a `.siteMissing` names one of its source hostnames); quarantineMap unchanged+  - Models.swift: `WorkMembershipBasis.workURLString`, populated in `indexByWorkID`+  - MembershipReconciler.swift: `Heals` OptionSet (`missingMembership`, `wrongHostWorkURL`, `all`), `run(... heals: Heals = .all)`, `MembershipReconcileReport.movedWorkURLs` included in `isEmpty`; adapt the one production caller and test callers+  - LibraryRepository.swift: derived `healsWrongHostWorkURL` over `diagnostics.diagnoses` (Q46); `reconcileAfterSync` passes the option set+  - LibraryToleranceScan result gains `wrongHostWorkURLCount` (0 until task 3)+  - Stream: 1+  - Requirements: [2.3](requirements.md#2.3), [2.6](requirements.md#2.6), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 2. Write failing validator tests for the tolerated diagnosis in both arms <!-- id:31jdrmx -->+  - New `WrongHostWorkURLValidatorTests` beside `WrongHostWorkURLDiagnosisTests` using `UnreadableRuleFixture`/`ValidatorFixtures`: `validate(graph:)` yields the tolerated case keyed on the workID column, title from `GroupOrdering.sortedWorkRows` first over a torn group with differing titles, `workTitle == nil` for an unarrived Work; `quarantineMap()` empty; `validate(hostnames:)` over the hostname records nothing for it; a value that is not an absolute HTTP(S) URL stays `.siteTuple` in both arms and in `validateStrict`; nil-`workID` membership stays `.siteTuple`; an Entry-arm finding on the same hostname becomes the tuple diagnosis+  - Amend `WrongHostWorkURLDiagnosisTests` (T-2289) to the new expectations: no quarantine, `confirmingAnAddressOnTheSiteClears` still true via the foreground pass+  - Blocked-by: 31jdrmw (Add the tolerated diagnosis case, basis column, option set and report counter)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 3. Implement the validator producers to pass the tests <!-- id:31jdrmy -->+  - LibraryValidator.swift `run(graph:)` membership loop (~:424-430): catch `.wrongHostWorkURL`, divert only when `actualHost != nil` and the membership `workID` column is non-nil (Q50); accumulate per Work; append one tolerated diagnosis per Work after the loop with the representative row title (Q53)+  - `validate(hostnames:context:)` membership arm (~:172-240): same catch and conditions; drop the case (Q47)+  - Fix the stale "three call sites" comment at `LibraryValidator.swift:317`+  - Blocked-by: 31jdrmx (Write failing validator tests for the tolerated diagnosis in both arms)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 4. Write failing scan and union tests for the foreground producer <!-- id:31jdrmz -->+  - In `LibraryToleranceScanTests`/`LibraryDiagnosticsTests`: scan emits the same diagnosis as `validate(graph:)` for the same store (one Work; two Works on one site; torn rows disagreeing — pair names the host on the value first in the heal's order, Q35; unarrived Work → title nil); `wrongHostWorkURLCount`; `refreshDiagnostics` keeps it after a refresh; `affectedRecordCount` with and without a `.siteMissing` on a source hostname (2.6)+  - Blocked-by: 31jdrmw (Add the tolerated diagnosis case, basis column, option set and report counter)+  - Stream: 1+  - Requirements: [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.6](requirements.md#2.6)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 5. Implement the scan producer and refresh survival <!-- id:31jdrn0 -->+  - LibraryDiagnostics.swift `LibraryToleranceScan.scan`: derive pairs from `membershipsByWorkID` using `WorkURLPlanner.host(of:)`; collect diagnosed workIDs before the `Work` enumerate and take the representative row's `displayTitle` there (no relationship fault, Q53); emit diagnoses and `wrongHostWorkURLCount`+  - `refreshDiagnostics`: nothing to latch — the derived gate reads the merged diagnostics+  - Blocked-by: 31jdrmz (Write failing scan and union tests for the foreground producer), 31jdrmy (Implement the validator producers to pass the tests)+  - Stream: 1+  - Requirements: [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.6](requirements.md#2.6)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 6. Write tests that a diagnosed hostname is not gated anywhere <!-- id:31jdrn1 -->+  - Over a store whose only finding is the tolerated case: capture/re-parse applies the site's rules; `mergeDestinations` is not refused and `WorkMergeModel` (reads `quarantineMap()`) lists the Work; `SettingsSyncModel.isHealthy`; no code change expected — the tests pin the consequence+  - Blocked-by: 31jdrn0 (Implement the scan producer and refresh survival)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++## Heal++- [x] 7. Write resolver unit and generated-input tests <!-- id:31jdrn2 -->+  - New `WrongHostWorkURLResolverTests`: move to empty; destination on-host → removed; mint (no membership for host); 2-cycle swap; 3-cycle rotation; two-to-one → first by (hostname, survivorFirst) lands, other removed; chain into an *other* value → all stay; chain into an on-host value → removed then land; torn rows disagreeing on one source+  - Generated cases (Swift Testing parameterised over a seeded generator, 2–6 hostnames, empty/on-host/movable/other, random torn duplication): every land destination unblocked and unclaimed; no value dropped except by remove with an on-host or earlier-landed destination; `resolve(apply(resolve(x)))` has no land/remove; output independent of input order+  - Blocked-by: 31jdrmw (Add the tolerated diagnosis case, basis column, option set and report counter)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.4](requirements.md#3.4), [3.8](requirements.md#3.8)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 8. Implement WrongHostWorkURLResolver <!-- id:31jdrn3 -->+  - New `Packages/AsterismCore/Sources/AsterismCore/WrongHostWorkURLResolver.swift` (internal, pure): `WorkSnapshot`, `Move.land(value:from:to:)/remove(value:from:)/stay(hostname:)`, blocked closure as least fixpoint from *other* values, ordered pass with per-destination claim (Q49); candidate order `(sourceHostname ASC, survivorFirst)`+  - Blocked-by: 31jdrn2 (Write resolver unit and generated-input tests)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.4](requirements.md#3.4), [3.8](requirements.md#3.8)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 9. Write failing reconciler heal tests <!-- id:31jdrn4 -->+  - In `MembershipReconcilerTests` using `ReconcileStore`: land on existing survivor across the workID group; mint with `site` nil, `urlIdentityState .none`, `createdAt` = `healCreatedAt`, primary unchanged, minted row visible to `dedupeMemberships` in the same run; source cleared on every row by exact value; one save per Work (spy `RepositorySaveStrategy`); second run writes nothing; report `movedWorkURLs` and hostnames only; `touchedHostnames` holds both; hostname not quarantined after the pass; phase skipped when `heals` lacks `.wrongHostWorkURL`; unarrived Work skipped+  - Blocked-by: 31jdrn3 (Implement WrongHostWorkURLResolver), 31jdrn0 (Implement the scan producer and refresh survival)+  - Stream: 1+  - Requirements: [3.3](requirements.md#3.3), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 10. Implement the heal phase in MembershipReconciler <!-- id:31jdrn5 -->+  - MembershipReconciler.swift: new phase between `heal` and `dedupeMemberships`; group rows by `workID` column, skip `unarrivedWorkIDs`; fetch Site winners with the helper phase 1 uses; per Work: snapshot → resolver → apply (lands first onto `Work.membership(in:for:)` over the group or a mint on `GroupOrdering.sortedWorkRows` first; then clear by exact value on source hostnames) → one save; append mints to the row list dedupe sees; `report.movedWorkURLs`, `touchedHostnames`; extend the existing debug line+  - Blocked-by: 31jdrn4 (Write failing reconciler heal tests)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.3](requirements.md#3.3), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 11. Write failing gate and launch-clearing tests <!-- id:31jdrn6 -->+  - Repository-level (pattern of `seedMembershiplessWork` in `MembershipReconcilerTests`): a library whose only report is the open's full validation heals on the launch `reconcileAfterSync`; a value arriving after the last refresh is healed on the pass following the next refresh; `reconcileAfterSync` outcome is non-empty after a URL-only heal; `quarantineReason` nil throughout+  - Blocked-by: 31jdrn5 (Implement the heal phase in MembershipReconciler)+  - Stream: 1+  - Requirements: [3.7](requirements.md#3.7), [3.9](requirements.md#3.9)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 12. Wire the derived gate through reconcileAfterSync and confirm launch behaviour <!-- id:31jdrn7 -->+  - LibraryRepository.swift `reconcileAfterSync` (~:377): `heals: [worksWithoutMembership ? .missingMembership : [], healsWrongHostWorkURL ? .wrongHostWorkURL : []]`; verify `AppLibraryModel.runLaunchReconcile` refreshes on the non-empty outcome; no new latch+  - Blocked-by: 31jdrn6 (Write failing gate and launch-clearing tests)+  - Stream: 1+  - Requirements: [3.7](requirements.md#3.7), [3.9](requirements.md#3.9)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 13. Write failing fold-carry and propagation tests <!-- id:31jdrn8 -->+  - `MembershipReconcilerTests`: dedupe carries a loser's URL (on-host and off-host) onto an empty survivor, survivor with a value unchanged, first by survivorFirst among several losers; `DuplicateReconcilerTests`: `collapseMemberships` delete arm carries; propagation does not copy an off-host carrier value; `BackupArchiveProjection` tests: export fold projects the carried URL without dirtying the context+  - Blocked-by: 31jdrmw (Add the tolerated diagnosis case, basis column, option set and report counter)+  - Stream: 1+  - Requirements: [1.5](requirements.md#1.5), [1.6](requirements.md#1.6)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 14. Implement carriedWorkURL at the three folds and the propagation guard <!-- id:31jdrn9 -->+  - `MembershipReconciler.carriedWorkURL(survivor:losers:) -> String?` (Q54); assign in `dedupeMemberships`' grouping loop (before losers are flattened) and in `DuplicateReconciler.collapseMemberships`' delete arm; use the value in `BackupArchiveProjection.mapMembershipRecords`; add `WorkURLPlanner.isOnHost` to the propagation guard at `DuplicateReconciler.swift:~1317`+  - Blocked-by: 31jdrn8 (Write failing fold-carry and propagation tests)+  - Stream: 1+  - Requirements: [1.5](requirements.md#1.5), [1.6](requirements.md#1.6)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++## Import and export++- [x] 15. Write failing import pre-pass and plan-gate tests <!-- id:31jdrna -->+  - In a new `WrongHostWorkURLImportTests` (pattern of the backup import gate tests): each cell — destination inserted / updated-applied / update-skipped / absent / off-host / on-host; source update skipped; nil-`workID` record untouched; multi-value order (first by candidate order lands, second stripped); plan gate accepts an archive whose only finding is the tolerated shape; a nil-Work off-host membership still refuses; refusal for another membership finding names the membership id and hostname+  - Blocked-by: 31jdrmw (Add the tolerated diagnosis case, basis column, option set and report counter)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 16. Implement the import pre-pass and the plan-gate wrapper <!-- id:31jdrnb -->+  - LibraryRepository+ConfirmImport.swift `commitMemberships`: pre-pass before the chunk loop rebuilding `BackupV7Membership` values through the memberwise init (`let` properties) — destination written iff no row for its id or Work ∈ `appliedWorkIDs`; precedence per Q23/Q30/Q32; nil-`workID` records untouched; chunking unchanged (Q56)+  - LibraryRepository+BackupImportGates.swift `validateImportPlanPayload`: switch on the map's error — `invalidStateTuple(type: "WorkSiteMembership")` or `wrongHostWorkURL` rethrown as `type: "WorkSiteMembership"` with the membership id and hostname; others keep the `Site` wrapper+  - Blocked-by: 31jdrna (Write failing import pre-pass and plan-gate tests), 31jdrmy (Implement the validator producers to pass the tests)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 17. Write compatibility tests: schema/archive unchanged and heal-minted membership round-trip <!-- id:31jdrnc -->+  - Existing golden export test passes unchanged; a library holding a heal-minted membership for a host with no Site row exports (synthesised untaught wire Site) and re-imports with the membership and URL intact; fix export only if the round-trip fails+  - Blocked-by: 31jdrn9 (Implement carriedWorkURL at the three folds and the propagation guard), 31jdrn5 (Implement the heal phase in MembershipReconciler)+  - Stream: 2+  - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++## Surface and records++- [x] 18. Write failing Check Library row tests for the tolerated case <!-- id:31jdrnd -->+  - `Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift`: row site = first source hostname; problem and resolution per Q45 (title, or "A work" + id prefix when unarrived); detail lists the pairs; `reteachHostname` nil; `workID`/`workTitle` set so the Open-work route shows; `recordCountText` "1 work record affected"; the nil-Work `.siteTuple` row keeps its T-2289 wording+  - Blocked-by: 31jdrmw (Add the tolerated diagnosis case, basis column, option set and report counter)+  - Stream: 3+  - Requirements: [2.5](requirements.md#2.5)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 19. Implement the Check Library row for the tolerated case <!-- id:31jdrne -->+  - `MaintenanceViewModels.swift`: `problem`/`resolution`/`detail`/`workNamed(by:)`/`recordCountText` for `.wrongHostWorkURL`; keep the `.siteTuple` `isWrongHostWorkURL` branch for the nil-Work case+  - Blocked-by: 31jdrnd (Write failing Check Library row tests for the tolerated case)+  - Stream: 3+  - Requirements: [2.5](requirements.md#2.5)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md++- [x] 20. Record the supporting decision-log amendments and the changelog entry <!-- id:31jdrnf -->+  - `specs/multi-site-works/decision_log.md` Q97 → "superseded by wrong-host-work-url-heal Decision 1"; `specs/library-integrity-tolerance/decision_log.md` Decision 4 amendment (fourth tolerated state) and Decision 3 note (import exception, this spec's Decision 3); `BackupImportGates.swift` header comment; CHANGELOG Fixed/Changed entry; `docs/agent-notes/testing.md` if a new fixture pattern was introduced+  - Blocked-by: 31jdrn7 (Wire the derived gate through reconcileAfterSync and confirm launch behaviour), 31jdrnb (Implement the import pre-pass and the plan-gate wrapper), 31jdrne (Implement the Check Library row for the tolerated case)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1)+  - References: specs/wrong-host-work-url-heal/requirements.md, specs/wrong-host-work-url-heal/design.md, specs/wrong-host-work-url-heal/decision_log.md

Things to double-check

make test-performance-m4 was not run for this branch

The ~21-minute host-only M4 target is not part of the pre-commit bar and there is no verification-run.md for this spec. Two paths on measured budgets changed: WorkSiteMembership.indexByWorkID now reads three more scalars per membership and sorts every multi-row group (Req 10.1's foreground refresh), and the merged T-2295 fix adds a per-duplicated-hostname predicate fetch to the same walk. UUID.bytesPrecede and the build-keys-only-for-multi-row-groups rule are the mitigations; neither has been measured. Remember the suite is not reproducible from a single run (percentile(of:) returns the second-slowest of 20), so a band needs three runs.

Hostname case, end to end

Worth a careful read as one story: the resolver keys on lowercased hostnames, Work.memberships(in:forHostMatching:) matches case-insensitively, WrongHostMembership.pairs lowercases, the import pre-pass lowercases — but MembershipReconciler.Key (de-duplication) and Work.membership(for:) still compare stored hostnames exactly, and a minted row is written lowercased. Confirm that no path can produce an Example.com/example.com membership pair on one Work that nothing folds, including via sync from a device on an older build.

First-launch heal over a large residue library

The heal has only been exercised over small on-disk fixtures. On a real library with hundreds of diagnosed Works the launch pass will: fetch the whole membership table (pre-existing), build a plan per Work, chunk-fetch the Work rows for planned Works, and save every batchSize Works. Worth confirming on a device-sized library that the launch reconcile stays inside its budget and that the diagnosis actually clears on the following foreground pass — and note that the app's own Development/Personal installs both mirror to CloudKit, so any device trial needs the usual explicit approval.

Un-masked findings changing commit-gate behaviour

Q21 accepts that removing the membership finding from the tuple map exposes Site, Work and Entry findings it stood in front of, and that recordPostCommitDiagnosis / introducedDiagnosis may now refuse a commit that previously passed. One test pins the un-masking itself; nothing pins the downstream gates. On a real library carrying both a wrong-host URL and a genuine tuple failure on the same hostname, the first upgrade could surface a quarantine the reader has never seen — worth deciding whether that needs a release note.

The commit-loop gate refactor in commitMemberships

willWriteMembership is now called before the insert/update branch (LibraryRepository+ConfirmImport.swift:592) rather than the old guard living inside the update branch. It tests existingMembershipIDs.contains(record.id) where the old code tested if let rows = rowsByID[record.id], !rows.isEmpty. Dictionary(grouping:) cannot produce an empty value array, so the two agree — but the shared predicate is load-bearing in two places now (the pre-pass and the loop), and any future change to how rowsByID is built has to preserve that invariant.

Open questions from the explanation pass.
  • tasks.md task 6 asked for tests exercising WorkMergeModel's candidate list and SettingsSyncModel.isHealthy directly (Req 2.1 names both). The suite landed in AsterismCore and asserts quarantineMap().isEmpty as a proxy, with a comment saying that is what both read (WrongHostWorkURLNotGatedTests.swift:46-49). No decision-log entry or commit body records why the app-target assertions were dropped.
  • A heal-minted membership is created with the lowercased destination hostname while pre-existing rows keep their stored spelling, and dedupeMemberships still groups on the exact stored hostname. Q65 explains why the heal must not mint a second differently-cased row, but nothing records what is expected to happen to a mixed-case pair that arrives by some other route (sync from an older build, an import).
  • Req 3.10 asks that the heal's log carry counts and hostnames only. The report fields are asserted, and structurally the log line interpolates only integers — but no test pins the log line's content, and no decision records that assertion as deliberately out of scope.