Round 3 of the pre-push review for PR #62. Round 2 verified the code and returned Needs fixes for prose only; commit c9505a9 answered it with a comment-only diff. This round re-verifies the code, checks each round-2 item against the tree, and runs the full AsterismCore suite with JUnit and coverage. Reviewed as git diff origin/main...HEAD (merge base 30c1573), four commits.
removeSiteMembership now calls publishPostCommitDiagnoses(across: hostnames, in: diagnoses) after its save, over the same hostname set it validated and gated on — a repaired hostname is un-quarantined immediately instead of at the next launch (LibraryRepository+Sites.swift:186-199).commitWorkURL, commitMerge, commitResolution validate through the hostname-scoped overload; the efficiency agent confirmed the narrowing is at the SQL level (predicated fetches), not in memory. The two teaching commits deliberately stay on the whole-library pass.introducedDiagnosis(across:in:); the priorDiagnosis pre-read is gone. Verified equivalent: the closure is synchronous and only recordPostCommitDiagnosis writes the latch, after the save..profraw files (SwiftPM wrote no default.profdata after the failing run).+WorkMerge.swift:128.report.md:76-83/:312 and LibraryValidator.swift:176; change "the one arm" to point at both arms in +WorkMerge.swift:128.deleteWork → commitWorkDeletion in the report (3 places); the stale "two passes" sentence in the refreshDiagnostics doc; a CHANGELOG Fixed entry; StoreMetadataTests in testing.md's flake family; follow-up tickets for scoping the teaching gates and a shared gate helper.Needs fixes
1 failing test —
The code is correct and verified again this round; what remains is prose, and it is two lines. Every gate line the branch touched executed under the suite (the new +Sites.swift:199 publish ran 3 times, the three scoped validator calls 13–18 times each), all four agents and a direct read confirm the teaching-gate refactor is behaviour-preserving (the withLockedContext body is synchronous and no writer of quarantined is reachable before the gate), and the regression test asserts the latch, the carried-forward tuple set and a post-refreshDiagnostics() latch. The one test failure is the documented store-digest flake (BootstrapClassifierTests.swift:76, store=fullFamily/seededVersion=atOrAboveV5), green on an isolated re-run (15 tests, exit 0), and it is fixed at source on sibling PR #63.
Round-2 items: (a) backup-export claim — resolved; (b) "every commit gate is scoped" — resolved in the validator doc and Q122; the minors — Q110 citation, Ticket header, test-gap follow-ups — resolved. Two did not land cleanly. The T-2289 history was reworded into a misattribution: the report with Follow-ups (a)–(d) that the branch now calls "T-2289's" (report.md:76-83, Related :312, and LibraryValidator.swift:176 "T-2289 follow-up (c)") is T-1956's (7006b91, PR #43, merged 2026-08-29 08:13 — 21 minutes before T-2289's 0c4f0e8/#40), and its follow-up (c) names commitWorkURL alone, not "the other gates". Round 2's own finding text carried the wrong ticket, so the author followed the reviewer; it is still wrong in the tree. And "the one divergence" survives in one place round 2 did not cite: LibraryRepository+WorkMerge.swift:128 still says "the one arm where the two derivations disagree (a malformed membership on a Site-less hostname)" while the overload doc it points the reader to now lists two. Both are comment/report edits; no Swift statement changes. Everything else below is optional.
Pass rate: 100% (2315 of 2316)
New tests: 0
Diff coverage: 100% (74 of 74 added lines)
725e8c3 T-2291: Add investigation report and failing regression tests 71c04a4 T-2291: Publish post-commit diagnoses from removeSiteMembership 876961d T-2291: Pre-push review fixes — fold the regression into the existing suite c9505a9 T-2291: Round-2 review fixes — prose accuracy in the report, doc and Q122 Asterism keeps a list of websites ("hostnames") whose saved rules look broken, so it can stop applying those rules until someone fixes them. That list is called the quarantine. Every action that edits a site's data checks its own work afterwards ("did I just make this site broken, or did I fix it?") and is supposed to update the list with the answer.
One action — removing a site from a Work (a book, a series) — did the check but never wrote the answer back. So if the row you removed was the very thing that made the site look broken, the app kept saying the site was broken until you relaunched it. This branch adds the one missing line that writes the answer back.
While there, it made three sibling actions cheaper: they used to re-check the entire library to answer a question about one or two sites. Now they check only the sites they touched. And two other actions that had their own hand-written version of the "did I make it worse?" rule now use the shared one.
A reader who removed a bad site from a Work would see it fixed on disk but still reported as broken, with rule application disabled, for the rest of the session. No data was lost; the app was lying about its own state.
LibraryRepository+Sites.swift — removeSiteMembership gains publishPostCommitDiagnoses(across: hostnames, in: diagnoses) after saveStrategy.save, where hostnames is every hostname of the Work (the one removed plus the survivors), the same set the validator and introducedDiagnosis already ran over.LibraryRepository+WorkMerge.swift, +DuplicateResolution.swift — commitWorkURL, commitMerge, commitResolution switch from LibraryValidator.validate(context:).quarantineMap() to LibraryValidator.validate(hostnames:context:) over [hostname], Array(mergeHostnames), hostnames.LibraryRepository+ComposedTeaching.swift — both teaching gates drop the priorDiagnosis pre-read and call introducedDiagnosis(across: [hostname], in:).LibraryValidator.swift — the scoped overload's doc now states which gates use it, and the two states where it disagrees with the full pass.MembershipRemovalTests.swift — the existing Q67 test that documented the bug as intended behaviour now asserts the latch, diagnostics.tupleDiagnoses, and the latch again after refreshDiagnostics().report.md (new) and Q122 in specs/multi-site-works/decision_log.md.The four-step shape — validate scoped, gate through introducedDiagnosis, save, publish through publishPostCommitDiagnoses — was already how commitWorkURL, commitMerge and commitResolution were spelled after T-2289. The fix puts removeSiteMembership on the same shape; the scoping brings those three onto the overload removeSiteMembership and commitWorkDeletion already used. introducedDiagnosis compares diagnoses[h] against quarantineReason(h), the latch; since nothing writes the latch before recordPostCommitDiagnosis and the locked closure is synchronous, reading it at the gate equals reading it at the top, which is why the pre-read could go.
commitResolution also threw on corruption anywhere in the store; the scoped pass only sees the hostnames written. Q122 supersedes Q110 on that clause.recordPostCommitDiagnosis writes two caches: quarantined[h] and diagnostics = diagnostics.recordingTupleDiagnosis(_:hostname:). The second is the one that matters for durability of the fix: refreshDiagnostics() runs the tolerance scan (which yields no .siteTuple) and unions it with diagnostics.tupleDiagnoses, so a latch cleared without the tuple set moving would be re-latched one foreground later. The regression test asserts all three points (latch, tuple set, latch after refresh) — the right shape for this mechanism.
Cost: validate(context:) is seven unpredicated table fetches plus ~3 relationship faults per Entry and 2 per Work; validate(hostnames:) is two rule-table fetches plus four predicated fetches per hostname (Site, Entry, membership ×2 via hostnameWorks) and the same per-row faults over that hostname's rows. No #Index on hostname exists, so the predicates are SQLite scans, but materialisation is bounded by the hostname's share. M4ScalePerformanceTests.storeLevelValidation measures the full pass alone; nothing in test-performance-m4 reaches any of these gates, so the cost claim rests on inspection.
Seven paths now publish after a validating save: the two teaching commits (direct recordPostCommitDiagnosis), commitWorkDeletion (its own loop), and four through publishPostCommitDiagnoses. The refusal-reporting shapes still differ (throw invalidInput / return .invalidated / return DuplicateResolutionOutcome?, with commitResolution alone refetching after rollback), which is the quality agent's case for a validateGateSaveAndPublish helper returning a refusal enum — a five-file refactor that does not belong on a round-3 bugfix branch but would make the T-2289/T-2291 omission structurally impossible. The refreshDiagnostics doc (LibraryRepository.swift:150-154) still says "the two passes that can repair a hostname" — stale since T-2289, more so now.
commitResolution's character path passes [""] when a Work has no primary membership; the scoped pass does one Site.hostname == "" fetch and returns [:], and the empty hostname is filtered at publish. Harmless, strictly cheaper than before.commitMerge's or commitResolution's post-write introduced-diagnosis refusal, and nothing pins either divergence. commitWorkURL's post-write arm is covered by WorkURLDiagnosisComparisonTests on the now-scoped validator.quarantined is derivable from diagnostics.quarantineMap() everywhere except test wiring (markQuarantined); the test asserting both caches separately exists because they can drift. A candidate for a computed property later.Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift
Why it matters. The fix. Without it a removal that dropped the malformed membership row diagnosing a site left the site quarantined for the session: capture rule application stayed disabled and Check Library kept reporting it.
What to look at. LibraryRepository+Sites.swift:186-199 — publishPostCommitDiagnoses(across: hostnames, in: diagnoses) after saveStrategy.save
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift
Why it matters. Each gate only ever reads diagnoses[h] for its own hostnames; the whole-library pass replayed every rule against every Entry to answer that. Behaviour-preserving for a hostname holding a Site row and no split Work group; the two divergences are documented at the overload.
What to look at. +WorkMerge.swift:132-133 and :482-483; +DuplicateResolution.swift:806
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift
Why it matters. One rule in one place. The inline diagnoses[hostname] != priorDiagnosis was a second and third spelling of the introduced-diagnosis rule that could drift from the shared helper.
What to look at. +ComposedTeaching.swift:250 and :368; the priorDiagnosis locals at the top of both closures are removed
Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift
Why it matters. The doc previously claimed a Site-less hostname yields no diagnosis "exactly as the full pass does", which is the opposite of the code: the full pass records under membership.hostname with no Site check. A future reader deciding whether to scope another gate needs the true contract.
What to look at. LibraryValidator.swift:163-288
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift
Why it matters. The Q67 test carried a comment stating the path "does not republish the map after its save" — the defect recorded as intended behaviour. It now asserts the latch is nil, the tuple set has moved, and a refreshDiagnostics() does not re-latch.
What to look at. MembershipRemovalTests.swift:308-330 and :170-172
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift
Why it matters. They are now the most expensive validation in the gate family and have the same shape as the three gates just scoped. Q122 records them as the exception; the doc and report say they were "left alone" but not why.
What to look at. +ComposedTeaching.swift:245 and :360 — LibraryValidator.validate(context:).quarantineMap()
The validate and gate steps already ran over Set(remaining).union([hostname]); publishing over the same set re-records a surviving site's standing diagnosis unchanged and clears the removed site's. Stated in the new comment at +Sites.swift:186-196 and in the report's Resolution §1.
Q122 (specs/multi-site-works/decision_log.md:128): a gate reads diagnoses[h] only for its own hostnames, so the whole-library pass derived diagnoses nobody consulted. Q110 of specs/duplicate-reconciliation recorded that pass as a deliberate net in commitResolution; Q122 supersedes it on that clause and says so. Q110 itself is left as a dated record.
Report, Deliberately not done: moving the membership arm above the Site-winner guard, or changing which rows the scoped pass ranks, changes what the validator reports — a validator change, not a call-site one. Stated at the overload doc and in Q122's caveats.
Recorded as the exception in Q122 and the overload doc, with no reason given. Open question for the author — see the last important-change card.
(inferred — not stated by the author.)Commit 876961d, from round 1: both scenarios (a removal that repairs, a removal under an unchanged diagnosis) were already seeded there; a duplicate fixture was removed.
Report, Deliberately not done: Q100 is a dated record of what was decided on 2026-08-26; amending it for work done later would make the log less accurate. Matches the convention of Q121 (T-2295) and Q65/Q66.
LibraryRepository.swift:803-810: both omissions the helper was written for were found by reading a gate, not a list, and a list goes stale when a gate is added. Note the same branch adds a named exception list to the validator overload doc — accepted knowingly, since round 2 asked for exactly that.
+WorkDeletion.swift:214-220: the previous citation named Decision 8 of title-teaching-retroactive-parsing, which is about backup V2 support. Verified this round: Q67 states the introduced-diagnosis rule; Decision 8 of library-integrity-tolerance is the prior-diagnosis comparison; the title-teaching Decision 8 is "Support Current Backup V2 Only".
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | report.md:76-83, :312; LibraryValidator.swift:176 — T-2289 misattribution | The follow-ups report (specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md) is T-1956's (Ticket: T-1956; 7006b91, PR #43, merged 2026-08-29 08:13), not T-2289's (0c4f0e8, PR #40, merged 08:34, which added publishPostCommitDiagnoses and wired the three gates). So "T-2289 fixed the three gates ... and left the fourth as a recorded follow-up" is wrong — T-1956 recorded it, T-2289 fixed three without mentioning the fourth — and "T-2289 follow-up (c) covered the other gates" is wrong twice: the ticket, and (c) names commitWorkURL alone. Introduced by c9505a9 in answer to round 2, whose own finding text carried the wrong ticket. | Not applied (no-edit review). Must fix: name T-1956 as the report that recorded Follow-ups (a)-(d), keep T-2289 as the commit that added the helper and wired commitWorkURL/commitMerge/commitResolution; at LibraryValidator.swift:176 say "T-1956's follow-up (c) named commitWorkURL; T-2291 scoped it and the merge and resolution gates". |
| minor | LibraryRepository+WorkMerge.swift:120-128 — "the one arm" | The commitWorkURL comment says the scoped overload gives a Site-holding hostname the whole-graph answer and to read the overload doc "for the one arm where the two derivations disagree (a malformed membership on a Site-less hostname)". The doc it points to now qualifies the equality with "and holds no split Work group" and lists two arms. Residual of round 2's "the one divergence" item, which cited LibraryValidator.swift only. | Not applied (no-edit review). Must fix: drop the count and the parenthetical — "read its own doc for where the two derivations disagree" — so the caveat has one spelling. |
| minor | report.md:140, :157, :180 — gate name | The report calls the deletion gate deleteWork; the API is commitWorkDeletion (LibraryRepository+WorkDeletion.swift:83, LibraryProviding.swift:266). T-1956's report uses the right name. | Not applied. Rename in the three places. |
| minor | LibraryRepository.swift:150-154 — refreshDiagnostics doc | "the two passes that can repair a hostname without [a full validation] invalidate it themselves: a teaching commit ... and a reconcile pass" — seven paths publish now. Pre-existing since T-2289, but this branch's premise is that every gate publishes and it edits the same file 650 lines below. | Not applied. One-line reword: "every commit gate and the reconcile pass invalidate it themselves". |
| minor | CHANGELOG.md — no Fixed entry | Sibling bugfixes T-2301, T-2295 and T-2289 each carry an [Unreleased] → Fixed entry; T-1956 did not. This branch adds none. | Not applied. Add a short Fixed entry (a removed site stops being reported as quarantined without a relaunch), or leave it — the convention is majority, not universal. |
| minor | docs/agent-notes/testing.md:88-92 vs report.md Verification | The report folds StoreMetadataTests.storeBornAtV5ReadsAtOrAboveV5 into the store-digest flake family ("the same WAL-checkpoint nondeterminism read from the other end"); the note lists only the BootstrapActionTests and BootstrapClassifierTests cells. This round's run failed on the BootstrapClassifierTests cell exactly as the note describes; PR #63 fixes the family at source. | Not applied. Either add StoreMetadataTests to the note (or let #63's note cover it) or soften the report's attribution. |
| nit | LibraryRepository.swift:807-810 vs LibraryValidator.swift:174-178 | The publish helper's doc says no caller list is kept because lists go stale; the validator overload's doc (same branch) now names its two non-callers. Round 2 asked for the latter, so this is accepted tension rather than an error. | No action. |
| nit | +ComposedTeaching.swift:268, :380 and +WorkDeletion.swift:251-253 — inline publications | The two teaching commits call recordPostCommitDiagnosis(diagnoses[hostname], hostname:) directly and commitWorkDeletion loops the same call — literally publishPostCommitDiagnoses' body minus its empty-hostname filter. The helper doc rationalises keeping them; "already hold the diagnoses in a loop" is a description, not a reason. | No action for this ticket; a one-line swap each if the family is ever unified. |
| nit | Teaching gates still on validate(context:) — +ComposedTeaching.swift:245, :360 | Now the most expensive validation left in the gate family; both guard on sites.first existing so only the split-Work-group arm could differ from the scoped pass. Q122 records the exception without a reason. | Follow-up ticket; not this branch. |
| nit | Shared gate helper | Five gates are the same four statements; the differences are entirely in how a refusal is reported (throw / return .invalidated / return DuplicateResolutionOutcome?, logging, commitResolution's post-rollback refetch). A validateGateSaveAndPublish(hostnames:context:) returning a refusal enum would make the T-2289/T-2291 omission structurally impossible. | Follow-up ticket; a five-file refactor does not belong on a round-3 bugfix branch. |
| nit | validate(hostnames: [String]) signature; +DuplicateResolution.swift:775-777 | Three call sites wrap a Set in Array(...) for the validator to Set(...).sorted() it again; the sibling helpers take some Sequence<String>. Separately, the character path passes [""] when a Work has no primary membership — one Site.hostname == "" fetch returning [:], filtered at publish. Both pre-existing, both cheaper than before. | No action. |
| nit | report.md:49-50; LibraryValidator.swift:164-166 | quarantineMap() is listed under LibraryValidator.swift but lives in LibraryDiagnostics.swift:317. The overload doc's arm order omits validate(membership:), which runs second in both passes (pre-existing). | No action. |
Source: local run at 2026-09-05T17:22:48+10:00 · snapshot c9505a9f50aeb27fc02093403af9815dbb681e5a
Baseline: none
Execution: failed · JUnit: 1 file · Coverage: 1 file · Baseline: absent
Coverage scope: every test in the repository
Totals: 2315 passed · 1 failed · 31 skipped · 0 errored · 0 flaky
| Suite | Test | Job or artifact | Message |
|---|---|---|---|
| AsterismCoreTests.BootstrapClassifierTests | classificationIsTotalAndWritesNothing(cell:) | — | Expectation failed: try root.digest() == before (error): store=fullFamily marker=five historical=false artefact=true seededVersion=atOrAboveV5: classifying the state changed it |
Derived by declaration name, from the diff (no baseline run).
No new or removed test declarations.
Aggregate diff coverage: 100% (74 of 74 measurable added lines).
Head 93.7% (77687 of 82872 lines)
8 of 10 changed files matched coverage data.
specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md — no candidatespecs/multi-site-works/decision_log.md — no candidateFiles that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 30c1573050a3432567c9cbdf96d94eab46194f0f.
Click to expand.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swiftindex eef8ff2..bcadbe2 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift@@ -185,6 +185,18 @@ extension LibraryRepository { operation: "removing a site membership", reason: String(describing: error)) }+ // T-2291: publish what this commit's own validation left behind, for+ // every hostname it wrote to — the step the deletion commit and the+ // teaching commits take, and the one this path was missing.+ //+ // A removal is the repair for exactly the diagnosis the removed row+ // caused, so without this the hostname stays quarantined until the+ // next launch: the foreground refresh carries the tuple set forward+ // (`LibraryDiagnostics.union`) rather than re-deriving it, so a stale+ // entry never falls out on its own. A diagnosis the removal left+ // standing is re-recorded unchanged, so nothing it did not repair is+ // cleared.+ self.publishPostCommitDiagnoses(across: hostnames, in: diagnoses) } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swiftindex ad0b45f..be0b096 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift@@ -115,11 +115,22 @@ extension LibraryRepository { row.modifiedAt = urlTimestamp } - // Validate the complete prospective graph (V4), then save once. An- // invalid result on the affected Site rolls back and reports.+ // Validate the prospective graph, then save once. An invalid result+ // on the affected Site rolls back and reports.+ //+ // T-2291: **scoped to the hostname this commit wrote to.** A Work URL+ // lands on one membership, and the gate below and the publication+ // after the save both read that hostname alone — so validating the+ // whole library replayed every rule against every Entry to answer a+ // question about one site. The scoped overload gives a hostname+ // holding a Site row the answer a whole-graph validation would give+ // it, and reports the tuple class alone, as `quarantineMap()` does —+ // read its own doc for the one arm where the two derivations+ // disagree (a malformed membership on a Site-less hostname). let diagnoses: [String: LibraryValidationError] do {- diagnoses = try LibraryValidator.validate(context: context).quarantineMap()+ diagnoses = try LibraryValidator.validate(+ hostnames: [hostname], context: context) } catch { context.rollback() workMergeLogger.error(@@ -457,12 +468,18 @@ extension LibraryRepository { // partial merge the old refusal was avoiding. for row in sourceGroup.rows { context.delete(row) } - // Validate the complete prospective graph, then save once. Merge is- // the highest-risk mutation (Work deletion plus bulk reassignment);- // an invalid result rolls back and reports instead of persisting damage.+ // Validate the prospective graph, then save once. Merge is the+ // highest-risk mutation (Work deletion plus bulk reassignment); an+ // invalid result rolls back and reports instead of persisting damage.+ //+ // T-2291: scoped to `mergeHostnames` — every site of both Works, and+ // therefore every site this merge wrote to. That is already the set+ // the gate and the publication read, so the whole-library pass was+ // deriving diagnoses for hostnames neither of them would consult. let diagnoses: [String: LibraryValidationError] do {- diagnoses = try LibraryValidator.validate(context: context).quarantineMap()+ diagnoses = try LibraryValidator.validate(+ hostnames: Array(mergeHostnames), context: context) } catch { context.rollback() workMergeLogger.error(
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swiftindex ad143b6..661107a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift@@ -798,9 +798,12 @@ extension LibraryRepository { private func commitResolution( context: ModelContext, hostnames: [String], operation: String ) throws -> DuplicateResolutionOutcome? {+ // T-2291: scoped to `hostnames` — every site the resolution wrote to,+ // which is already the set the gate and the publication below read. The+ // whole-library pass was deriving diagnoses neither of them consults. let diagnoses: [String: LibraryValidationError] do {- diagnoses = try LibraryValidator.validate(context: context).quarantineMap()+ diagnoses = try LibraryValidator.validate(hostnames: hostnames, context: context) } catch { context.rollback() _ = try? context.fetch(FetchDescriptor<Entry>())
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swiftindex 4941a08..1bc142e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift@@ -82,10 +82,6 @@ extension LibraryRepository { return try await withLockedContext(mode: .exclusive, operation: "committing composed teaching") { context in let hostname = contract.basis.hostname - // What this hostname was already diagnosed with, read before any- // mutation. Step 7 rolls back only when the commit *changes* it.- let priorDiagnosis = self.quarantineReason(hostname: hostname)- // 1. Refetch basis and re-project. let currentBasis: ComposedTeachingBasis do { currentBasis = try self.buildComposedTeachingBasis(hostname: hostname, context: context) }@@ -226,16 +222,24 @@ extension LibraryRepository { // `quarantineMap` is the projection this guard has always compared // against — an illegal tuple, or now a second Site row. //- // **The comparison is against `priorDiagnosis`, not against nil**- // (Req 3.2, 3.3, Decision 8). This guard used to roll back whenever- // the hostname carried any diagnosis afterwards, which made a- // diagnosed hostname impossible to re-teach: the app reported+ // **The comparison is against the hostname's prior diagnosis, not+ // against nil** (Req 3.2, 3.3, Decision 8). This guard used to roll+ // back whenever the hostname carried any diagnosis afterwards, which+ // made a diagnosed hostname impossible to re-teach: the app reported // something wrong and then refused the only action that would fix // it. Equality is the comparison, not a severity order — // `LibraryValidationError` has no ordering that would not be invented — // so an unchanged diagnosis commits and a changed one rolls back. // Clearing is `diagnoses[hostname] == nil`, which is never a // difference worth rolling back for.+ //+ // T-2291: through `introducedDiagnosis(across:in:)`, like every other+ // commit gate. The rule was spelled inline here and in+ // `commitRecalculation` below, which is a second place it can drift+ // from the shared helper. The helper reads the quarantine latch+ // itself — untouched until this commit publishes — so reading it+ // here, after the mutation and before the save, is still reading the+ // pre-write answer. let diagnoses: [String: LibraryValidationError] do { diagnoses = try LibraryValidator.validate(context: context).quarantineMap()@@ -243,11 +247,11 @@ extension LibraryRepository { context.rollback() return .invalidated(reason: "composed teaching produced an invalid library: \(error)") }- if let diagnosis = diagnoses[hostname], diagnosis != priorDiagnosis {+ if let introduced = self.introducedDiagnosis(across: [hostname], in: diagnoses) { context.rollback() return .invalidated( reason: "composed teaching would introduce a new diagnosis on Site "- + "'\(hostname)': \(diagnosis)")+ + "'\(hostname)': \(introduced.diagnosis)") } do { try self.saveStrategy.save(context) }@@ -298,7 +302,6 @@ extension LibraryRepository { ) async throws -> ComposedRecalculationOutcome { try await withLockedContext(mode: .exclusive, operation: "committing recalculation") { context in let hostname = contract.basis.hostname- let priorDiagnosis = self.quarantineReason(hostname: hostname) let currentBasis: ComposedTeachingBasis do { currentBasis = try self.buildComposedTeachingBasis(hostname: hostname, context: context) }@@ -359,14 +362,14 @@ extension LibraryRepository { context.rollback() return .invalidated(reason: "recalculation produced an invalid library: \(error)") }- // The same comparison as the composed commit, for the same reason:- // roll back only when the recalculation changed the hostname's- // diagnosis (Req 3.2, 3.3, Decision 8).- if let diagnosis = diagnoses[hostname], diagnosis != priorDiagnosis {+ // The same comparison as the composed commit, through the same+ // helper, for the same reason: roll back only for a diagnosis the+ // recalculation itself introduced (Req 3.2, 3.3, Decision 8).+ if let introduced = self.introducedDiagnosis(across: [hostname], in: diagnoses) { context.rollback() return .invalidated( reason: "recalculation would introduce a new diagnosis on Site "- + "'\(hostname)': \(diagnosis)")+ + "'\(hostname)': \(introduced.diagnosis)") } do { try self.saveStrategy.save(context) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swiftindex dbd2a46..8e6885a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift@@ -213,8 +213,11 @@ extension LibraryRepository { // relationship graph the way Merge does, so it takes Merge's // validator discipline (Q21) — but compared against the *prior* // diagnosis rather than against nil, or a hostname that was already- // diagnosed would have undeletable works (Decision 8 of- // `specs/title-teaching-retroactive-parsing`).+ // diagnosed would have undeletable works (Q67 of+ // `specs/multi-site-works`, which extends Decision 8 of+ // `specs/library-integrity-tolerance` to this gate; the citation+ // here used to name `specs/title-teaching-retroactive-parsing`,+ // whose Decision 8 is about backup V2 support). let diagnoses: [String: LibraryValidationError] do { diagnoses = try LibraryValidator.validate(
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex 1be2d31..580a402 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -799,9 +799,15 @@ public actor LibraryRepository { /// hostname it wrote to — the diagnosis it left behind, or nil, which /// clears the quarantine. A commit that skips this leaves a hostname it /// repaired quarantined until the next launch, because the foreground- /// refresh cannot re-derive the tuple set. The teaching commit always did- /// this through `recordPostCommitDiagnosis`; the Work URL, Merge and- /// duplicate-resolution commits now do too.+ /// refresh cannot re-derive the tuple set.+ ///+ /// Every commit gate that validates and saves owes this step, over the same+ /// hostnames it gated on. Some reach it through `recordPostCommitDiagnosis`+ /// directly, because they write one hostname (the teaching commits) or+ /// already hold the diagnoses in a loop (the deletion commit). No caller+ /// list is kept here: both omissions this helper was written for (T-2289,+ /// T-2291) were found by reading a gate, not by reading a list — and a list+ /// is one more thing that goes stale when a gate is added. func publishPostCommitDiagnoses( across hostnames: some Sequence<String>, in diagnoses: [String: LibraryValidationError] ) {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swiftindex 8752e71..427b9de 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift@@ -163,14 +163,21 @@ public enum LibraryValidator { /// /// The same per-row arm `validate(graph:)` runs — `validate(site:)`, /// `validate(work:)`, `validate(entry:)`, in that order, first failure per- /// hostname wins — over a graph narrowed to the hostnames asked about. The- /// answer for a hostname is therefore the answer a whole-graph validation- /// would give it, at the cost of that hostname rather than of the library.+ /// hostname wins — over a graph narrowed to the hostnames asked about. For a+ /// hostname that **has a Site row** and holds no split Work group the answer+ /// is the answer a whole-graph validation would give it, at the cost of that+ /// hostname rather than of the library. The two divergences are spelled out+ /// below. ///- /// It exists for `reconcileAfterSync`, whose question after a repair is about- /// the one or two hostnames the pass consolidated (Req 2.2). Running the full- /// validator to answer it replayed every rule against every Entry in the- /// library on every arrival that repaired anything.+ /// It was written for `reconcileAfterSync`, whose question after a repair is+ /// about the one or two hostnames the pass consolidated (Req 2.2); it is now+ /// the validator of every commit gate **but the two teaching commits** —+ /// `commitComposedTeaching` and `commitRecalculation` still run+ /// `validate(context:)` over the whole library (T-2289 follow-up (c) covered+ /// the other gates; the teaching pair was left alone). Every gate on this+ /// overload likewise gates and publishes over the hostnames it wrote to and+ /// reads nothing else. Running the full validator to answer that replayed+ /// every rule against every Entry in the library on every such write. /// /// Three things the narrowing deliberately preserves: ///@@ -182,9 +189,33 @@ public enum LibraryValidator { /// holding an Entry of another hostname resolves through the index here as /// it does in the full pass, so the cross-Site failure is still reported /// against the Entry's hostname and not manufactured against the Work's.- /// - **A hostname with no Site row yields no diagnosis**, exactly as the full- /// pass does: that is `.siteMissing`, a tolerated state rather than a tuple- /// failure, and there is no row for a tuple to be illegal on.+ /// - **A hostname with no Site row yields no *tuple* diagnosis.** That is+ /// `.siteMissing`, a tolerated state rather than a tuple failure, and there+ /// is no row for a tuple to be illegal on.+ ///+ /// **Where it does not agree with the full pass (T-2291).** Two arms, in+ /// opposite directions.+ ///+ /// - *This pass reports less.* The whole-graph membership arm records under+ /// `membership.hostname` with no Site check, so a *malformed membership row+ /// on a hostname that has no Site row* is a diagnosis there and is not one+ /// here: this pass `continue`s at the Site-winner guard below before it+ /// reaches its membership loop. A library in that state is quarantined for+ /// it at bootstrap and un-quarantined by any commit gate that publishes+ /// over the hostname, while the damage stands.+ /// - *This pass reports more.* For a duplicated-UUID Work group the two+ /// passes rank different candidate sets: this one picks the winner among+ /// the rows reachable from the hostname, the full pass among every row in+ /// the library, then reads the hostnames off that winner's memberships. A+ /// group whose `GroupOrdering` winner carries no membership on this+ /// hostname — the Req 8.1 lagging-writer state — is diagnosed here and not+ /// there. Reporting more can only quarantine; it cannot clear anything the+ /// full pass would hold.+ ///+ /// Neither pass repairs a row; the derivations only disagree about who is+ /// told. Read the paragraph above as a per-hostname equality for a hostname+ /// that holds a Site row and no split Work group, not as one across all+ /// inputs. public static func validate( hostnames: [String], context: ModelContext ) throws -> [String: LibraryValidationError] {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swiftindex 88ce3c6..be0ddf6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift@@ -126,7 +126,14 @@ struct MembershipRemovalTests { /// the diagnosis as "unknown", because there was none left to name. The /// state removal exists to let the reader out of was the state it would not /// let them out of.- @Test("A removal that repairs the removed site's diagnosis commits")+ ///+ /// T-2291: and the repair has to be *published*, not just permitted. This+ /// path validated its own hostnames, gated on them and saved without ever+ /// calling `publishPostCommitDiagnoses`, so the site the removal had just+ /// repaired stayed quarantined for the rest of the session — the foreground+ /// refresh carries the tuple set forward (`LibraryDiagnostics.union`) rather+ /// than re-deriving it, so a stale entry never falls out on its own.+ @Test("A removal that repairs the removed site's diagnosis commits and clears its quarantine") func removalThatClearsADiagnosisCommits() async throws { let fixture = try await twoSiteFixture() try await fixture.repository.setMembershipIdentity(@@ -141,11 +148,16 @@ struct MembershipRemovalTests { #expect( try await fixture.repository.membershipRows(of: Self.workID).map(\.hostname) == ["first.example"])- // The cached quarantine is not asserted: unlike Work deletion, this path- // does not republish the map after its save, so the removed site keeps a- // stale entry until the next full validation. That is a separate- // question from whether the removal was allowed, which is what this- // test is about.+ // The quarantine the removed row caused went with it, without waiting+ // for the next launch (T-2291).+ #expect(await fixture.repository.quarantineReason(hostname: "second.example") == nil)+ // The latch alone is not the assertion: the tuple set the next+ // foreground refresh unions against has to have moved too, or the+ // refresh puts the hostname this removal repaired straight back into+ // quarantine.+ #expect(await fixture.repository.diagnostics.tupleDiagnoses["second.example"] == nil)+ try await fixture.repository.refreshDiagnostics()+ #expect(await fixture.repository.quarantineReason(hostname: "second.example") == nil) // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.@@ -155,6 +167,10 @@ struct MembershipRemovalTests { /// found it is not one it introduced either, so the surviving site's /// standing quarantine must not make the Work's other memberships /// unremovable.+ ///+ /// T-2291's other arm too: the publication after the save re-records that+ /// standing diagnosis unchanged rather than clearing it, so a removal that+ /// repaired nothing on this hostname does not un-quarantine it. @Test("A standing diagnosis on the surviving site does not refuse the removal") func removalCommitsUnderAnUnchangedDiagnosis() async throws { let fixture = try await twoSiteFixture()
diff --git a/specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md b/specs/bugfixes/membership-removal-never-refreshes-quarantine/report.mdnew file mode 100644index 0000000..41327dd--- /dev/null+++ b/specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md@@ -0,0 +1,318 @@+# Bugfix Report: removeSiteMembership never refreshes the cached quarantine++**Date:** 2026-09-05+**Status:** Fixed+**Ticket:** T-2291++## Description of the Issue++`LibraryRepository.removeSiteMembership(workID:hostname:)` validates the+prospective graph, gates on `introducedDiagnosis(across:in:)`, saves — and+returns. It never calls `recordPostCommitDiagnosis` /+`publishPostCommitDiagnoses`.++A removal that *repairs* a hostname — dropping the very membership row whose+malformed tuple diagnosed it — therefore leaves the hostname quarantined in the+repository's in-memory map until the next launch. The foreground refresh cannot+recover: `LibraryDiagnostics.union` carries the tuple set forward from the last+full derivation rather than re-deriving it, so a stale entry survives every+refresh.++This is exactly the T-2289 defect (`0c4f0e8`, PR #40) on the one write path+T-2289 missed. T-2289 added `publishPostCommitDiagnoses` and wired it into+`commitWorkURL`, `commitMerge` and `commitResolution`; the sibling+`LibraryRepository+WorkDeletion.swift` has gated *and* published since it was+written.++**Reproduction steps:**+1. Have a two-site Work whose membership on `b.example` carries a malformed+ tuple (for instance an unrecognised `urlIdentityStateRaw`), so `b.example`+ is quarantined at launch.+2. Remove that site from the Work (Work detail -> remove site).+3. The removal succeeds and the offending row is gone, but+ `quarantineReason(hostname: "b.example")` still reports the diagnosis, and+ every path gated on the quarantine stays disabled until the app is+ relaunched.++**Impact:** Medium. No data loss and no incorrect write — the library on disk is+correct. What is wrong is the cached diagnosis: rule application on capture+(`LibraryRepository+ReparseCapture.swift`) stays disabled for the repaired+hostname and Check Library / Settings keep reporting it, for the rest of the app+session. Backup export is *not* affected — it used to be the second consumer of+the latch, but its quarantine gate was retired by Req 3.1 of+`specs/cloudkit-mirroring` (`BackupV9Exporter.swift`, "the quarantine and+unresolved gates are gone"; `LibraryRepository.swift`, the `refreshDiagnostics`+doc), and a quarantined library exports.++## Investigation Summary++- **Symptoms examined:** the quarantine latch surviving a repairing write, and+ the same three commits validating more of the graph than they write to.+- **Code inspected:**+ - `LibraryRepository+Sites.swift` — `removeSiteMembership`+ - `LibraryRepository.swift` — `introducedDiagnosis`,+ `publishPostCommitDiagnoses`, `recordPostCommitDiagnosis`+ - `LibraryRepository+WorkDeletion.swift` — the correct sibling+ - `LibraryRepository+WorkMerge.swift`, `+DuplicateResolution.swift` — the+ three commits still validating the whole graph+ - `LibraryValidator.swift` — `validate(context:)` vs+ `validate(hostnames:context:)`, and `LibraryDiagnostics.quarantineMap()`+- **Hypotheses tested:** whether the foreground refresh could recover the stale+ entry on its own — it cannot; `LibraryDiagnostics.union` documents exactly+ why (the scan produces no `.siteTuple`, so the tuple set has to be carried+ forward, and a stale carried-forward entry is never re-derived).++## Discovered Root Cause++**Defect type:** Missing state publication after a successful commit.++`removeSiteMembership` is a full commit gate — it validates, refuses an+introduced diagnosis, and saves — but it is the only such gate that does not+publish its own validation result. The in-memory quarantine map and the+`diagnostics` tuple set are therefore left holding the *pre-write* answer for a+hostname the write just changed.++**Why it occurred:** the publication step was added to the teaching commits+first and to the deletion commit when that was written; T-2289 swept the Work+URL, Merge and duplicate-resolution commits into line. It was not that+`removeSiteMembership` was missed — that report's Follow-up (a)+(`specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md`)+names it explicitly, alongside the three gates, as never calling+`recordPostCommitDiagnosis`. It was listed and deferred: T-2289 fixed the three+gates that lived in the files it was already editing and left the fourth, in a+different file, as a recorded follow-up. This ticket is that follow-up.++**Contributing factors:** three of the four gates T-2289 touched still call the+full-library `LibraryValidator.validate(context:)`, which makes the shape of the+"validate, gate, publish" pattern harder to see as one thing across the files+that implement it.++## Resolution for the Issue++**Changes made:**++1. `LibraryRepository+Sites.swift` — `removeSiteMembership` now calls+ `publishPostCommitDiagnoses(across: hostnames, in: diagnoses)` after the+ save, over the same hostname set it validated and gated on (every hostname+ of the Work, not only the one being dropped). A hostname the removal+ repaired is un-quarantined immediately; a diagnosis it left standing is+ re-recorded unchanged, so nothing it did not repair is cleared.++2. `LibraryRepository+WorkMerge.swift`, `LibraryRepository+DuplicateResolution.swift`+ — `commitWorkURL`, `commitMerge` and `commitResolution` swapped+ `LibraryValidator.validate(context:).quarantineMap()` for the hostname-scoped+ `LibraryValidator.validate(hostnames:context:)`, over the hostname set each+ already gates and publishes on (`[hostname]`, `mergeHostnames`, `hostnames`).+ Behaviour is unchanged for the hostnames each gate reads — see the caveat+ below for the two states where the derivations diverge — and the cost is not:+ the whole-library pass replayed every rule against every Entry in the library+ to answer a question about one or two sites, and every diagnosis it derived+ outside that set was discarded unread. What it also did, per **Q110 of+ `specs/duplicate-reconciliation`**, was act as a throw-on-corruption net for+ `commitResolution`: "the validation still *runs* over the whole graph, so a+ resolution that corrupted the store would throw rather than pass". That+ property is deliberately given up here — a gate's refusal is about the graph+ it wrote to, and the whole-graph re-derivation on every resolution is the cost+ Q110 accepted before the scoped overload existed. Q122 of+ `specs/multi-site-works` supersedes Q110 on that point.++3. `LibraryRepository+ComposedTeaching.swift` — the two hand-rolled+ `diagnoses[hostname] != priorDiagnosis` comparisons (`commitComposedTeaching`,+ `commitRecalculation`) now go through `introducedDiagnosis(across:in:)` like+ every other commit gate; the now-unused `priorDiagnosis` locals are gone.+ Behaviourally identical for one hostname — the helper reads the same+ quarantine latch, which nothing touches between the capture point and the+ gate — but it is one rule in one place rather than three spellings of it.++4. `LibraryRepository+WorkDeletion.swift` — corrected a comment citation: the+ introduced-diagnosis rule is Q67 of `specs/multi-site-works` (extending+ Decision 8 of `specs/library-integrity-tolerance`), not Decision 8 of+ `specs/title-teaching-retroactive-parsing`, which is about backup V2 support.++**Approach rationale:** the publication step is what every other commit gate+already does; adding the missing call is the whole fix, and using the existing+`publishPostCommitDiagnoses` helper puts `removeSiteMembership` on the same+four-step shape as `commitWorkURL`, `commitMerge` and `commitResolution` —+validate scoped to the hostnames written, gate through+`introducedDiagnosis(across:in:)`, save, publish through+`publishPostCommitDiagnoses(across:in:)`. Those four are now spelled the same+way; the family as a whole is not, and this change does not make it so:+`deleteWork` publishes through its own `recordPostCommitDiagnosis` loop, the two+teaching commits call `recordPostCommitDiagnosis` directly for their one+hostname and still validate the whole library, `commitResolution` alone refetches+Entry and Work after a rollback, and only some gates log.++The validator narrowing is safe because each gate only ever consulted its own+hostnames, and both passes divert the tolerated wrong-host Work URL finding out+of the tuple map. **Two caveats, found in review:** the two derivations do *not*+agree per hostname in every state.++*First,* the whole-graph membership arm records under+`membership.hostname` with no Site check+(`LibraryValidator.swift`, `validate(graph:)`), while the scoped pass `continue`s+at its Site-winner guard before reaching its membership loop — so a malformed+membership row on a hostname that holds **no Site row** is a diagnosis in the+full pass and none in the scoped one. Such a hostname is therefore quarantined at+bootstrap and un-quarantined by any scoped gate that publishes over it, while the+damage stands. This is pre-existing for `deleteWork`, `removeSiteMembership` and+`reconcileAfterSync`; this change extends it to `commitWorkURL`, `commitMerge`+and `commitResolution`. The scoped overload's own doc now states it (it+previously claimed the opposite for that arm). Moving the membership arm above+the Site-winner guard would close it and was left out of scope here: it changes+what the scoped pass reports rather than where it is called from.++*Second,* the two passes pick a duplicated-UUID Work group's winner from+different candidate sets: the scoped pass among the rows reachable from the+hostname, the full pass among every row in the library, reading the hostnames off+that winner's memberships. For a group whose `GroupOrdering` winner does not+carry a membership on this hostname — the Req 8.1 lagging-writer state — the+scoped pass reports the Work and the full pass does not. This one runs the other+way: the scoped pass reports *more*, so it can only quarantine where the full+pass would not, never un-quarantine.++**Alternatives considered:**+- Re-derive the quarantine on the next foreground refresh instead of publishing+ at the commit — rejected; `LibraryDiagnostics.union` documents why that cannot+ work (the scan produces no `.siteTuple`, so the tuple set is carried forward,+ never re-derived).+- Leave the three commits on the full validator — rejected; the ticket names it,+ and the narrowing is exactly the change `removeSiteMembership` and+ `deleteWork` already made.++## Regression Test++**Test file:**+`Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift`+**Test names:** `removalThatClearsADiagnosisCommits`,+`removalCommitsUnderAnUnchangedDiagnosis`++Both scenarios were already seeded by this suite for Q67 — a removal that repairs+the removed site's diagnosis, and one that leaves the surviving site's standing —+so the coverage went into them rather than into a second fixture of the same+shape. The first of them carried a comment stating that this path "does not+republish the map after its save", which was the bug recorded as intended+behaviour; it is now the assertion instead.++**What it verifies:**+- `removalThatClearsADiagnosisCommits`: a hostname quarantined by the malformed+ membership tuple on the site being dropped has its quarantine — *and* its entry+ in the carried-forward tuple set — cleared by the removal, and a subsequent+ `refreshDiagnostics()` does not put it back.+- `removalCommitsUnderAnUnchangedDiagnosis`: a diagnosis on the hostname the Work+ stays on is re-recorded unchanged by the publication, so the removal does not+ over-clear.++**Run command:** `make test-core CORE_TEST='MembershipRemovalTests'`++## Affected Files++| File | Change |+|------|--------|+| `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift` | `removeSiteMembership` publishes its post-commit diagnoses |+| `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift` | `commitWorkURL` and `commitMerge` validate hostname-scoped |+| `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift` | `commitResolution` validates hostname-scoped |+| `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift` | both teaching gates go through `introducedDiagnosis(across:in:)` |+| `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift` | comment citation corrected |+| `Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift` | scoped overload's doc: which gates use it (and which two do not), and the two arms where it does not match the full pass |+| `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift` | `publishPostCommitDiagnoses` doc: stale caller list dropped |+| `Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift` | regression assertions, replacing the comment that documented the bug |++## Verification++**Automated:**+- [x] The regression assertions fail before the fix and pass after it (verified+ by disabling the new `publishPostCommitDiagnoses` call and re-running).+ Only the clearing arm is red without it: the new+ `quarantineReason == nil` / `tupleDiagnoses[...] == nil` assertions in+ `removalThatClearsADiagnosisCommits` are what the fix turns green.+ `removalCommitsUnderAnUnchangedDiagnosis` passes before and after — it+ guards against the publication over-clearing, which is a property of the+ fix rather than a symptom of the bug.+- [x] `make test-core` — clean full runs, exit 0, 2,316 tests (2026-09-05; once+ before the pre-push review and once after its fixes)+- [x] No new compiler warnings (no code-style linter is configured in this repo)++**Known flake seen during verification:** across the branch's two verification+rounds, six further full runs each failed on a *different* single cell of the+store-digest family — `BootstrapActionTests.aFailedOpenChangesNothing` (twice),+`BootstrapClassifierTests`' "classifying the state changed it" cell (twice), and+`StoreMetadataTests.storeBornAtV5ReadsAtOrAboveV5`, which is the same+WAL-checkpoint nondeterminism read from the other end (`.indeterminate` for a+store that was just written at V5). Every one of them passed on an isolated+re-run (`make test-core CORE_TEST=…`, exit 0), and a full run on the unmodified+tree reproduced the family too. This is what+`docs/agent-notes/testing.md` documents as "Known flaky family: the store-digest+comparisons", whose note says one green full run on the branch is what separates+the flake from a real failure — that green run is recorded above.++**Manual verification:**+- Not performed on device. To verify: on a site quarantined by a malformed+ membership row, remove that site from the Work; the site should stop being+ reported as quarantined without relaunching the app.++## Prevention++**Recommendations to avoid similar bugs:**+- Four of the gates — `commitWorkURL`, `commitMerge`, `commitResolution`,+ `removeSiteMembership` — are now spelled identically: validate scoped to the+ hostnames written, gate through `introducedDiagnosis(across:in:)`, save,+ publish through `publishPostCommitDiagnoses(across:in:)`. A new gate that+ omits a step is visibly different from those four rather than merely shorter.+ The rest of the family still varies (see the approach rationale above), so the+ four are a template to copy, not a shape the compiler enforces.+- A commit gate that validates is also a fresher answer than the quarantine+ latch. Any future gate that computes diagnoses must publish them; computing+ them and discarding them is the defect both T-2289 and T-2291 were.++## Deliberately not done++- **Q100 in `specs/multi-site-works/decision_log.md` was left as written.** Its+ wording ("the merge and duplicate-resolution gates change behaviour to match")+ omits `commitWorkURL`, which T-2289 later swept in, and now also the two+ teaching gates. It is a dated historical record of what was decided on+ 2026-08-26; amending it to describe work done months later would make the log+ less accurate, not more. **Q122 records the current state of the rule+ instead**, which is the convention the sibling bugfixes follow (Q121 for+ T-2295; Q65, Q66 in `specs/library-integrity-tolerance`).++- **The scoped/full membership-arm divergence was not closed.** Moving the+ membership loop above the Site-winner guard in `validate(hostnames:context:)`+ would make the two derivations agree for a Site-less hostname, but it changes+ what the scoped pass reports rather than where it is called from — a validator+ change, not a call-site one, and out of this ticket's scope. It is documented+ at the overload and in the approach rationale above. The same goes for the+ split-Work-group divergence: it reports more, not less, and closing it means+ changing which rows the scoped pass ranks.++- **Q110 of `specs/duplicate-reconciliation` was left as written.** Like Q100 it+ is a dated record — of 2026-08-02, before the hostname-scoped overload had any+ gate callers — and its throw-on-corruption-anywhere clause was true of the code+ it described. Q122 of `specs/multi-site-works` supersedes it on that point and+ cites it; the approach rationale above says what the branch gives up.++## Follow-ups (out of scope)++Noticed in review, not part of this fix:++- (a) **No test reaches `commitMerge`'s post-write introduced-diagnosis refusal+ or its publication.** `CrossSiteMergeTests` (~`:195-205`) covers the *pre*-write+ Req 4.5 refusal, which is a different branch. The post-write arm is exercised+ only indirectly.+- (b) **No test reaches `commitResolution`'s post-write refusal or its+ publication** either; the `DuplicateResolutionTests` refusal cases are the+ gone/split-reason arms, not the introduced-diagnosis one.+- (c) **Nothing pins the documented scoped/full divergences.**+ `MembershipValidationTests.nilMembershipSiteIsTolerated` covers a well-formed+ row only; neither the Site-less-hostname arm nor the split-Work-group arm has a+ test that would notice if a later change closed or widened it.++## Related++- T-2291 (this fix), T-2289 (`0c4f0e8`, PR #40)+- `specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md`+ — Follow-ups (a), (b), (c), (d)+- `specs/multi-site-works/decision_log.md` Q67, Q100, Q122+- `specs/duplicate-reconciliation/decision_log.md` Q110 — superseded by Q122 on+ the whole-graph pass in `commitResolution`+- `specs/cloudkit-mirroring` Req 3.1 — why backup export is no longer a consumer+ of the quarantine latch
diff --git a/specs/multi-site-works/decision_log.md b/specs/multi-site-works/decision_log.mdindex 15159c3..b277041 100644--- a/specs/multi-site-works/decision_log.md+++ b/specs/multi-site-works/decision_log.md@@ -125,6 +125,7 @@ parallel and were numbered from Q90 and Q110 respectively. | Q119 | 2026-08-26 | Req 8.6's "recorded bands" was implemented as the *budgets* in `make test-performance-m4`, and Q89's not-met set is amended to include `diagnosis-refresh-*` | A "band" is the spread of a measurement and a "budget" is what the suite asserts; the requirement's SHALL reads as the first and the work applied the second, which is the checkable one. The three `diagnosis-refresh` arms went 0.300–0.302 s → 0.360–0.367 s against a 250 ms budget that was **already** a known issue before this branch, so the branch deepened a pre-existing breach rather than opening a new one — still inside the 400 ms ceiling, and belonging in Q89's list rather than unmentioned | | Q120 | 2026-08-26 | `MembershipReconciler.reattach` returns the Work ids it could not attach, and `dedupeMemberships` skips their rows — the orphan exclusion is by that set, never by `resolvedWorkID` being nil | The guard was `resolvedWorkID`, which an orphan always has: its `workID` column is how phase 0 finds it (Q37), so the "an orphan is keyless here" exclusion was dead and two orphan rows for one unarrived Work were deduped, deleting the loser against Req 8.3 and Q22. Phase 0 already fetches the orphans by predicate and every Work by id, so it can name the unarrived Works for free — asking `membership.work == nil` per row in phase 2 instead would fault one to-one relationship for every membership in the library, which is the cost Q84 keeps this pass clear of | | Q121 | 2026-08-29 | `.duplicateSiteRows` is diagnosed only while a row other than the winner still owns a title pattern or URL rule (`LibraryDiagnosis.duplicateRowsAwaitReconciliation`, shared by the validator and the tolerance scan); a duplicate whose losers own nothing is a settled state and produces no diagnosis (T-2295) | Decision 6 never deletes a stripped loser, so a diagnosis keyed on row count could never clear: Recent kept its "records could not be resolved" banner and attention edge up for good while Library Check said there was nothing to do — the shape Q57 already retired for duplicate application UUIDs. "No loser owns a rule" is exactly what one completed reconciler pass leaves, and what two untaught twins were from the start. Deleting the converged loser (the ticket's option 2) stays out: the cascade race Decision 6 guards against is real, and the symptom is fully closed without it. See `specs/bugfixes/reconciled-duplicate-banner/report.md` |+| Q122 | 2026-09-05 | A commit gate that validates **publishes what it validated**, over the hostnames it gated on, through `publishPostCommitDiagnoses(across:in:)`; and — with one exception — it validates through the hostname-scoped `LibraryValidator.validate(hostnames:context:)` rather than the whole-library pass (T-2291). The exception is the two teaching commits, `commitComposedTeaching` and `commitRecalculation`, which still call `LibraryValidator.validate(context:)`; they gate and publish over one hostname like the rest | Q100 made every gate refuse only a diagnosis it introduced but said nothing about the step after the save, and `removeSiteMembership` was computing its diagnoses and discarding them: a removal that dropped the very membership row whose malformed tuple diagnosed a site left that site quarantined for the rest of the session, because `LibraryDiagnostics.union` carries the tuple set forward rather than re-deriving it, so no foreground refresh can clear a stale entry. The same omission was T-2289 on three other gates. The scoping is the second half because a gate reads `diagnoses[h]` only for its own hostnames — the whole-library pass replayed every rule against every Entry and threw the rest away. **This supersedes Q110 of `specs/duplicate-reconciliation`** on its last clause: Q110 recorded `commitResolution`'s whole-graph pass as a deliberate throw-on-corruption net ("a resolution that corrupted the store would throw rather than pass"), which the scoping gives up — a gate's refusal is about the graph it wrote to, and Q110 was written before the scoped overload had any gate callers. Caveats, recorded at the overload: the scoped pass skips a Site-less hostname before its membership loop, so a malformed membership row there is a bootstrap diagnosis that a scoped gate clears while the damage stands; and it ranks a split Work group's rows within the hostname, so it reports a group the full pass does not when the group's winner holds no membership here — that one only ever quarantines. See `specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md` | ## Decision 1: Drop the superseded Work columns in V8, no dual-write window
Verified from the tree, not from the round-2 text: git log -- specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md → 7006b91 Fix T-1956 (#43), header **Ticket:** T-1956; git show 0c4f0e8 → Fix T-2289: Retired Rule Quarantine (#40), adding func publishPostCommitDiagnoses and three call sites. Merge order: #43 at 08:13, #40 at 08:34 on 2026-08-29. T-2289's own report (specs/bugfixes/retired-rule-quarantine/report.md) records no follow-up about removeSiteMembership.
Writers of quarantined: setQuarantine (refreshDiagnostics, +ConfirmImport.swift:120), markQuarantined/clearQuarantine (only via recordPostCommitDiagnosis), init. None is reachable from buildComposedTeachingBasis, the planner, applyComposedOutcome, applyManualAssignment; the withLockedContext body is a synchronous non-escaping closure with no await. For a one-element set introducedDiagnosis is exactly diagnoses[hostname] != quarantineReason(hostname).
BootstrapClassifierTests.classificationIsTotalAndWritesNothing(cell:), one cell of 288 — store=fullFamily marker=five historical=false artefact=true seededVersion=atOrAboveV5, "classifying the state changed it" at BootstrapClassifierTests.swift:76 — matches docs/agent-notes/testing.md:82-92 exactly. Isolated re-run: swift test --no-parallel --filter BootstrapClassifierTests, 15 tests, passed, exit 0. Not in a file this branch touches; fixed at source on PR #63.
SwiftPM wrote no default.profdata after the failing run (the recipe's note says coverage is exported only on success). The five .profraw files the run left in .build/arm64-apple-macosx/debug/codecov/ — copied before the isolated re-run started — were merged with llvm-profdata merge -sparse and exported with llvm-cov export -format=lcov against AsterismCorePackageTests. 350 source files. Coverage scope is the package, tier 3 of the skill's command selection (no Makefile or CLAUDE.md command emits JUnit).
From the exported lcov: +Sites.swift:199 (new publish) 3 hits; +WorkMerge.swift:132-133 13 and :482-483 18; +DuplicateResolution.swift:806 15; +ComposedTeaching.swift:250 74 and :368 9. Every changed executable line ran under the suite.
git status --porcelain empty before and after both runs; no untracked files appeared outside the inputs directory. No file was modified, staged, committed or pushed by this review; no device or Mac app was touched.