asterism branch T-1956/bugfix-work-url-merge-commits-roll-back-on-any-diagnosis commits 2 (+ working tree) files 3 touched lines +343 / -2

Pre-push review: T-1956 Work URL commit gate

Commits aa4ea00 and 7133412 on top of origin/main (PR #43), plus the uncommitted fixes applied in this review. Second pass — the first returned Needs fixes only because the bugfix report was missing; it is now present.

At a glance

  • commitWorkURL now refuses only a diagnosis the write introduced; an unrelated pre-existing quarantine no longer rolls back a legal Work URL confirmation.
  • Two regression tests: commitsWhenDiagnosisUnchanged is the discriminating one (fails on old code); rollsBackNewDiagnosis guards the gate against being neutered.
  • Bugfix report present at specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md, structure matches sibling reports, all Q67/Q100/Decision 8 citations verified.
  • Uncommitted fixes from this review: test uses the public FixedRepositoryClock instead of a private copy; code comment credits Q67 and Q100 and names Decision 8's spec; test's Q46 citation names its spec; report's gate family lists all five gates.
  • Skipped (noted as follow-ups): a repaired-diagnosis test case, and the directory/branch name still saying "and merge commits" although commitMerge was investigated and cleared, not fixed.

Verdict

Ready to push

The production change is a one-line switch of commitWorkURL's rollback gate onto the existing introducedDiagnosis(across:in:) helper, bringing it in line with the other four commit gates. Three independent reviews found no correctness or efficiency defects; all findings were comment-accuracy nits and a duplicated test clock, which have been fixed in the working tree. make test-core passes in full (the focused suite was re-run after the fixes). The working-tree edits are uncommitted — commit them before pushing.

Review findings

7 raised · 4 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Asterism checks the health of its library after every write. Each website ("hostname") can carry a diagnosis — a note saying something about that site's data is broken. When you confirm the URL of a Work, the app writes the change, re-checks the library, and if the site now has a diagnosis it undoes the write.

The bug: the check asked "does this site have any diagnosis?" instead of "did this write cause a diagnosis?". So a site that was already flagged for some unrelated reason could never accept a Work URL confirmation — every attempt was undone and blamed on the old problem.

Why it matters

Users on an already-flagged site were stuck: a perfectly valid action failed with a confusing message about a problem it had nothing to do with. The fix compares the new diagnosis with the one the site already had and only undoes the write when something new appeared.

Key concepts

  • Diagnosis / quarantine: a recorded problem on a site, like a warning sticker on a shelf. The sticker stays until someone fixes the shelf.
  • Rollback: undoing a write before it is saved, as if it never happened.
  • Introduced diagnosis: a sticker that appeared because of this write, as opposed to one that was already there.

Changes overview

  • LibraryRepository+WorkMerge.swift: in commitWorkURL, the post-save validation gate changes from if let reason = diagnoses[hostname] to if let introduced = self.introducedDiagnosis(across: [hostname], in: diagnoses). The refusal message now interpolates introduced.diagnosis.
  • WorkURLDiagnosisComparisonTests.swift (new): a serialized suite with a private fixture that seeds raw SwiftData, runs one full LibraryValidator.validate, and builds a repository whose quarantine map comes from that validation — the same shape as bootstrap and as ReteachDiagnosisComparisonTests.
  • report.md: the standard bugfix report, with four out-of-scope follow-ups.

Implementation approach

introducedDiagnosis (in LibraryRepository.swift) compares the post-write diagnosis for each hostname against quarantineReason(hostname:), the latch left by the last full validation. Because nothing in commitWorkURL touches the quarantine map before the gate, and the whole method runs under withLockedContext(mode: .exclusive), that read is reliably the pre-write answer. The helper was already used by commitMerge, commitResolution, removeSiteMembership and commitWorkDeletion; this gate was the one left on the raw lookup when Q67/Q100 converted the others.

Trade-offs

The alternative — inlining the prior/post comparison — was rejected in the report because the shared helper exists precisely so the rule cannot be half-applied again. The fix deliberately does not add recordPostCommitDiagnosis after save (a commit that repairs a diagnosis leaves the quarantine latched until the next bootstrap); that is a pre-existing gap shared by the whole gate family and is listed as follow-up (a).

Technical deep dive

The gate change is semantically identical to the other four call sites: Set([hostname]).sorted() over a single element is negligible, and equality is on LibraryValidationError (Equatable), not severity. The interesting part is the second test. rollsBackNewDiagnosis needs to reach the gate rather than return .refreshed from the staleness check, so it corrupts Work B's membership workURLString between project and commit — a field Work A's buildWorkURLBasis never reads (it filters entries on $0.work?.id == work.id). That is the Q46 (library-integrity-tolerance) trick reused. Note that this test does not discriminate old from new code: with an empty quarantine map, introducedDiagnosis degenerates to "any diagnosis present". Only commitsWhenDiagnosisUnchanged proves the fix; the second test protects against a future regression that removes the gate.

Architecture impact

None beyond parity. Five gates now share one rule. Two sites in LibraryRepository+ComposedTeaching.swift still spell the comparison inline (follow-up b); they are functionally equivalent for a single hostname but are the remaining place the rule can drift.

Potential issues

  • A Work URL commit that repairs a diagnosis still leaves the stale quarantine latched in memory (follow-up a). No test pins this behaviour; adding one would document it rather than fix it.
  • commitWorkURL validates the whole graph where LibraryValidator.validate(hostnames:context:) would do (follow-up c) — pre-existing cost, not introduced here.
  • WorkDeletion.swift:216-217 cites the wrong spec's Decision 8 (follow-up d, verified).

Important changes — detailed

commitWorkURL: gate on introducedDiagnosis, not any diagnosis

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

Why it matters. The whole fix. A hostname carrying an unrelated pre-existing diagnosis could never accept a Work URL confirmation; now only a diagnosis this write introduced rolls it back.

What to look at. LibraryRepository+WorkMerge.swift:132-153

Takeaway. When a decision log converts a set of call sites onto a shared helper, grep for the old pattern (here `diagnoses[hostname]`) across every sibling gate before closing it out.
Rationale. Q67/Q100 of specs/multi-site-works established the introduced-only rule for the other gates; the report rejects inlining the comparison because the helper exists to keep the rule in one place.

WorkURLDiagnosisComparisonTests: unchanged-diagnosis commits

Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLDiagnosisComparisonTests.swift

Why it matters. This is the discriminating regression test — it fails on the old gate and passes on the new one, and also asserts the untouched diagnosis is still reported afterwards.

What to look at. commitsWhenDiagnosisUnchanged (lines 23-65)

Takeaway. Seed an illegal tuple the write cannot touch (two active title patterns), validate once to build the quarantine map the way bootstrap does, then commit — the pre-existing diagnosis must survive unchanged.
Rationale. Mirrors ReteachDiagnosisComparisonTests so the same rule is tested the same way across gates.

WorkURLDiagnosisComparisonTests: introduced diagnosis rolls back

Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLDiagnosisComparisonTests.swift

Why it matters. Guards the gate against being removed altogether; corrupts a sibling Work's URL between project and commit so the contract stays fresh and the gate, not the staleness check, refuses.

What to look at. rollsBackNewDiagnosis (lines 67-119)

Takeaway. To reach a post-commit gate in a test, mutate a field the projection basis does not observe (Q46 of library-integrity-tolerance).
Rationale. Does not discriminate old vs new code — with an empty quarantine map both behave the same — but documents what 'introduced' means. (inferred — not stated by the author)

Bugfix report with four verified follow-ups

specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md

Why it matters. Was the sole reason the previous review returned Needs fixes. Structure matches sibling reports; all citations checked against the decision logs and the follow-up about WorkDeletion.swift's wrong Decision 8 citation is accurate.

What to look at. report.md (whole file)

Takeaway. The follow-ups section is a useful pattern: name what you noticed and deliberately did not fix, with file:line, so the next reader does not re-investigate.
Rationale. Report scope kept to the one gate; the directory name's 'and merge commits' reflects the ticket's original framing, and the investigation cleared commitMerge.

Key decisions

Reuse introducedDiagnosis rather than inline the comparison.

Stated in the report's Alternatives Considered: the helper exists so the rule cannot be half-applied again; inlining would reintroduce that risk.

Do not add recordPostCommitDiagnosis to commitWorkURL.

Listed as follow-up (a). None of the five non-teaching gates call it today, so adding it to one would be a separate behavioural change rather than a parity fix.

Keep whole-graph validation in commitWorkURL.

Follow-up (c) notes the hostname-scoped overload would do. Not changed here; pre-existing cost.

Private test fixture instead of sharing ReteachFixture.

ReteachFixture is file-private; extracting shared test support is out of scope for a bugfix. The duplicated clock, however, was replaced with the public FixedRepositoryClock in this review.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorWorkURLDiagnosisComparisonTests.swift clockPrivate WorkURLDiagnosisClock (with an NSLock guarding a never-mutated value) duplicated the public FixedRepositoryClock already used by ~30 test files including WorkURLContractTests.Deleted the class; fixture now uses FixedRepositoryClock.
minorLibraryRepository+WorkMerge.swift commentComment credited Q100 alone with converting every other gate; Q100 itself credits Q67 for the deletion and membership-removal gates. Decision 8 was cited without its spec.Comment now reads 'Decision 8 (specs/library-integrity-tolerance)' and 'Q67 and Q100 (specs/multi-site-works)'.
nitWorkURLDiagnosisComparisonTests.swift Q46 citationBare 'Q46' is ambiguous: specs/multi-site-works also has an unrelated Q46.Citation now names specs/library-integrity-tolerance.
nitreport.md PreventionThe 'four commit-and-validate gates' family omitted commitWorkDeletion, which is gated the same way and listed under Code inspected.Now lists five gates.
minorTest coverageNo repaired-diagnosis case (post-commit diagnosis nil where the prior was non-nil). The sibling Reteach suite has one; here it would pin the stale-latch behaviour from follow-up (a).Skipped: it would document known-imperfect behaviour rather than the fix. Worth adding when follow-up (a) is done.
nitDirectory and branch name'work-url-and-merge-commits' suggests commitMerge was fixed; the report shows it was investigated and cleared.Skipped: renaming a spec directory and branch is more than a small fix; the report title and commit message are correctly scoped.
nitTest fixture duplicationWorkURLDiagnosisFixture copies ReteachFixture's init/seed/diagnosedRepository nearly verbatim (third such private fixture).Skipped: extracting shared test support is out of scope for a bugfix.

Per-file diffs

Click to expand.

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift Modified +12 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swiftindex 0d0c211..875a5b8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift@@ -132,10 +132,20 @@ extension LibraryRepository {             // The quarantine key is the membership's hostname, not the Work's             // primary one: the write happened on that site, and that is the site             // whose diagnoses can refuse it (Req 8.5).-            if let reason = diagnoses[hostname] {+            //+            // T-1956: this used to roll back on **any** post-commit diagnosis+            // for the hostname, with no comparison against what it already+            // carried. Decision 8 (`specs/library-integrity-tolerance`) applies+            // here exactly as it does to the teaching commits: refuse only for+            // a diagnosis this write **introduced**, not one the hostname+            // already had — an unrelated pre-existing diagnosis must not roll+            // back a legal Work URL confirmation. Q67 and Q100+            // (`specs/multi-site-works`) moved every other gate onto+            // `introducedDiagnosis` and missed this one.+            if let introduced = self.introducedDiagnosis(across: [hostname], in: diagnoses) {                 context.rollback()                 return .invalidated(-                    reason: "Work URL change produced an invalid library state: \(reason)"+                    reason: "Work URL change produced an invalid library state: \(introduced.diagnosis)"                 )             }             do {
Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLDiagnosisComparisonTests.swift Added +162 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLDiagnosisComparisonTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLDiagnosisComparisonTests.swiftnew file mode 100644index 0000000..a29d1ce--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLDiagnosisComparisonTests.swift@@ -0,0 +1,162 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// T-1956: `commitWorkURL` rolled back on **any** post-commit diagnosis for the+/// Work's hostname, with no comparison against what the hostname already+/// carried before the commit. That made confirming a Work URL fail with a+/// generic reason even on a hostname whose pre-existing diagnosis the commit+/// left completely untouched — the same defect Decision 8+/// (`specs/library-integrity-tolerance/decision_log.md`) identified and fixed+/// for `commitComposedTeaching` and `commitRecalculation`.+///+/// Mirrors `ReteachDiagnosisComparisonTests`: the comparison is equality+/// against the prior diagnosis, not "any diagnosis at all", and never against+/// severity — an unchanged diagnosis commits, and only a diagnosis the write+/// itself introduces rolls back.+@Suite("Work URL commit diagnosis comparison", .serialized)+struct WorkURLDiagnosisComparisonTests {+    private let host = "workurl-reteach.example"++    @Test("Work URL confirmation commits when the hostname's diagnosis is unchanged")+    func commitsWhenDiagnosisUnchanged() async throws {+        let fixture = try WorkURLDiagnosisFixture()+        let workID = UUID()+        // An illegal taught tuple — two active title patterns — which a Work+        // URL commit neither causes nor can repair. Before the fix, this alone+        // rolled back every Work URL confirmation on the hostname.+        try fixture.seed { context in+            let site = Site(hostname: self.host)+            site.mode = .taught+            context.insert(site)+            context.insert(try TitlePattern(+                version: 1, isActive: true, createdAt: Date(timeIntervalSince1970: 1),+                definition: .wholeTitle, site: site))+            context.insert(try TitlePattern(+                version: 2, isActive: true, createdAt: Date(timeIntervalSince1970: 2),+                definition: .wholeTitle, site: site))++            let work = Work.create(+                in: context, id: workID, title: "A Work", hostname: self.host,+                timestamp: Date(timeIntervalSince1970: 3))+            work.lastParsedTitle = "A Work"+            work.titleProvenanceRaw = TitleProvenance.parsed.rawValue+        }+        let repository = try fixture.diagnosedRepository()+        let before = try #require(await repository.quarantineReason(hostname: host))++        let contract = try await repository.projectWorkURL(+            workID: workID, hostname: host,+            request: .replaceManual("https://\(host)/confirmed"))+        let outcome = try await repository.commitWorkURL(contract)+        guard case .committed = outcome else {+            Issue.record("expected committed, got \(outcome)"); return+        }++        // The write landed…+        let context = fixture.freshContext()+        let work = try #require(try context.fetch(FetchDescriptor<Work>()).first { $0.id == workID })+        #expect(work.membershipValues.first?.workURLString == "https://\(host)/confirmed")+        // …and the diagnosis it did not touch is still recorded, not cleared by+        // a commit that repaired nothing.+        #expect(await repository.quarantineReason(hostname: host) == before)+    }++    @Test("Work URL confirmation rolls back when it would introduce a new diagnosis")+    func rollsBackNewDiagnosis() async throws {+        let fixture = try WorkURLDiagnosisFixture()+        let workAID = UUID()+        let workBID = UUID()+        try fixture.seed { context in+            let site = Site(hostname: self.host)+            site.mode = .taught+            context.insert(site)+            context.insert(try TitlePattern(+                version: 1, isActive: true, createdAt: Date(timeIntervalSince1970: 1),+                definition: .wholeTitle, site: site))++            let workA = Work.create(+                in: context, id: workAID, title: "Work A", hostname: self.host,+                timestamp: Date(timeIntervalSince1970: 2))+            workA.lastParsedTitle = "Work A"+            workA.titleProvenanceRaw = TitleProvenance.parsed.rawValue++            let workB = Work.create(+                in: context, id: workBID, title: "Work B", hostname: self.host,+                timestamp: Date(timeIntervalSince1970: 3))+            workB.lastParsedTitle = "Work B"+            workB.titleProvenanceRaw = TitleProvenance.parsed.rawValue+        }+        let repository = try fixture.diagnosedRepository()+        #expect(await repository.quarantineReason(hostname: host) == nil)++        let contract = try await repository.projectWorkURL(+            workID: workAID, hostname: host,+            request: .replaceManual("https://\(host)/a"))++        // A concurrent write between project and commit corrupts Work B's own+        // confirmed URL — a field Work A's basis does not observe, so the+        // contract does not go stale (mirrors Q46 of+        // `specs/library-integrity-tolerance` for `commitRecalculation`).+        // This is what "introduced" means: a diagnosis the hostname did not+        // carry before this commit.+        try fixture.seed { context in+            let workB = try context.fetch(FetchDescriptor<Work>()).first { $0.id == workBID }+            workB?.membershipValues.first?.workURLString = "not a url"+        }++        let outcome = try await repository.commitWorkURL(contract)+        guard case .invalidated(let reason) = outcome else {+            Issue.record("expected invalidated, got \(outcome)"); return+        }+        #expect(reason.contains("absolute HTTP"), "reason did not name the diagnosis: \(reason)")++        // Rolled back: Work A never received the confirmed URL.+        let context = fixture.freshContext()+        let workA = try #require(try context.fetch(FetchDescriptor<Work>()).first { $0.id == workAID })+        #expect(workA.membershipValues.first?.workURLString == nil)+    }+}++// MARK: - Fixture++/// A minimal analogue of `ReteachFixture` (`ReteachDiagnosisComparisonTests.swift`)+/// scoped to Work URL commits: raw SwiftData seeding, then a repository whose+/// quarantine map comes from one full validation — the same shape the+/// bootstrap uses.+private struct WorkURLDiagnosisFixture {+    let directory: URL+    let configuration: LibraryConfiguration+    let container: ModelContainer+    private let clock: FixedRepositoryClock++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismWorkURLDiagnosisTests-\(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()+    }++    /// Mirrors the bootstrap: one validation of the store as it stands feeds+    /// both the diagnoses and the quarantine projection.+    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/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md Added +169 / -0
diff --git a/specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md b/specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.mdnew file mode 100644index 0000000..2b5b0c7--- /dev/null+++ b/specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md@@ -0,0 +1,169 @@+# Bugfix Report: Work URL confirmation rolls back on any diagnosis, not just an introduced one++**Date:** 2026-08-29+**Status:** Fixed+**Ticket:** T-1956++## Description of the Issue++Confirming a Work URL on a hostname that already carried an unrelated,+pre-existing diagnosis failed with a generic "invalid library state" reason —+even though the confirmation itself was legal and touched nothing the+diagnosis was about.++**Reproduction steps:**+1. Teach a hostname such that it carries a diagnosis unrelated to Work URLs+   (e.g. two active title patterns on one Site).+2. On that same hostname, confirm a legal Work URL for a Work that has nothing+   to do with the diagnosis.+3. The commit rolls back and reports "Work URL change produced an invalid+   library state", quoting the pre-existing diagnosis, even though the commit+   introduced nothing new.++**Impact:** Any hostname already under quarantine for an unrelated reason+became unable to accept Work URL confirmations at all, with no way to tell+from the reason given that the diagnosis predated the write.++## Investigation Summary++- **Symptoms examined:** a Work URL commit refused with a diagnosis that+  named a defect the commit did not cause and could not repair.+- **Code inspected:** `LibraryRepository+WorkMerge.swift` (`commitWorkURL`,+  `commitMerge`), `LibraryRepository.swift` (`introducedDiagnosis(across:in:)`,+  `recordPostCommitDiagnosis`), `LibraryRepository+DuplicateResolution.swift`+  (`commitResolution`), `LibraryRepository+Sites.swift`+  (`removeSiteMembership`), `LibraryRepository+ComposedTeaching.swift`.+- **Hypotheses tested:**+  - Validation itself mis-diagnosing the write — ruled out; the diagnosis was+    correctly attributed to the pre-existing state, just not compared against+    it.+  - `commitMerge` suffering the same defect — ruled out; it was already gated+    on `introducedDiagnosis`.+  - `commitWorkURL` comparing against the prior diagnosis before refusing —+    confirmed absent; it gated on the raw post-commit `diagnoses[hostname]`+    lookup with no prior-state comparison.++## Discovered Root Cause++`commitWorkURL` gated its rollback on `diagnoses[hostname]` directly — any+diagnosis present for the hostname after the write rolled the commit back,+regardless of whether that diagnosis existed before the write. Q100 of+`specs/multi-site-works` converted the other commit gates+(`commitMerge`, `commitResolution`'s resolution/deletion/membership-removal+gates) onto the shared `introducedDiagnosis(across:in:)` helper, which refuses+only a diagnosis the write itself introduced — comparing against what the+hostname already carried. `commitWorkURL` was missed in that pass and kept the+older, over-broad comparison.++**Defect type:** Logic error (missing prior-state comparison; a converted+sibling was left out of the conversion).++**Why it occurred:** Q100 named "the merge and duplicate-resolution gates" as+the ones changing to match Q67's rule, having already covered deletion and+membership-removal in Q67 itself. `commitWorkURL` was not named in either pass+and was overlooked.++## Resolution for the Issue++**Changes made:**+- `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift`+  — `commitWorkURL`'s rollback gate now calls+  `self.introducedDiagnosis(across: [hostname], in: diagnoses)` instead of+  reading `diagnoses[hostname]` directly, matching `commitMerge` and the other+  converted gates. The surrounding comment cites Q100 of+  `specs/multi-site-works` as the pass that converted every other gate and+  missed this one.++**Approach rationale:** a one-line switch to the helper every other commit+gate already uses — no new comparison logic, no behavioural change beyond+what Q67/Q100 already established as correct.++**Alternatives considered:**+- Duplicate the prior/post comparison inline in `commitWorkURL` — rejected;+  the whole point of Q100 was one helper so the rule cannot be half-applied+  again, and inlining here would reintroduce that risk.++## Regression Test++**Test file:** `Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLDiagnosisComparisonTests.swift`+**Test names:** `commitsWhenDiagnosisUnchanged`, `rollsBackNewDiagnosis`++**What it verifies:**+- `commitsWhenDiagnosisUnchanged`: a hostname carrying an unrelated+  pre-existing diagnosis (two active title patterns on one Site) still+  commits a legal Work URL confirmation, and the pre-existing diagnosis is+  still reported afterward — the commit neither caused it nor cleared it.+- `rollsBackNewDiagnosis`: a diagnosis genuinely introduced by the write (a+  concurrent mutation that corrupts another Work's confirmed URL on the same+  hostname between project and commit) still rolls back the commit and+  reports the introduced diagnosis by name.++**Run command:** `make test-core CORE_TEST='WorkURLDiagnosisComparisonTests'`++## Affected Files++| File | Change |+|------|--------|+| `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift` | `commitWorkURL` gate switched to `introducedDiagnosis(across:in:)`; comment cites Q100 |+| `Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLDiagnosisComparisonTests.swift` | new regression tests |++## Verification++**Automated:**+- [x] Focused regression test passes (`make test-core CORE_TEST='WorkURLDiagnosisComparisonTests'`, 2026-08-29)+- [x] Full test suite passes (`make test-core`, 2026-08-29)+- [x] No compiler warnings in the changed files (no code-style linter is configured in this repo)++**Manual verification:**+- Not performed on device in this session. To verify: confirm a Work URL on a+  hostname already under quarantine for an unrelated reason; the confirmation+  should commit, and the quarantine reason should be unchanged afterward.++## Prevention++**Recommendations to avoid similar bugs:**+- When a decision log entry converts a set of call sites onto a shared+  helper, grep for every other call site sharing the old, unconverted pattern+  before closing the decision out — a Q entry naming specific gates is easy+  to under-scope if a sibling gate uses the same defect in different words.+- The five commit-and-validate gates that write to a hostname's graph+  (`commitWorkURL`, `commitMerge`, `commitResolution`, `removeSiteMembership`,+  `commitWorkDeletion`) are a natural family; a future audit of one is a+  reasonable trigger to re-check all five.++## Follow-ups (out of scope)++These were noticed while investigating this bug but are not part of this fix:++- (a) `commitWorkURL`, `commitMerge`, `commitResolution`, and+  `removeSiteMembership` never call `recordPostCommitDiagnosis` after a+  successful save. A commit that happens to *repair* a diagnosis (clears what+  was previously quarantined) leaves the stale quarantine latched in the+  repository's in-memory map until the next bootstrap — only the teaching+  commits (`LibraryRepository+ComposedTeaching.swift`) and+  `LibraryRepository+WorkDeletion.swift` call it today.+- (b) `LibraryRepository+ComposedTeaching.swift` still spells the+  introduced-diagnosis comparison inline at two sites (around lines 246 and+  366: `if let diagnosis = diagnoses[hostname], diagnosis != priorDiagnosis`)+  rather than calling `introducedDiagnosis(across:in:)`. Functionally+  equivalent for a single hostname, but it is a second place the rule can+  drift from the shared helper.+- (c) `commitWorkURL` validates the whole graph+  (`LibraryValidator.validate(context:)`) where the hostname-scoped overload+  (`LibraryValidator.validate(hostnames:context:)`, used by+  `removeSiteMembership`) would do — a Work URL confirmation only ever+  touches one hostname's membership.+- (d) `WorkDeletion.swift:216-217` cites "Decision 8 of+  `specs/title-teaching-retroactive-parsing`" for the introduced-diagnosis+  rule. That spec's Decision 8 is "Support Current Backup V2 Only" — unrelated+  content. The rule actually originates at Q67 of `specs/multi-site-works`+  (extended to the merge and duplicate-resolution gates at Q100 of the same+  spec's decision log); the citation should point there instead.++## Related++- T-1956 (this fix)+- `specs/multi-site-works/decision_log.md` Q67, Q100+- `specs/bugfixes/title-gate-blocks-identity-attachment/report.md` — another+  bugfix that traced a commit-gate defect to an over-broad condition applied+  where a narrower one belonged

Things to double-check

Commit the working-tree fixes before pushing.

Three files carry uncommitted edits from this review; git diff shows them. The focused suite passed after the edits; the full make test-core ran before the (comment-and-clock-only) edits.

Follow-up (a): stale quarantine after a repairing commit.

Pre-existing across all five gates; if you want it tracked, it deserves its own ticket rather than living only in this report.