asterism branch T-2291/bugfix-remove-site-membership-quarantine-refresh commits 2 files 7 touched (5 source, 1 test, 1 report) lines +431 / -26

Pre-push review: T-2291

removeSiteMembership now publishes its post-commit diagnoses, three commit gates switch to the hostname-scoped validator, and the two teaching gates route through introducedDiagnosis. Reviewed as the diff PR #62 adds to origin/main (30c1573..71c04a4). Review-only run: no code was modified.

At a glance

  • Fix verified. removeSiteMembership publishes over Set(remaining).union([hostname]) — every hostname of the Work — using the same diagnoses map it gated on, after the save, inside withLockedContext(.exclusive). Same placement as deleteWork, commitWorkURL, commitMerge, commitResolution.
  • Refactor verified. introducedDiagnosis reads quarantineReason — the same latch the removed priorDiagnosis captured. The lock closure is synchronous and only the post-save recordPostCommitDiagnosis writes the latch, so reading it at the gate is reading the pre-write value.
  • Narrowing verified as strictly less work: the full pass fetches seven whole tables and builds a LibraryDiagnostics.union that quarantineMap() throws away; the scoped pass fetches the two rule tables plus per-hostname rows. No performance band covers these gates.
  • Must fix: stale comment in MembershipRemovalTests.swift:146-150 now documents the bug as intended; the new suite duplicates that test's scenario on a copied fixture.
  • Overstated: "scoped map agrees with quarantineMap() per hostname" fails for a malformed membership on a hostname with no Site row (full pass records it at LibraryValidator.swift:473; scoped pass continues at :203). "Four gates spelled identically" — deletion and the teaching gates differ.
  • Ticket coverage: every item in T-2291 is addressed, including the optional teaching-gate item and the WorkDeletion citation; Q100 left as a dated record, which is right, but the repo's convention (Q65/Q66, Q26 of url-locator-generalisation) is a new quick-decision row per bugfix that changes a rule.

Verdict

Needs fixes

1 failing test — BootstrapClassifierTests.classificationIsTotalAndWritesNothing at the store=fullFamily, seededVersion=atOrAboveV5 cell, the store-digest flake named in docs/agent-notes/testing.md:90-92; it passed on an isolated re-run (make test-core CORE_TEST='BootstrapClassifierTests', exit 0) and touches nothing this branch changes. The verification run happened while two sibling worktrees were also running the core suite.

The fix itself is correct and complete against the ticket: the missing publishPostCommitDiagnoses call lands over the same hostname set the gate validated, after the save and inside the exclusive lock, exactly like its siblings; the validator narrowing and the introducedDiagnosis refactor were both verified behaviour-preserving for the hostnames each gate reads. What needs fixing before push is around the fix, not in it: MembershipRemovalTests.swift:146-150 still carries a comment stating that this path "does not republish the map after its save" — the bug, documented as designed behaviour — and that existing test is the same scenario the new clearsRepairedQuarantine re-seeds on a fourth private copy of the same fixture. Two claims in the report and PR description are also overstated: the scoped and full validators do not agree per hostname for a malformed membership on a Site-less hostname (pre-existing, but this change extends it to three more gates), and the gates are not "spelled identically" (deletion publishes by hand, the teaching gates still validate the whole library). All small; none change the fix.

Review findings

11 raised · 0 fixed · 11 skipped

Jump to findings →

Tests

Pass rate: 100% (2317 of 2318)

New tests: 2

Diff coverage: 100% (183 of 183 added lines)

Jump to tests →

Commits

Three-level explanation

What changed

Asterism keeps a library of works, each attached to one or more websites ("sites"). When the app starts, it checks every site's records; a site with damaged records is put in quarantine — a note in memory saying "don't trust this site's rules until it's repaired". Certain features (rule application on capture, backup export, the Check Library screen) look at that note.

One way to repair a site is to remove the damaged link between a work and that site. The removal itself worked, but the code forgot to update the in-memory note afterwards. So the site stayed flagged as broken until you quit and relaunched the app, even though the damage was already gone. This change adds the missing "update the note" step.

Why it matters

Without it, a user who fixed a problem kept seeing it reported, and features stayed disabled, for the rest of the session. Nothing was lost on disk — it was purely the cached status that lied.

Key concepts

  • Quarantine map: an in-memory dictionary hostname → diagnosis. Think of it as a sticky note on the fridge; someone has to take it down.
  • Commit gate: every write that changes a site's records validates the result first, refuses to save if it introduced a new problem, saves, then publishes what it found. Four steps; this path had only three.
  • Scoped validation: three other writes were checking the entire library to answer a question about one or two sites. They now check only the sites they touched. Same answer, much less work.

Changes overview

  • LibraryRepository+Sites.swiftremoveSiteMembership gains publishPostCommitDiagnoses(across: hostnames, in: diagnoses) after saveStrategy.save.
  • +WorkMerge.swift (commitWorkURL, commitMerge) and +DuplicateResolution.swift (commitResolution) — LibraryValidator.validate(context:).quarantineMap()validate(hostnames:context:) over the set each gate already reads.
  • +ComposedTeaching.swift — both teaching gates drop a captured priorDiagnosis and an inline diagnoses[hostname] != priorDiagnosis in favour of the shared introducedDiagnosis(across:in:).
  • +WorkDeletion.swift — comment citation corrected (Q67 of multi-site-works, not Decision 8 of title-teaching-retroactive-parsing).
  • New MembershipRemovalDiagnosisTests.swift with two tests and a private fixture; bugfix report.

Implementation approach

The repository is an actor; withLockedContext(mode: .exclusive) runs a synchronous closure, so within a gate there is no suspension point between mutation, validation and save. introducedDiagnosis compares each validated hostname's diagnosis with the current latch (quarantineReason); because nothing writes the latch before the post-save publish, that comparison is against the pre-write state — the same value the removed priorDiagnosis local held. publishPostCommitDiagnoses then writes each hostname's diagnosis (or nil) into quarantined and rebuilds the diagnostics tuple set that the foreground refreshDiagnostics() unions against and cannot re-derive.

Trade-offs

Re-deriving on the next foreground refresh was rejected: LibraryDiagnostics.union documents that the scan produces no .siteTuple, so stale entries persist. A shared validate-gate-save-publish helper was not introduced (agents concur it would need three or four knobs for gate-specific error mapping). The teaching gates were left on the whole-library validator — not named by the ticket, but the same narrowing argument applies to them.

Technical deep dive

Publish set. hostnames = Set(remaining).union([hostname]) (Sites.swift:165): every membership hostname across all rows of the Work's group, including the dropped one. The dropped hostname's diagnosis after the delete is nil if the removed row was the only fault, so publishing clears it; a standing fault on the surviving site is re-recorded unchanged (Q67 arm). Empty hostnames are skipped by the helper.

Scoped vs full validator. Same four arms in the same order (site → membership → work → entry), first failure per hostname wins, same isToleratedWrongHostWorkURL divert, quarantineMap() is .siteTuple only. One divergence: the full pass's membership arm records under membership.hostname with no Site check (LibraryValidator.swift:462-473), the scoped pass hits guard let winner = SiteResolutionOrder.sorted(rows).first else { continue } (:203) before its membership loop. A malformed membership on a Site-less hostname is therefore quarantined at bootstrap and cleared by any scoped gate that publishes over it while the damage stands. Pre-existing for deleteWork/removeSiteMembership/reconcileAfterSync; now also commitWorkURL/commitMerge/commitResolution. The overload's own doc ("a hostname with no Site row yields no diagnosis, exactly as the full pass does") is wrong for that arm.

Ordering. The scoped pass gets Works via hostnameWorks, which fetches an id set in chunks of bulkOperationBatchSize; with >500 Works and ≥2 independently faulty Works on one hostname, the first-failure winner can differ between bootstrap (rowid order) and gate, and introducedDiagnosis compares whole error values (record id embedded) — a legal write could be refused as "introduced". Pre-existing exposure, now on three more gates.

Architecture impact

Seven gates now share introducedDiagnosis; four share publishPostCommitDiagnoses (deletion loops recordPostCommitDiagnosis by hand, teaching calls it directly for one hostname). Five gates use the scoped validator, two do not. The report's "spelled identically" undersells the remaining variance. The character-resolution path that passes hostnames: [""] still yields [:], matching prior behaviour.

Potential issues

  • Stale comment in MembershipRemovalTests.swift:146-150 asserting the old behaviour; new suite duplicates that scenario.
  • No test reaches the post-write refusal or publish step of commitMerge/commitResolution; no parity test between validate(hostnames:) and quarantineMap() across diagnosis classes.
  • publishPostCommitDiagnoses docstring enumerates callers and is now stale; so is the scoped overload's "exists for reconcileAfterSync".

Important changes — detailed

Sites: removeSiteMembership publishes its post-commit diagnoses

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

Why it matters. The actual bug fix. Without it a removal that repaired a hostname left it quarantined for the session, disabling rule application on capture, Check Library and backup export.

What to look at. LibraryRepository+Sites.swift:188-199 (publishPostCommitDiagnoses over `hostnames`)

Takeaway. A commit gate that computes diagnoses must publish them; computing and discarding is the T-2289/T-2291 defect class. The publish set must equal the validated set, including the hostname being removed.
Rationale. The foreground refresh carries the tuple set forward (LibraryDiagnostics.union) rather than re-deriving it, so no later pass can clear a stale entry. Same step deleteWork and the teaching commits already take.

WorkMerge / DuplicateResolution: hostname-scoped validation

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

Why it matters. Cost: each gate was fetching every table and replaying every rule to read one or two hostnames. Also the place where the 'agree per hostname' claim is slightly too strong.

What to look at. LibraryRepository+WorkMerge.swift:118-131 and :469-483; LibraryRepository+DuplicateResolution.swift:801-806

Takeaway. validate(hostnames:context:) fetches the two rule tables whole (validate(site:) needs the full arrays) plus per-hostname Site/Entry/membership/Work rows; everything the full pass computed outside the set was discarded unread.
Rationale. Ticket item; the narrowing is what removeSiteMembership and deleteWork already did. Safe because each gate only ever reads diagnoses[h] for its own hostnames and both passes divert the wrong-host Work URL finding. Caveat found in review: a Site-less hostname's membership diagnosis is recorded by the full pass and skipped by the scoped one.

ComposedTeaching: both gates use introducedDiagnosis

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

Why it matters. Removes two inline spellings of the Q67 rule and their captured priorDiagnosis locals. Behaviour-preserving only because of an ordering invariant worth knowing.

What to look at. LibraryRepository+ComposedTeaching.swift:242-257 and :365-373

Takeaway. The latch (quarantined map) is only written by post-save publishes, and withLockedContext's closure is synchronous on the actor, so reading quarantineReason at the gate — after the mutation, before the save — still yields the pre-write value. That is the invariant the helper relies on everywhere.
Rationale. Optional ticket item: one rule in one place rather than three spellings. The teaching gates still validate the whole library, which the same narrowing argument would also cover.

Tests: MembershipRemovalDiagnosisTests on a fourth copy of the diagnosed-store fixture

Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalDiagnosisTests.swift

Why it matters. clearsRepairedQuarantine is the regression test and is red on main. But MembershipRemovalTests.removalThatClearsADiagnosisCommits already seeds the identical repair scenario on the shared M5Fixture and carries a comment documenting the bug as designed; keepsUnrepairedQuarantine duplicates removalCommitsUnderAnUnchangedDiagnosis almost line for line.

What to look at. MembershipRemovalDiagnosisTests.swift:1-161; compare MembershipRemovalTests.swift:129-179

Takeaway. Before writing a new fixture, grep the existing suite for the same operation: the existing test's comment named the missing publish step precisely.
Open question. Rationale not stated by the author and not inferable from the diff.

WorkDeletion: citation corrected

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

Why it matters. Comment-only, but the wrong spec was cited (title-teaching-retroactive-parsing Decision 8 is about backup V2 support). Verified: Q67 of multi-site-works extends Decision 8 of library-integrity-tolerance.

What to look at. LibraryRepository+WorkDeletion.swift:213-220

Takeaway. Cite decision-log entries by spec name and ID; two specs each have a Decision 8.
Rationale. Ticket names it under 'other minor cleanups'.

Key decisions

Publish over every hostname of the Work, not only the dropped one.

hostnames = Set(remaining).union([hostname]) is the set the gate validated (task 8 review comment in Sites.swift: a removal on a two-site Work changes the other site's graph too). Publishing over the same set keeps the latch consistent with the validation that authorised the save.

Scope the three commits to the hostnames they wrote to.

Named by the ticket. Each gate already read only diagnoses[h] for its own hostnames; the whole-library pass's other results were discarded. Report and code comments state the scoped overload answers a hostname as the full pass would. Review caveat: true except for the Site-less-hostname membership arm.

Route the teaching gates through introducedDiagnosis; drop priorDiagnosis.

Optional ticket item. Equivalent for one hostname because nothing writes the latch between the closure start and the post-save publish, and the closure is synchronous on the actor.

Leave the teaching gates on the whole-library validator.

Not named by the ticket. Their comment still says "Validate the complete prospective graph", which stays accurate. The same narrowing argument would apply; a follow-up if accepted.

(inferred — not stated by the author.)
Do not amend Q100 in specs/multi-site-works/decision_log.md.

Report's "Deliberately not done": Q100 is a dated record of 2026-08-26. Correct. The repo's convention for a bugfix that changes a rule is a new quick-decision row (Q65, Q66 in library-integrity-tolerance; Q26 in url-locator-generalisation), which the report did not consider.

No shared validate-gate-save-publish helper.

Each gate maps failure differently (throw invalidInput vs return .invalidated with gate-specific wording; resolution refetches Entry/Work after rollback). A helper would need three or four knobs to replace ~12 plain lines per gate.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorMembershipRemovalTests.swift:146-150Existing test removalThatClearsADiagnosisCommits seeds the same repair scenario as the new clearsRepairedQuarantine (on the shared M5Fixture) and carries a comment stating this path "does not republish the map after its save, so the removed site keeps a stale entry until the next full validation". After the fix that is false; the diff leaves it in place. removalCommitsUnderAnUnchangedDiagnosis already asserts the keepsUnrepairedQuarantine arm.Not applied (review-only run). Replace the comment with the two assertions (quarantineReason == nil; diagnostics.tupleDiagnoses[...] == nil, ideally after refreshDiagnostics()) and either drop the new suite and its copied fixture or state in the report why a second fixture is needed. Update the report's Regression Test section accordingly.
minorValidator parity claim (report, PR, +WorkMerge.swift comments, LibraryValidator.swift:186-187)The full pass's membership arm records under membership.hostname with no Site check (LibraryValidator.swift:462-473); the scoped pass continues at `guard let winner` (:203) before reaching its membership loop. A malformed membership on a Site-less hostname is quarantined at bootstrap and cleared by any scoped gate that publishes over it while the damage stands. Pre-existing on deleteWork/removeSiteMembership/reconcileAfterSync; this change extends it to three more gates and the report says the two maps agree per hostname.Not applied. Either move the membership arm above the Site-winner guard in validate(hostnames:) (two-line move; only the Entry arm needs the winner) or soften the claim in the report, PR and comments and open a follow-up ticket.
minorreport.md Prevention / Approach rationale"The four commit gates are now spelled identically" is unnamed and not true of the family: deleteWork publishes via a manual recordPostCommitDiagnosis loop, the teaching gates publish directly and still validate the whole library, commitResolution alone refetches after rollback, and only some gates log.Not applied. Name the four (commitWorkURL, commitMerge, commitResolution, removeSiteMembership) and state the remaining differences, or finish the sweep (deleteWork through publishPostCommitDiagnoses; teaching gates scoped).
minorLibraryRepository.swift:798-804, LibraryValidator.swift:171-174publishPostCommitDiagnoses' docstring enumerates its callers and omits membership removal; the scoped overload's doc says it "exists for reconcileAfterSync" while it is now the commit-gate validator for six callers.Not applied. Drop the caller lists or update them.
minorreport.md Verification"Regression tests fail before the fix" overstates: only clearsRepairedQuarantine is red without the publish call; keepsUnrepairedQuarantine passes before and after. The PR description gets this right.Not applied. Reword to match the PR description.
minorspecs/multi-site-works/decision_log.mdNo quick-decision row records that every commit gate now validates scoped and publishes; sibling bugfixes (Q65, Q66; Q26 of url-locator-generalisation) each added one.Not applied. Add one row after Q100 pointing at the bugfix report.
minorTest coverage of changed linescommitMerge and commitResolution have no test that reaches the post-write refusal or the publish step, and no test asserts validate(hostnames:) == validate(context:).quarantineMap() across several diagnosis classes. commitWorkURL and both teaching gates are covered (WorkURLDiagnosisComparisonTests, WrongHostWorkURLDiagnosisTests, ReteachDiagnosisComparisonTests).Not applied. One test each mirroring WorkURLDiagnosisComparisonTests.rollsBackNewDiagnosis, plus a small parity table test — or record as follow-ups.
minorLibraryValidator.swift:200-224 (pre-existing, now reached by three more gates)Per-hostname Work order in the scoped pass comes from hostnameWorks' chunked id-set fetch and is not stable across runs for >500 Works; with two independently faulty Works the first-failure winner can differ from bootstrap and introducedDiagnosis (whole-value compare) would refuse a legal write. Also fetches the hostname's memberships twice.Not applied. Sort winners by id before validating in both passes, or note under Deliberately not done. Follow-up.
nitMembershipRemovalDiagnosisTests.swift:261, :275, :335-347Precondition asserts only quarantineReason != nil (could pin the membership tuple diagnosis); tupleDiagnoses read tests state rather than the refresh behaviour (RefreshUnionInvariantTests pattern is refreshDiagnostics() then quarantineReason == nil); fixture never removes its temp directory (matches the fixture it copies; most other fixtures clean up).Not applied. Moot if the new suite is folded into MembershipRemovalTests.
nitvalidate(hostnames: [String]) signatureThree call sites wrap a Set in Array(...) only for the validator to Set(...).sorted() it again; introducedDiagnosis and publishPostCommitDiagnoses already take some Sequence<String>.No action for this ticket; optional signature change later.
nitreport.md header; commit 725e8c3Header lacks the `**Ticket:** T-2291` line every recent sibling carries. The 'failing test' commit seeded bare .taught sites, so it was red for a reason the fix cannot repair (the seeds were corrected in the second commit); the final test was verified red-then-green per the report.Not applied. Add the Ticket line; no action on the commit.

Tests

Source: local run at 2026-09-05T16:03:32+10:00 · snapshot 71c04a49cdba63e29b81c939cf42c8f33d63902f

Baseline: none

Execution: failed · JUnit: 1 file · Coverage: 1 file · Baseline: absent

Coverage scope: every test in the repository

Totals: 2317 passed · 1 failed · 31 skipped · 0 errored · 0 flaky

Failed tests

SuiteTestJob or artifactMessage
AsterismCoreTests.BootstrapClassifierTestsclassificationIsTotalAndWritesNothing(cell:)Expectation failed: try root.digest() == before (error): store=fullFamily marker=absent historical=false artefact=true seededVersion=atOrAboveV5: classifying the state changed it

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Diff coverage

FileAdded linesCoveredDiff coverage
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift1919100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift44100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift1212100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift55100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift2222100%
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalDiagnosisTests.swift161121100%
specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md208no coverage data

Aggregate diff coverage: 100% (183 of 183 measurable added lines).

Overall coverage

Head 93.8% (77801 of 82986 lines)

6 of 7 changed files matched coverage data.

Blast radius

Files 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.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Per-file diffs

Click to expand.

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift Modified +19 / -16
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) }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift Modified +4 / -1
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>())
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift Modified +12 / -0
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)         }     } 
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift Modified +5 / -2
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(
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift Modified +22 / -7
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swiftindex ad0b45f..feae8bc 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift@@ -115,11 +115,20 @@ 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 the+            // answer a whole-graph validation would give it (and, like+            // `quarantineMap()`, reports the tuple class alone).             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 +466,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(
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalDiagnosisTests.swift Added +161 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalDiagnosisTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalDiagnosisTests.swiftnew file mode 100644index 0000000..fc8497d--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalDiagnosisTests.swift@@ -0,0 +1,161 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// T-2291: `removeSiteMembership` validated and gated on the introduced+/// diagnosis, saved, and then returned without ever publishing what it left+/// behind. A removal that *repaired* a hostname — dropping the very membership+/// row whose malformed tuple diagnosed it — left the hostname quarantined until+/// the next launch, because the foreground refresh carries the tuple set+/// forward rather than re-deriving it.+///+/// This is the T-2289 defect on the one write path T-2289 missed. The sibling+/// `LibraryRepository+WorkDeletion.swift` gates *and* publishes; so does every+/// teaching commit, and since T-2289 so do the Work URL, Merge and+/// duplicate-resolution commits.+@Suite("Membership removal post-commit diagnosis", .serialized)+struct MembershipRemovalDiagnosisTests {+    private let kept = "membership-kept.example"+    private let dropped = "membership-dropped.example"++    @Test("Removing the membership that diagnosed a hostname clears its quarantine")+    func clearsRepairedQuarantine() async throws {+        let fixture = try MembershipRemovalDiagnosisFixture()+        let workID = UUID()+        try fixture.seed { context in+            // Two legal taught sites: `.taught` wants exactly one active title+            // rule, so a bare row would diagnose the hostname for a reason the+            // removal cannot repair and the test would prove nothing.+            for (index, hostname) in [self.kept, self.dropped].enumerated() {+                let site = Site(hostname: hostname)+                site.mode = .taught+                context.insert(site)+                context.insert(try TitlePattern(+                    version: 1, isActive: true,+                    createdAt: Date(timeIntervalSince1970: TimeInterval(index + 1)),+                    definition: .wholeTitle, site: site))+            }+            let work = Work.create(+                in: context, id: workID, title: "A Work", hostname: self.kept,+                timestamp: Date(timeIntervalSince1970: 3))+            work.lastParsedTitle = "A Work"+            work.titleProvenanceRaw = TitleProvenance.parsed.rawValue++            // The membership that diagnoses `dropped`: an identity state spelling+            // nothing in the closed set. Record-local damage no pass repairs —+            // exactly the class that quarantines a hostname — and removing the+            // row is what repairs it.+            let membership = WorkSiteMembership(+                hostname: self.dropped, createdAt: Date(timeIntervalSince1970: 4),+                workID: workID, work: work,+                site: try LibraryRepository.fetchSites(+                    hostname: self.dropped, context: context).first)+            membership.urlIdentityStateRaw = "not-a-state"+            context.insert(membership)+        }+        let repository = try fixture.diagnosedRepository()+        #expect(await repository.quarantineReason(hostname: dropped) != nil)++        try await repository.removeSiteMembership(workID: workID, hostname: dropped)++        // The row is gone…+        let context = fixture.freshContext()+        #expect(+            try context.fetch(FetchDescriptor<WorkSiteMembership>())+                .map(\.hostname).sorted() == [kept])+        // …and the quarantine the row caused went with it, without waiting for+        // the next launch.+        #expect(await repository.quarantineReason(hostname: dropped) == nil)+        // The tuple set the next foreground refresh unions against moved too,+        // or the refresh would re-quarantine the hostname this removal repaired.+        #expect(await repository.diagnostics.tupleDiagnoses[dropped] == nil)+    }++    @Test("A diagnosis the removal did not repair survives it")+    func keepsUnrepairedQuarantine() async throws {+        let fixture = try MembershipRemovalDiagnosisFixture()+        let workID = UUID()+        try fixture.seed { context in+            let keptSite = Site(hostname: self.kept)+            keptSite.mode = .taught+            context.insert(keptSite)+            // An illegal taught tuple on the site the Work stays on — two active+            // title patterns — which dropping the other site's membership+            // neither causes nor can repair.+            context.insert(try TitlePattern(+                version: 1, isActive: true, createdAt: Date(timeIntervalSince1970: 1),+                definition: .wholeTitle, site: keptSite))+            context.insert(try TitlePattern(+                version: 2, isActive: true, createdAt: Date(timeIntervalSince1970: 2),+                definition: .wholeTitle, site: keptSite))++            // Legal, so the only diagnosis in play is the one on `kept`.+            let droppedSite = Site(hostname: self.dropped)+            droppedSite.mode = .taught+            context.insert(droppedSite)+            context.insert(try TitlePattern(+                version: 1, isActive: true, createdAt: Date(timeIntervalSince1970: 3),+                definition: .wholeTitle, site: droppedSite))++            let work = Work.create(+                in: context, id: workID, title: "A Work", hostname: self.kept,+                timestamp: Date(timeIntervalSince1970: 4))+            work.lastParsedTitle = "A Work"+            work.titleProvenanceRaw = TitleProvenance.parsed.rawValue+            context.insert(WorkSiteMembership(+                hostname: self.dropped, createdAt: Date(timeIntervalSince1970: 5),+                workID: workID, work: work, site: droppedSite))+        }+        let repository = try fixture.diagnosedRepository()+        let before = try #require(await repository.quarantineReason(hostname: kept))++        try await repository.removeSiteMembership(workID: workID, hostname: dropped)++        // The removal landed over a hostname it did not introduce a diagnosis+        // on (Q67), and the diagnosis it repaired nothing of is still recorded.+        #expect(await repository.quarantineReason(hostname: kept) == before)+    }+}++// MARK: - Fixture++/// The shape `WorkURLDiagnosisFixture` uses, scoped to membership removal: raw+/// SwiftData seeding, then a repository whose quarantine map and diagnostics+/// come from one full validation — what the bootstrap does.+private struct MembershipRemovalDiagnosisFixture {+    let directory: URL+    let configuration: LibraryConfiguration+    let container: ModelContainer+    private let clock: FixedRepositoryClock++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(+                path: "AsterismMembershipRemovalDiagnosisTests-\(UUID())",+                directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+        try FileManager.default.createDirectory(+            at: configuration.storeURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)+        container = try LibraryRepository.openContainer(at: configuration.storeURL)+        clock = FixedRepositoryClock(Date(timeIntervalSince1970: 1_800_000_000))+    }++    func freshContext() -> ModelContext { ModelContext(container) }++    func seed(_ mutate: (ModelContext) throws -> Void) throws {+        let context = ModelContext(container)+        try mutate(context)+        try context.save()+    }++    func diagnosedRepository() throws -> LibraryRepository {+        let diagnostics = try LibraryValidator.validate(context: freshContext())+        return LibraryRepository.makeRepository(+            configuration, container, .m4, clock, ModelContextSaveStrategy(),+            quarantined: diagnostics.quarantineMap(), diagnostics: diagnostics)+    }+}
specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md Added +208 / -0
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..ad1f044--- /dev/null+++ b/specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md@@ -0,0 +1,208 @@+# Bugfix Report: removeSiteMembership never refreshes the cached quarantine++**Date:** 2026-09-05+**Status:** Fixed++## 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+stays disabled for the repaired hostname, Check Library keeps reporting it, and+backup export stays gated, for the rest of the app session.++## 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 but did not include the+membership-removal path, which lives in a different file and was already using+the hostname-scoped validator (so it did not surface in the same grep).++**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 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.++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 keeps the four gates spelled the same way.+The validator narrowing is safe because each gate only ever consulted its own+hostnames: the scoped overload answers a hostname exactly as a whole-graph+validation would (its own contract), and both passes divert the tolerated+wrong-host Work URL finding out of the tuple map, so `quarantineMap()` and the+scoped map agree per hostname.++**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/MembershipRemovalDiagnosisTests.swift`+**Test names:** `clearsRepairedQuarantine`, `keepsUnrepairedQuarantine`++**What it verifies:**+- `clearsRepairedQuarantine`: a hostname quarantined by a malformed membership+  tuple has its quarantine — and its entry in the carried-forward tuple set —+  cleared by the removal that deletes the offending row.+- `keepsUnrepairedQuarantine`: a diagnosis on the hostname the Work stays on+  (two active title patterns) is untouched by the removal, so the publication+  does not over-clear.++**Run command:** `make test-core CORE_TEST='MembershipRemovalDiagnosisTests'`++## 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/Tests/AsterismCoreTests/MembershipRemovalDiagnosisTests.swift` | new regression tests |++## Verification++**Automated:**+- [x] Regression tests fail before the fix and pass after it (verified by+      disabling the new `publishPostCommitDiagnoses` call and re-running)+- [x] `make test-core` — one clean full run, exit 0 (2026-09-05)+- [x] No new compiler warnings (no code-style linter is configured in this repo)++**Known flake seen during verification:** two further full runs each failed on a+different cell of the store-digest family+(`BootstrapActionTests` / `BootstrapClassifierTests`, `try root.digest() == before`).+A full run on the unmodified tree reproduced the same failures, and the suites+pass in isolation. This is the family already documented in+`docs/agent-notes/testing.md` ("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:**+- The four commit gates 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 now visibly different from its siblings rather than merely+  shorter.+- 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. The current state of the rule is recorded here and in+  the code comments instead.++## 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

Things to double-check

Site-less hostname clearing.

Reproduce mentally or in a test: a membership row on x.example with urlIdentityStateRaw = "bad" and no Site row for x.example. Bootstrap quarantines it via the full pass; a merge whose mergeHostnames contains x.example now publishes nil for it. Decide whether that clearing is acceptable (nothing gated on a Site-less hostname's rules) or whether backup-export gating makes it matter.

Store-digest flake under load.

The verification run here executed while two sibling worktrees (T-1910 release, T-2293) were also running the core suite. Any BootstrapActionTests/BootstrapClassifierTests digest failure in this run is the family documented in docs/agent-notes/testing.md, not this change — but confirm it is that cell and nothing else before dismissing it.

Publish set on the dropped hostname when the Site row is duplicated.

validate(hostnames:) validates every Site row for a hostname but picks one winner for the Entry arm. If dropped has two Site rows, the scoped diagnosis after removal may still be non-nil for a Site-tuple reason; publishing re-records it unchanged, which is the intended Q67 arm. Nothing to change, but worth knowing when reading the test.