Round 2 of the pre-push review for PR #62. Diff is origin/main...HEAD (merge base 30c1573), three commits; the third, 876961d, answers round 1. Review-only run: no fixes were applied to the tree.
MembershipRemovalDiagnosisTests.swift no longer exists; MembershipRemovalTests.swift:153-160 asserts the quarantine clears, the tuple set moves, and a refresh does not re-latch. Verified red-without / green-with per the commit message and the report.+WorkMerge.swift comments and the overload doc — the Site-less-hostname divergence is now spelled out and left as a documented follow-up. One residual over-claim remains (below).publishPostCommitDiagnoses; Q122 added to specs/multi-site-works/decision_log.md:128.cloudkit-mirroring Req 3.1; and the new validate(hostnames:) doc / Q122 say every commit gate validates scoped — the two teaching gates do not.specs/duplicate-reconciliation recorded commitResolution's whole-graph pass as a deliberate safety net; this branch removes it without citing or superseding Q110. The report's "did not surface in the same grep" account is wrong — T-2289's Follow-up (a) named removeSiteMembership explicitly.quarantined, nil-clears path, and a family audit of every LibraryValidator.validate( call site.make test-performance-m4 are not implicated: no measured path calls a changed gate, and the scoped validator's body is unchanged.Needs fixes
3 failing tests — BootstrapActionTests ×2 and BootstrapClassifierTests ×1, the store-digest flake family named in docs/agent-notes/testing.md:82, none in a file this branch touches; both suites passed on an isolated re-run (make test-core CORE_TEST='BootstrapActionTests|BootstrapClassifierTests', exit 0), and the T-2293 sibling PR fixes the flake at source.
All three round-1 items are resolved: the duplicate suite and its copied fixture are gone and the bug-as-designed comment in MembershipRemovalTests.swift is now three assertions (latch, carried-forward tuple set, and a refreshDiagnostics() that does not re-latch); the validator-parity and "spelled identically" claims are qualified in the report, the PR body, the merge comments and the overload doc; the caller list is gone from publishPostCommitDiagnoses, the scoped overload names its current callers, and Q122 records the rule. The fix itself is correct and was re-verified this round: the closure under withLockedContext is synchronous and no writer of quarantined is reachable between the old priorDiagnosis capture and the gate, so the teaching-gate refactor is behaviour-preserving; nil clears through recordPostCommitDiagnosis; the family audit finds no remaining gate that computes diagnoses and discards them. What still needs fixing is prose that outruns the code, the same defect class round 1 sent back — two lines are must-fix: the report's Impact section says "backup export stays gated" for the session, but that gate was retired (Req 3.1 of cloudkit-mirroring; LibraryRepository.swift:143-145 says so); and the new validate(hostnames:) doc says it "is now the validator of every commit gate" while Q122 states the scoped-validation rule without exception — the two teaching gates still validate the whole library, which the report itself admits. The remaining minors (Q110 left stale, the "did not surface in the same grep" history, the untracked test gaps for commitMerge/commitResolution) are one-line edits. Tests: the full run failed on three cells of the known store-digest flake family (BootstrapActionTests ×2, BootstrapClassifierTests ×1), none in a file this branch touches; both suites passed in isolation (make test-core CORE_TEST='BootstrapActionTests|BootstrapClassifierTests', exit 0), and the T-2293 sibling PR fixes the flake at source.
Pass rate: 100% (2313 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 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 is repaired". Certain features (applying rules on capture, the Check Library screen) read that note.
One way to repair a site is to remove the damaged link between a work and that site. The removal worked, but the code forgot to update the in-memory note afterwards, so the site stayed flagged until you quit and relaunched the app even though the damage was gone. This branch adds the missing "update the note" step.
A user who fixed a problem kept seeing it reported, and rule application stayed off for that site, for the rest of the session. Nothing on disk was wrong — only the cached status lied.
LibraryRepository+Sites.swift — removeSiteMembership calls publishPostCommitDiagnoses(across: hostnames, in: diagnoses) after a successful save, over the same hostname set it validated and gated on (every hostname of the Work, not just the removed one). That helper writes both the quarantine latch and the diagnostics tuple set, and the second write is the one that matters: refreshDiagnostics() unions the carried-forward tuple set with the foreground scan, so a stale entry would re-latch one refresh later.+WorkMerge.swift, +DuplicateResolution.swift — commitWorkURL, commitMerge, commitResolution switch from LibraryValidator.validate(context:).quarantineMap() to validate(hostnames:context:) over the set each gate already reads.+ComposedTeaching.swift — both teaching gates drop the priorDiagnosis local and go through introducedDiagnosis(across: [hostname], in:), which reads the same latch at gate time.+WorkDeletion.swift, LibraryRepository.swift, LibraryValidator.swift; regression assertions in MembershipRemovalTests.swift; bugfix report; Q122.The fix is the missing fourth step of an existing pattern, using an existing helper. The three cleanups close follow-ups (b), (c), (d) of the T-2289 report; (a) is the fix. The teaching-gate refactor is safe because withLockedContext's closure is synchronous ((ModelContext) throws -> Value), so no actor interleaving can move quarantined between the old capture point and the gate, and no writer of it is reachable from the basis/projection code in between.
Narrowing the validator drops a whole-library re-check that the three gates never read, at the cost of one documented divergence: the scoped pass continues at the Site-winner guard, so a malformed membership row on a hostname with no Site row is diagnosed by the full pass and not by the scoped one. The branch documents this and leaves closing it (moving the membership arm above the guard) to a follow-up. Q110 of duplicate-reconciliation had recorded the whole-graph pass in commitResolution as a deliberate throw-on-corruption net; that property is gone for the gate and Q110 is not updated.
recordPostCommitDiagnosis(nil, hostname:) is the clearing path: clearQuarantine plus diagnostics.recordingTupleDiagnosis(nil, …), which filters the hostname's .siteTuple out of the diagnoses array (O(D log D) per hostname, D = library-wide diagnosis count, no observation trigger — the app pulls diagnostics on onMutation). publishPostCommitDiagnoses is that loop over Set(hostnames) with an empty-hostname filter; deleteWork still spells the loop by hand, and the new doc on the helper rationalises that rather than replacing it.
introducedDiagnosis compares diagnoses[h] against quarantineReason(h) — the latch, untouched inside the closure — over Set(hostnames).sorted(). For one hostname it reduces exactly to the old diagnosis != priorDiagnosis. The save-throws path publishes nothing, matching every sibling: the per-call ModelContext is discarded with its pending deletes and no autosave exists.
The scoped validator now runs on seven of eight diagnosis-computing sites (the two teaching gates and bootstrap keep the full pass). Per hostname it fetches Site, Entry, WorkSiteMembership, then hostnameWorks(groupingMemberships: false) fetches the memberships again plus ⌈W/500⌉ Work chunks — a pre-existing double fetch now on three more gates. Fetch-failure shape also differs: fromContext wraps into .invalidStateTuple(type: "Library", id: "graph"), the scoped pass rethrows raw; only the reason: text of the three gates' .invalidated outcomes changes.
The four gates commitWorkURL/commitMerge/commitResolution/removeSiteMembership are now textually identical in their validate → gate → save → publish tail; nothing enforces it. A gatedSave(context:hostnames:operation:) throws -> (hostname, diagnosis)? helper would make the compiler enforce the shape that two tickets (T-2289, T-2291) have now been about — worth a ticket, not this branch.
GroupOrdering winner lacks the membership on this hostname (the Req 8.1 lagging-writer state) the scoped pass reports the Work and the full pass does not. "Scoped reports more" cannot un-quarantine anything, but the doc's "the one divergence" is stronger than the code.introducedDiagnosis is a whole-value compare. Pre-existing (round 1), now on three more gates.commitMerge/commitResolution have no test reaching their post-write refusal or publish. The report's "Deliberately not done" covers the divergence, not the coverage.commitTeaching in +Contracts.swift:236 writes Site tuple state and saves with no gate at all — different defect class, out of scope, ticket-worthy.Fully implemented: the publish step in removeSiteMembership (T-2289 follow-up a); scoped validation on the three gates (c, extended); teaching gates through the shared helper (b); citation fix (d); regression assertions; Q122. Partially: the family's shape — four gates identical, three variants remain; documentation of the divergence — one of two divergences named; Q110 not superseded. Missing: nothing the ticket asked for. The report's Impact claim about backup export is wrong and its "why it occurred" contradicts the T-2289 report it cites.
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift
Why it matters. The fix. Without it a removal that repaired a hostname left it quarantined until relaunch, because refreshDiagnostics() unions the carried-forward tuple set rather than re-deriving it.
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift
Why it matters. Round-1 item 1. The existing Q67 test carried a comment saying this path 'does not republish the map after its save' — the defect recorded as design. The new suite that duplicated it is gone.
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift
Why it matters. Cost reduction on commitWorkURL, commitMerge and commitResolution, and the source of the documented divergence: a malformed membership row on a Site-less hostname is diagnosed by the full pass and not the scoped one.
What to look at. +WorkMerge.swift:132-133 ([hostname]) and :481-482 (Array(mergeHostnames)); +DuplicateResolution.swift:806 (hostnames)
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift
Why it matters. Removes the last two inline spellings of the introduced-diagnosis rule (T-2289 follow-up b). Behaviour-preserving only because nothing between the old priorDiagnosis capture and the gate can write the quarantine map.
Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift
Why it matters. Round-1 item 2 and 3 land here: the doc now says per-hostname equality holds 'for a hostname that has a Site row' and spells the Site-less divergence out. But 'it is now the validator of every commit gate' is false — the two teaching gates still call validate(context:).quarantineMap().
What to look at. LibraryValidator.swift:163-201 (doc only; body unchanged)
specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md
Why it matters. The permanent record. Mostly accurate and now honest about the family's remaining variation, but the Impact section says backup export stays gated (retired in cloudkit-mirroring Req 3.1) and 'Why it occurred' says removeSiteMembership 'did not surface in the same grep' during T-2289 when that report's Follow-up (a) lists it by name.
What to look at. report.md:36-39 (Impact), :78-86 (Why it occurred), :186-197 (Regression Test), :234-252 (Deliberately not done); decision_log.md:128 (Q122)
The gate validated Set(remaining).union([hostname]) (task 8 review: a removal changes the other site's graph too), so the publication covers the same set. A standing diagnosis on the surviving site is re-recorded unchanged, never cleared. Stated in the code comment and report.
Each of commitWorkURL, commitMerge, commitResolution only ever consulted diagnoses[h] for its own hostnames; the whole-library pass replayed every rule against every Entry and discarded the rest. T-2289 follow-up (c), ticket-named. The Site-less-hostname divergence this introduces on three more gates is documented at the overload, in the report and in Q122, and closing it is left as a validator change out of scope.
T-2289 follow-up (b). Equivalent for one hostname because the helper reads the same latch, which nothing inside the closure writes before the save. Report Resolution item 3.
Round-1 finding; commit 876961d. The existing Q67 test already seeded the scenario and carried the bug as a comment; a fourth copy of the diagnosed-store fixture bought nothing.
Q100 is a dated record of 2026-08-26 and omits commitWorkURL and the teaching gates; amending it would make the log less accurate. Q122 records the current rule, following Q121 / Q65 / Q66. Report, Deliberately not done.
Moving the membership arm above the Site-winner guard changes what the scoped pass reports rather than where it is called from — a validator change on a bugfix branch. Report, Deliberately not done. Not decided: whether Q110 of duplicate-reconciliation (whole-graph pass in commitResolution as a throw-on-corruption net) is superseded — it is not cited.
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. New docstring, LibraryRepository.swift:805-810. The same argument applies to the new 'every commit gate' sentence on the validator overload, which the branch did not apply to itself.
The helper doc says the deletion commit 'already holds the diagnoses in a loop'; the loop is the helper's body minus the empty-hostname filter. No reason is given for not replacing it.
(inferred — not stated by the author.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | report.md:36-39 (Impact) | "backup export stays gated, for the rest of the app session" is false. The export quarantine gate was retired by Req 3.1 of specs/cloudkit-mirroring: BackupV9Exporter.swift:75 says "The quarantine and unresolved gates are gone" and LibraryRepository.swift:143-145 says "its quarantine gate is gone (Req 3.1) and a quarantined library now exports". The stale entry's remaining consumers are capture rule application (+ReparseCapture.swift) and Check Library / Settings. | Not applied (review-only run). Drop the export clause from Impact. Pre-existing, optional: the LibraryDiagnostics.union doc still says "un-gating backup export". |
| minor | LibraryValidator.swift:171-174; decision_log.md:128 (Q122) | The new overload doc says it "is now the validator of every commit gate" and Q122 states the rule as "a commit gate ... validates through the hostname-scoped" overload. commitComposedTeaching (+ComposedTeaching.swift:245) and commitRecalculation (:360) still call LibraryValidator.validate(context:).quarantineMap(). The report admits this (:121-122); the docstring and Q122 contradict it. Same defect class as round-1 item 2. | Not applied. Either scope the two teaching gates too (they read one hostname and write its Site row, so the Site-less divergence does not apply) or reword both to "every commit gate but the two teaching commits". |
| minor | report.md:78-86 (Why it occurred) | Says removeSiteMembership "did not surface in the same grep" during T-2289. The T-2289 report's Follow-up (a) (specs/bugfixes/work-url-and-merge-commits-roll-back-on-any-diagnosis/report.md:138-144) names removeSiteMembership explicitly as never calling recordPostCommitDiagnosis. It was listed and deferred, not missed — and this report cites those follow-ups in Related. | Not applied. Reword: T-2289 recorded it as follow-up (a) and fixed the three gates in its own file. |
| minor | specs/duplicate-reconciliation/decision_log.md:116 (Q110) | Q110 records that commitResolution "validates the whole graph but reads only diagnoses[hostname]" and that "the validation still runs over the whole graph, so a resolution that corrupted the store would throw rather than pass". This branch narrows exactly that call (+DuplicateResolution.swift:806); the throw-on-corruption-anywhere property is gone for the gate. Q110 is not cited in the report or Q122 and is now stale, so "Behaviour is unchanged" (report:96) is stronger than the record supports. | Not applied. Cite Q110 from Q122 (or add "superseded by Q122 of multi-site-works" to Q110's rationale) and mention it under Deliberately not done. |
| minor | +WorkDeletion.swift:250-252; LibraryRepository.swift:805-807 | The deletion commit's `for hostname in hostnames { self.recordPostCommitDiagnosis(diagnoses[hostname], hostname: hostname) }` is publishPostCommitDiagnoses' body minus the empty-hostname filter. The branch edits the comment above it and rewrites the helper's doc to rationalise the loop ("already hold the diagnoses in a loop") instead of replacing it. | Not applied. Replace with publishPostCommitDiagnoses(across: hostnames, in: diagnoses) and drop the clause. Optional this branch. |
| minor | LibraryValidator.swift:166-168, :192 ("the one divergence") | A second scoped/full divergence exists: the scoped pass picks a Work group's winner among rows reachable from the hostname (hostnameWorks + entries.compactMap(\.work)), the full pass among every row in the library and reads hostnames off the winner's memberships (:522-547). For a duplicated-UUID group whose GroupOrdering winner lacks this hostname's membership (Req 8.1 lagging writer) the scoped pass records the Work and the full pass does not. Direction is "scoped reports more", so it cannot un-quarantine, but "the one divergence" and "the answer a whole-graph validation would give it" are stronger than the code. | Not applied. Soften "the one" or add a clause for the split-group arm. |
| minor | Test coverage: commitMerge, commitResolution, scoped-vs-full parity | Round-1 gaps stand and are not recorded as follow-ups: no test reaches commitMerge's or commitResolution's post-write introduced-diagnosis refusal or publish (CrossSiteMergeTests:195-205 is the pre-write Req 4.5 refusal; DuplicateResolutionTests:652/683 are gone/split reasons); nothing pins the documented Site-less-hostname divergence (MembershipValidationTests.nilMembershipSiteIsTolerated covers a well-formed row only). "Deliberately not done" covers the divergence, not the coverage. | Not applied. Add a follow-up bullet naming the three, or a Transit ticket. |
| minor | LibraryValidator.swift:221 + LibraryRepository.swift:1660 (hostnameWorks) | The scoped validator fetches a hostname's WorkSiteMembership rows, then hostnameWorks(groupingMemberships: false) fetches them again (predicate hostname == && work != nil) before its chunked Work fetch. Pre-existing shape from reconcileAfterSync; this branch puts it on three more gates. | Not applied. Extract the chunked by-id Work fetch into a helper and call it with Set(memberships.compactMap(\.workID)). Follow-up. |
| minor | LibraryRepository.swift:148-152 (refreshDiagnostics doc) | Pre-existing, same file, same rule: "the two passes that can repair a hostname without one invalidate it themselves: a teaching commit ... and a reconcile pass". Seven gates do now. | Not applied. One line pointing at publishPostCommitDiagnoses / Q122. |
| minor | report.md:1-4 (header) | No **Ticket:** T-2291 line. Every ticketed sibling report since late August has it (T-2290, T-2051, T-2116, T-1948, T-2289, T-2301, T-2287, T-1959, T-2288, T-1956); the eight without are older or unticketed. Round-1 nit, not taken. | Not applied. Add the line after **Status:**. |
| minor | report.md:190-202 (Known flake) | Quotes docs/agent-notes/testing.md's "one green full run" sentence, which the T-2293 sibling branch replaces with a Fixed note on merge; and attributes StoreMetadataTests.storeBornAtV5ReadsAtOrAboveV5 to "the same WAL-checkpoint nondeterminism", which neither testing.md nor the T-2293 report lists — the author's inference stated as fact. | Not applied. Date-stamp the citation and soften the StoreMetadata attribution. |
| nit | report.md:96 (Resolution item 2) | "Behaviour is unchanged and the cost is not" is contradicted two paragraphs later by "this change extends [the divergence] to commitWorkURL, commitMerge and commitResolution". | Not applied. "Unchanged for hostnames holding a Site row". |
| nit | LibraryRepository.swift:783-785 (introducedDiagnosis doc) | "Sorted, because every caller's hostname collection is a Set" — after this branch callers pass [hostname] arrays and hostnames: [String]. The Set(...).sorted() is still right; the sentence is not. | Not applied. "because the refusal names one hostname and callers pass unordered collections". |
| nit | validate(hostnames: [String]) signature; +Sites.swift:164 | Three call sites wrap a Set in Array(...) for the validator to Set(...).sorted() it again; the sibling helpers take some Sequence<String>. Also pre-existing: Set(remaining).union([hostname]) where remaining is already a Set. | No action for this ticket; optional signature change later. |
| nit | +WorkDeletion.swift:218-220 | "the citation here used to name specs/title-teaching-retroactive-parsing, whose Decision 8 is about backup V2 support" is changelog in a code comment; the report and git history hold it. | Not applied. Drop the second half of the parenthetical. |
| nit | MembershipRemovalTests.swift:180-189 | removalCommitsUnderAnUnchangedDiagnosis asserts quarantineReason(first.example) == prior but not diagnostics.tupleDiagnoses["first.example"] == prior, the mirror of the assertion its sibling adds. recordPostCommitDiagnosis writes both stores. | Not applied. One more #expect for symmetry. |
| nit | +ComposedTeaching.swift:268, :380 | The teaching gates still publish through recordPostCommitDiagnosis(diagnoses[hostname], hostname:) directly while their gate line now uses introducedDiagnosis(across: [hostname]). Correct for one hostname; publishPostCommitDiagnoses(across: [hostname], in:) would make all seven gates end the same way. | Optional. |
| nit | CHANGELOG.md | No entry. Convention is mixed (4 of the last 12 bugfix merges added one); T-2289, the same defect class, did. | Optional. |
| nit | +Contracts.swift:236 commitTeaching (out of scope) | Segment teaching writes a TitlePattern onto the Site and re-parses Entries/Works, then saves at :391 with no validation, gate or publication — the only Site-tuple write outside the gate family. Different defect class from T-2291; noticed during the family audit. | Not this branch. Worth a Transit ticket. |
Source: local run at 2026-09-05T16:57:59+10:00 · snapshot 876961d0450fca1200e8a6995c76d609e5f2cf24
Baseline: none
Execution: failed · JUnit: 1 file · Coverage: 1 file · Baseline: absent
Coverage scope: every test in the repository
Totals: 2313 passed · 3 failed · 31 skipped · 0 errored · 0 flaky
| Suite | Test | Job or artifact | Message |
|---|---|---|---|
| AsterismCoreTests.BootstrapActionTests | aLockTimeoutChangesNothing() | — | Expectation failed: try root.digest() == before (error): a lease the app could not take wrote something |
| AsterismCoreTests.BootstrapActionTests | aFailedOpenChangesNothing(refusal:) | — | Expectation failed: try root.digest() == before (error): markerRecordingAnUnknownVersion: the refused open wrote something |
| AsterismCoreTests.BootstrapClassifierTests | classificationIsTotalAndWritesNothing(cell:) | — | Expectation failed: try root.digest() == before (error): store=fullFamily marker=five historical=false artefact=false 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/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/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..3490faf 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift@@ -163,14 +163,17 @@ 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** the answer is the answer a whole-graph+ /// validation would give it, at the cost of that hostname rather than of the+ /// library. The one divergence is 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, each of which 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 +185,20 @@ 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).** 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. Neither pass repairs the row; only the two derivations disagree+ /// about who is told. Read the paragraph above as a per-hostname equality+ /// for a hostname holding a Site row, not as one across all inputs. public static func validate( hostnames: [String], context: ModelContext ) throws -> [String: LibraryValidationError] {
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..80c31fb--- /dev/null+++ b/specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md@@ -0,0 +1,261 @@+# 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 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. **One caveat, found in review:** the two derivations do *not*+agree per hostname in every state. 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.++**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: current callers, and the one arm 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.++## 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
diff --git a/specs/multi-site-works/decision_log.md b/specs/multi-site-works/decision_log.mdindex 15159c3..71df890 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 it validates through the hostname-scoped `LibraryValidator.validate(hostnames:context:)` rather than the whole-library pass (T-2291) | 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. Caveat, 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. See `specs/bugfixes/membership-removal-never-refreshes-quarantine/report.md` | ## Decision 1: Drop the superseded Work columns in V8, no dual-write window
Verified: withLockedContext's closure is (ModelContext) throws -> Value (LibraryRepository.swift:1537-1553), synchronous; every writer of quarantined (setQuarantine, markQuarantined, clearQuarantine, recordPostCommitDiagnosis, publishPostCommitDiagnoses) runs after a save, in refreshDiagnostics, in reconcileAfterSync after its lock, in +ConfirmImport.swift:120, or in test support — none reachable from the basis/projection code between the old capture and the gate.
Correct and matches every sibling. The per-call ModelContext is discarded with its pending deletes; no autosave. Publishing after a failed save would publish diagnoses for a graph never persisted.
BootstrapActionTests.aLockTimeoutChangesNothing, BootstrapActionTests.aFailedOpenChangesNothing(refusal: .markerRecordingAnUnknownVersion), BootstrapClassifierTests.classificationIsTotalAndWritesNothing(cell: store=fullFamily marker=five …) — all try root.digest() == before, the store-digest family in docs/agent-notes/testing.md:82-111. Neither file is touched by this branch. Isolated re-run through make test-core CORE_TEST='BootstrapActionTests|BootstrapClassifierTests': both suites passed, exit 0 (16:58:38–17:00:44 +10:00). The full run overlapped with sibling worktrees running the same suite. T-2293 (sibling PR) fixes the family at source.
The ecosystem recipe exports lcov only on a passing run; because the run failed on the flake, the five .profraw files from the run were merged by hand (llvm-profdata merge -sparse) and exported from the same test binary. Diff coverage is therefore from the full run, not the isolated re-run.
None of the M4 suites call a changed gate; reconcileAfterSync uses the scoped validator whose body is unchanged; diagnosis-refresh-* goes through LibraryToleranceScan, untouched. Not implicated in either direction.
fromContext wraps a fetch error into .invalidStateTuple(type: "Library", id: "graph", …); the scoped pass rethrows raw. Only the reason: text of the three narrowed gates' .invalidated outcomes changes. Not worth code; maybe a word in the overload doc.