PR #47 — git diff origin/main...HEAD. The Library Check screen offered a “Re-teach” button for a .siteTuple diagnosis even when the hostname had no Entry left to enter the composed teaching surface from; the tap hit a nil and did nothing.
LibraryDiagnosticsModel gains a canReteach: (String) -> Bool closure; a .siteTuple row only exposes reteachHostname when the class is clearable and the closure says yes.AppLibraryModel.libraryDiagnosticsModel() wires it to the existing canComposeTeaching(forHostname:) — the same predicate composedTeachingModel(forHostname:) guards on, so the button is withheld iff the tap would have hit nil.reteach(hostname:) re-checks the live predicate, so a direct call after an Entry is deleted mid-screen is also refused.make test-core pass, make test-quick pass, 0 compiler warnings. Existing UI test LibraryDiagnosticsUITests unaffected (its fixture seeds an Entry).Ready to push
The fix is small, mirrors the existing SiteDetailModel.canReteach guard, and is exactly what Q53 prescribed. make test-core and make test-quick both pass with no new compiler warnings; the two new model tests run and pass. Two review agents raised no blocking or major findings. One doc line was edited locally (Q53 now points at Q65) and is left uncommitted. The one worthwhile follow-up is a test of the AppLibraryModel.libraryDiagnosticsModel() wiring, which is the only untested line of the fix.
70f9c50 Fix T-1948: Re-teach button does nothing for a rule-invalid site with no entries fd158a2 T-1948: keep decision-log quick decisions in ID order working-tree Fixes applied in this review Asterism has a “Library Check” screen that lists problems it found in your notes library. One kind of problem (a site whose saved rules are broken) can be fixed by teaching the site again, so the screen shows a “Re-teach” button. But re-teaching works by opening one of the notes you saved from that site — and if you had deleted all of them, there was nothing to open. The button still appeared, and tapping it did nothing at all.
Now the screen asks first: “is there actually a note to re-teach from?” If not, the button is hidden and the row explains why instead.
A button that looks active but does nothing is confusing, and this was the only way to fix that kind of problem, so the reader was stuck with no explanation.
MaintenanceViewModels.swift — LibraryDiagnosticsModel.init takes a new canReteach: @MainActor (String) -> Bool (default { _ in true }). row(_:) becomes an instance method so it can consult it: reteachHostname is set only when diagnosis.clearableByReteaching && hostname.map(canReteach) == true. resolution(_:canReteach:) picks a different sentence when withheld. reteach(hostname:) gains a guard canReteach(hostname).AppLibraryModel.swift — libraryDiagnosticsModel() passes { [weak self] in self?.canComposeTeaching(forHostname: $0) ?? false }.LibraryDiagnosticsModelTests.swift — helper grows a canReteach parameter; two new tests cover the withheld row (nil hostname + wording) and the refused direct call.Dependency injection by closure, matching the existing pendingConflicts closure on the same model. The model stays testable against MockLibraryProvider alone and never learns what an Entry is. The predicate reused, canComposeTeaching(forHostname:), is what SiteDetailModel.canReteach already uses on the Sites screen, so both screens answer the question identically.
load(), so an Entry synced in while the screen is open is not reflected until reopen — the same staleness the Sites screen already has.canComposeTeaching(forHostname:) is capabilities.supportsSegmentTeaching && repository != nil && recentPresentation.allRows.contains { $0.hostname == hostname }. Despite the name, RecentPresentation is built from an unbounded FetchDescriptor<Entry>(), so allRows is every Entry in the library — the check is a true “any Entry exists” predicate, not a window. It is populated by refreshAll() before state == .ready, and both diagnostics call sites are only reachable from the ready UI; the generation-race abandon keeps the previous presentation rather than clearing it, so a false negative (wrongly withheld button) is not reachable in practice. The contains is O(entries) per .siteTuple row per load; tuple diagnoses are rare, so this is not a hot path.
No new coupling: the closure keeps LibraryDiagnosticsModel ignorant of AppLibraryModel. row(_:) is now an instance method while site/problem/recordCountText/resolution stay static and are called with Self. — a mild style inconsistency; threading canReteach: into a static row would read more uniformly. The { _ in true } default reproduces the original bug for any future caller that forgets the parameter; it exists because two other test files construct the model with only two arguments.
supportsSegmentTeaching is off or repository is nil. Neither can occur at this call site today (the factory guards repository; the capability is a build constant), and SiteDetailModel.reteachUnavailableMessage has the identical latent inaccuracy.AppLibraryModel wiring line is untested; every assertion lives in the model tests with an injected closure. A test via mock.recentPresentationResult would prove the end-to-end fix. No RecentPresentationRow fixture exists in AsterismTests yet, which is why it was not added here.reteach(hostname:) is the only place that re-evaluates the predicate after load; it silently no-ops rather than surfacing anything, which is acceptable because the button that reaches it has already been withheld in the common case.Fully implemented: withholding the button, explanatory row text, direct-call guard, model tests, report, decision log. Partial: end-to-end coverage of the wiring. Missing (by design): a hostname-only teaching entry point — recorded as out of scope in Q65.
Asterism/Asterism/ViewModels/MaintenanceViewModels.swift
Why it matters. This is the fix. The view renders the button only when reteachHostname is non-nil, so gating it here removes the dead button and swaps in the explanatory sentence.
What to look at. MaintenanceViewModels.swift:238-252 (row), :283-292 (resolution)
Asterism/Asterism/ViewModels/AppLibraryModel.swift
Why it matters. Reuses the exact predicate composedTeachingModel(forHostname:) guards on, so 'button shown' and 'sheet will have content' cannot drift apart. It is the one line of the fix without a test.
What to look at. AppLibraryModel.swift:993-1001
Asterism/Asterism/ViewModels/MaintenanceViewModels.swift
Why it matters. Defence in depth: the only place the predicate is re-read after load(), so it catches an Entry deleted while the screen is open.
What to look at. MaintenanceViewModels.swift:212-220
Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift
Why it matters. Behavioural coverage of both new branches via the injected closure; the canReteach == true path stays covered by the pre-existing siteTupleRowOffersReteach.
What to look at. LibraryDiagnosticsModelTests.swift:83-121
Q65: the entry point is a new capability, not a guard, and no task authorises it. Q53 had already named withholding as the minimum fix.
The row's resolution sentence carries the explanation; the diagnostics screen has no disabled controls today and Req 4.5 limits it to the single re-teach action.
(inferred — not stated by the author.)Keeps LibraryDiagnosticsModel testable against MockLibraryProvider alone and mirrors the existing pendingConflicts closure on the same init.
Two other test files (DuplicateSurfaceTests, WorkDetailCharacterTests) construct the model with two arguments. Dropping the default would be safer against a future caller regressing to a dead button, but requires touching those tests; left as-is.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | decision_log.md Q53 | Q53's rationale still read 'left open deliberately' with no pointer to its resolution. | Appended 'Resolved by Q65 (T-1948)' to the Q53 row. |
| minor | AppLibraryModel.libraryDiagnosticsModel() wiring | The closure that binds canReteach to canComposeTeaching(forHostname:) has no test; all coverage injects the closure directly. A test via mock.recentPresentationResult would prove the fix end to end. | Not added: AsterismTests has no RecentPresentationRow fixture yet, so this is more than a small fix. Recommended follow-up. |
| minor | MaintenanceViewModels.swift:172 default { _ in true } | The default silently reproduces the original bug for any future caller that omits the parameter. | Removing it would require editing DuplicateSurfaceTests and WorkDetailCharacterTests; review constraints forbid test edits for non-bugs. Left as-is. |
| minor | MaintenanceViewModels.swift:238 row(_:) static -> instance | row(_:) became an instance method while its sibling helpers stay static, adding five Self. prefixes; threading canReteach: into a static row would read uniformly. | Style only; skipped. |
| nit | Requirement citations | The new comments, report and Q65 cite Req 3.4 ('more than one Site row is not clearable'); the general 'say whether re-teaching clears it' obligation is Req 4.2. | The whole file and decision log already use Req 3.4 in this broader sense (Q53, Row docs); changing only the new lines would be inconsistent. Skipped. |
| nit | Withheld-row wording | 'no notes captured' is also shown when supportsSegmentTeaching is false or repository is nil; neither is reachable here and SiteDetailModel shares the same inaccuracy. | Skipped. |
| nit | report.md root cause | 'Sites screen: task 21+ of library-integrity-tolerance Req 6' — Req 6 for the Sites screen lives in polish-and-export; and make test-core does not run LibraryDiagnosticsModelTests (app bundle), only make test-quick does. | Report accuracy nit; not changed. |
Click to expand.
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex c091050..506bb3e 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -990,7 +990,15 @@ public final class AppLibraryModel { // A live read, not a copy: the view holds this model in `@State` for // the life of the screen, and a conflict recorded or cleared while // the reader is on it has to reach the list (Req 9.4).- pendingConflicts: { [weak self] in self?.pendingConflicts ?? [] })+ pendingConflicts: { [weak self] in self?.pendingConflicts ?? [] },+ // The same question the Sites screen asks before offering its own+ // re-teach button (Req 6.2). A `.siteTuple` hostname with no Entry+ // left has nothing for the composed surface to enter from, and+ // without this the button would tap into a nil and do nothing+ // (T-1948, Q53/Q65).+ canReteach: { [weak self] hostname in+ self?.canComposeTeaching(forHostname: hostname) ?? false+ }) } // MARK: - Preserved edit conflicts (Req 2.10, Q47)
diff --git a/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift b/Asterism/Asterism/ViewModels/MaintenanceViewModels.swiftindex b7468a7..63a74d3 100644--- a/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift+++ b/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift@@ -154,17 +154,31 @@ public final class LibraryDiagnosticsModel { /// model with `@State`, so the copy was the only one the reader ever saw and /// Req 9.4's "without an app restart" did not hold for this row source. private let pendingConflicts: @MainActor () -> [PendingConflict]+ /// Whether the composed teaching surface can actually be entered for a+ /// hostname — `AppLibraryModel.canComposeTeaching(forHostname:)` answers+ /// this the same way `SiteDetailModel.canReteach` does.+ ///+ /// A `.siteTuple` diagnosis is `clearableByReteaching` by *class*, but a+ /// hostname with no Entry at all has nothing for the composed surface to+ /// enter from: `composedTeachingModel(forHostname:)` returns nil and a+ /// button that called `reteach(hostname:)` anyway would silently do+ /// nothing (T-1948, Q53). Checking here withholds the button instead —+ /// no action offered where it cannot succeed (Req 3.4) — rather than+ /// discovering the nil after the reader has already tapped.+ private let canReteach: @MainActor (String) -> Bool public init( library: any LibraryProviding, onReteach: @escaping @MainActor (String) -> Void, onResolveDuplicate: (@MainActor (DuplicateSetKey) -> Void)? = nil,- pendingConflicts: @escaping @MainActor () -> [PendingConflict] = { [] }+ pendingConflicts: @escaping @MainActor () -> [PendingConflict] = { [] },+ canReteach: @escaping @MainActor (String) -> Bool = { _ in true } ) { self.library = library self.onReteach = onReteach self.onResolveDuplicate = onResolveDuplicate self.pendingConflicts = pendingConflicts+ self.canReteach = canReteach } /// Reads the library's current diagnoses. Deliberately a read and not a@@ -183,7 +197,7 @@ public final class LibraryDiagnosticsModel { // first — they are the older, damage-shaped class — then the sets, then // the conflicts, each already in its own stable order. let conflicts = pendingConflicts()- rows = diagnostics.diagnoses.map(Self.row)+ rows = diagnostics.diagnoses.map(row) + workload.reviewItems.map(Self.row) + workload.deferredItems.map(Self.row) + conflicts.map(Self.row)@@ -195,7 +209,13 @@ public final class LibraryDiagnosticsModel { /// Takes the re-teach route. The caller owns navigation: the composed /// teaching surface is entered from an Entry, and resolving one for a /// hostname is `AppLibraryModel`'s job, not this model's.+ ///+ /// Guarded the same way row construction is (T-1948): the view only ever+ /// offers this for a row whose `reteachHostname` is non-nil, but a second+ /// check here means the model itself never hands the host a hostname it+ /// already knows has nowhere to go. public func reteach(hostname: String) {+ guard canReteach(hostname) else { return } onReteach(hostname) } @@ -215,18 +235,21 @@ public final class LibraryDiagnosticsModel { // MARK: - Wording - private static func row(_ diagnosis: LibraryDiagnosis) -> Row {- Row(+ private func row(_ diagnosis: LibraryDiagnosis) -> Row {+ // Exactly the clearable class, taken from the diagnosis itself rather+ // than re-derived here, so the screen and the commit cannot disagree+ // about what re-teaching can fix — narrowed further by whether there+ // is actually an Entry to re-teach from (T-1948).+ let canActuallyReteach =+ diagnosis.clearableByReteaching && diagnosis.hostname.map(canReteach) == true+ return Row( id: diagnosis.id,- site: site(diagnosis),- problem: problem(diagnosis),+ site: Self.site(diagnosis),+ problem: Self.problem(diagnosis), recordCount: diagnosis.recordCount,- recordCountText: recordCountText(diagnosis),- resolution: resolution(diagnosis),- // Exactly the clearable class, taken from the diagnosis itself rather- // than re-derived here, so the screen and the commit cannot disagree- // about what re-teaching can fix.- reteachHostname: diagnosis.clearableByReteaching ? diagnosis.hostname : nil)+ recordCountText: Self.recordCountText(diagnosis),+ resolution: Self.resolution(diagnosis, canReteach: canActuallyReteach),+ reteachHostname: canActuallyReteach ? diagnosis.hostname : nil) } private static func site(_ diagnosis: LibraryDiagnosis) -> String {@@ -257,10 +280,17 @@ public final class LibraryDiagnosticsModel { } } - private static func resolution(_ diagnosis: LibraryDiagnosis) -> String {+ private static func resolution(_ diagnosis: LibraryDiagnosis, canReteach: Bool) -> String { switch diagnosis { case .siteTuple:- "Re-teaching this site replaces those rules and clears this."+ // Req 3.4 either way: when there is nothing to re-teach from, the+ // row still says re-teaching *would* clear this — it names the+ // resolution class — but explains why the button is withheld+ // rather than leaving the reader to guess (T-1948, Q65).+ canReteach+ ? "Re-teaching this site replaces those rules and clears this."+ : "Re-teaching this site would clear this, but Asterism has no notes "+ + "captured from it to re-teach with." case .duplicateSiteRows: // Q36: informational. Asterism consolidates the copies itself and // teaching one still works — it writes to the copy every other screen
diff --git a/Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift b/Asterism/AsterismTests/LibraryDiagnosticsModelTests.swiftindex 09c798e..b5ee345 100644--- a/Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift+++ b/Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift@@ -23,11 +23,16 @@ struct LibraryDiagnosticsModelTests { private static func model( _ diagnostics: LibraryDiagnostics,- onReteach: @escaping @MainActor (String) -> Void = { _ in }+ onReteach: @escaping @MainActor (String) -> Void = { _ in },+ canReteach: @escaping @MainActor (String) -> Bool = { _ in true } ) -> (LibraryDiagnosticsModel, MockLibraryProvider) { let mock = MockLibraryProvider() mock.diagnostics = diagnostics- return (LibraryDiagnosticsModel(library: mock, onReteach: onReteach), mock)+ return (+ LibraryDiagnosticsModel(+ library: mock, onReteach: onReteach, canReteach: canReteach),+ mock+ ) } private static func diagnostics(@@ -75,6 +80,46 @@ struct LibraryDiagnosticsModelTests { #expect(!row.resolution.localizedCaseInsensitiveContains("cannot")) } + // MARK: - T-1948: no Entry to re-teach from++ @Test("A siteTuple row withholds the re-teach route when the hostname has no Entry to teach from, and says why")+ func siteTupleRowWithholdsReteachWithNoEntry() async throws {+ let (model, _) = Self.model(+ Self.diagnostics(+ tuple: ["tuple.test": .invalidStateTuple(+ type: "Site", id: "tuple.test", reason: "taught with no active pattern")]),+ canReteach: { _ in false })+ await model.load()++ let row = try #require(model.rows.first)+ // The composed teaching surface is entered from an Entry (T-1948,+ // Q53): with none, offering the button would tap into a nil and do+ // nothing, so it is withheld instead of shown disabled-looking-active.+ #expect(row.reteachHostname == nil)+ // Req 3.4: still says either way, so the reader is not left guessing+ // why the action other rows offer is missing here.+ #expect(row.resolution.localizedCaseInsensitiveContains("re-teach"))+ #expect(row.resolution.localizedCaseInsensitiveContains("no notes"))+ }++ @Test("Taking the re-teach route is refused when the hostname has nothing to teach from")+ func reteachRouteRefusedWithNoEntry() async {+ final class Box: @unchecked Sendable { var value: String? }+ let box = Box()+ let (model, _) = Self.model(+ Self.diagnostics(+ tuple: ["tuple.test": .invalidStateTuple(type: "Site", id: "tuple.test", reason: "x")]),+ onReteach: { box.value = $0 },+ canReteach: { _ in false })+ await model.load()++ // Defense in depth: even if something calls this directly rather than+ // through the (now withheld) button, the host never sees a hostname+ // it cannot enter the composed surface for.+ model.reteach(hostname: "tuple.test")+ #expect(box.value == nil)+ }+ @Test("A duplicateSiteRows row reads as informational, with nothing for the reader to do") func duplicateSiteRowsReadsAsInformational() async throws { let (model, _) = Self.model(
diff --git a/specs/bugfixes/re-teach-button-no-op-for-site-with-no-entries/report.md b/specs/bugfixes/re-teach-button-no-op-for-site-with-no-entries/report.mdnew file mode 100644index 0000000..baebe0a--- /dev/null+++ b/specs/bugfixes/re-teach-button-no-op-for-site-with-no-entries/report.md@@ -0,0 +1,174 @@+# Bugfix Report: Re-teach Button Does Nothing for a Rule-Invalid Site With No Entries++**Date:** 2026-08-29+**Status:** Fixed+**Ticket:** T-1948++## Description of the Issue++On the library diagnostics screen ("Library Check"), a `.siteTuple` (rule-invalid)+diagnosis row offers a "Re-teach `<hostname>`" button whenever+`diagnosis.clearableByReteaching` holds. Tapping it calls+`LibraryDiagnosticsModel.reteach(hostname:)`, which hands the hostname to+`AppLibraryModel`, which presents a sheet driven by+`composedTeachingModel(forHostname:)`. That factory is entered from an `Entry`+— it looks up the hostname's Recent row and builds the composed teaching+surface around it. When the site's only Entry has already been deleted, the+lookup finds nothing, the factory returns `nil`, and the sheet's `if let`+renders no content. The button appears active and does nothing when tapped,+with no error and no explanation.++**Reproduction steps:**+1. Teach a hostname (so it carries title/URL rules).+2. Delete every Entry captured from that hostname.+3. Get the library into a state where that hostname reports `.siteTuple`+ (rule-invalid) — reachable via the tolerated-state fixtures used elsewhere+ in this milestone's tests, or in practice by whatever produces an illegal+ tuple for a hostname with no Entries left.+4. Open Settings -> Library Check.+5. Tap "Re-teach `<hostname>`" on that row.++**Impact:** Per Q52 in `specs/library-integrity-tolerance/decision_log.md`,+the diagnostics screen is the *only* route to re-teach a genuinely+tuple-diagnosed hostname, and `.siteTuple` is the one diagnosis class Req 3.1+says re-teaching clears. For a hostname with no Entry left, that single+repair route silently failed - the milestone's one clearable diagnosis had no+working fix for one shape of it.++## Investigation Summary++- **Symptoms examined:** the call site was already flagged in code comments+ (`AppLibraryModel.composedTeachingModel(forHostname:)`, `SitesModels.swift`)+ and recorded as Q53 in the decision log, so the defect was known and+ documented but deliberately left open rather than fixed as a drive-by+ during the `library-integrity-tolerance` milestone.+- **Code inspected:** `MaintenanceViewModels.swift` (`LibraryDiagnosticsModel`,+ the diagnostics screen's view model), `AppLibraryModel.swift`+ (`composedTeachingModel(forHostname:)`, `canComposeTeaching(forHostname:)`),+ `SitesModels.swift` (`SiteDetailModel`, which already solves the identical+ problem for the Sites settings screen), `ContentView.swift` (the sheet+ presentation at the `composedTeachingModel(forHostname:)` call site).+- **Hypotheses tested:** confirmed `AppLibraryModel` already has a working+ precedent for this exact class of guard - `SiteDetailModel.canReteach`,+ fed by `AppLibraryModel.canComposeTeaching(forHostname:)` - used by the+ Sites screen to disable its own re-teach button and explain why. The+ diagnostics screen's `LibraryDiagnosticsModel` had no equivalent check; it+ offered the button whenever the diagnosis class was clearable, without+ asking whether an Entry existed to enter the composed surface from.++## Discovered Root Cause++**Defect type:** Missing precondition check before offering an action.++**Why it occurred:** `LibraryDiagnosticsModel.row(_:)` derived+`reteachHostname` solely from `diagnosis.clearableByReteaching`, a+diagnosis-class-level property. It never asked the host (`AppLibraryModel`)+whether the composed teaching surface could actually be entered for that+specific hostname - a question that depends on data (whether an Entry+exists), not on the diagnosis class. The Sites screen asks this question via+`canComposeTeaching(forHostname:)`; the diagnostics screen never did.++**Contributing factors:** the two screens' re-teach routes were built at+different times (Sites screen: task 21+ of `library-integrity-tolerance`+Req 6; diagnostics screen: Req 4.5, task 23), and the guard added to one was+not carried to the other.++## Resolution for the Issue++**Changes made:**+- `Asterism/Asterism/ViewModels/MaintenanceViewModels.swift` - added a+ `canReteach: @MainActor (String) -> Bool` closure to+ `LibraryDiagnosticsModel` (default `{ _ in true }`), used by `row(_:)` to+ narrow `reteachHostname` to `nil` when a `.siteTuple` diagnosis is+ class-clearable but the specific hostname has nothing to re-teach from.+ `resolution(_:canReteach:)` now returns a different sentence in that case,+ naming the diagnosis as clearable in principle but explaining there is+ nothing captured to re-teach with (Req 3.4: say why, either way).+ `reteach(hostname:)` also re-checks the closure before calling `onReteach`,+ as defense in depth against a call that bypasses the view.+- `Asterism/Asterism/ViewModels/AppLibraryModel.swift` - wired+ `libraryDiagnosticsModel(...)`'s new `canReteach` parameter to the existing+ `canComposeTeaching(forHostname:)`, the same check `siteDetailModel(...)`+ already uses for the Sites screen.++**Approach rationale:** this is the minimum fix the ticket asked for -+withhold the button when the action cannot succeed, and say why - reusing a+check (`canComposeTeaching(forHostname:)`) that already existed for exactly+this purpose on a sibling screen. It keeps the two re-teach routes agreeing+about which hostnames are enterable, using one source of truth.++**Alternatives considered:**+- **A hostname-only teaching entry point** (build the composed surface without+ requiring an Entry) - this is the "real fix" the ticket names, but it is a+ materially larger feature: it needs a new construction path for+ `ComposedTeachingViewModel` that does not start from an `Entry`, and no+ task in the `library-integrity-tolerance` milestone authorised it. Left out+ of scope per the ticket's explicit instruction; Q53 already records it as+ future work.+- **Re-deriving "has an Entry" inside `LibraryDiagnosticsModel` itself** (e.g.+ reading `library.diagnostics` more deeply, or querying the repository+ directly) - rejected in favor of asking `AppLibraryModel` the same question+ it already answers via `canComposeTeaching(forHostname:)`, so the screen+ and the sheet's own `if let` cannot disagree.++## Regression Test++**Test file:** `Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift`+**Test names:**+- `siteTupleRowWithholdsReteachWithNoEntry` - a `.siteTuple` diagnosis with+ `canReteach` returning `false` produces a row with `reteachHostname == nil`+ and a resolution sentence that still names re-teaching as the fix in+ principle, and says why the button is withheld.+- `reteachRouteRefusedWithNoEntry` - `model.reteach(hostname:)` does not call+ `onReteach` when the hostname cannot be entered, even if invoked directly.++**What it verifies:** the fix's exact contract - the button is withheld (not+just visually disabled) and the reasoning is explained, per Req 3.4; and the+model itself refuses to route a hostname it knows has nowhere to go.++**Run command:** `make test-quick` (unit-test bundle, includes+`AsterismTests/LibraryDiagnosticsModelTests`).++## Affected Files++| File | Change |+|------|--------|+| `Asterism/Asterism/ViewModels/MaintenanceViewModels.swift` | Added `canReteach` closure to `LibraryDiagnosticsModel`; narrowed `row(_:)`'s `reteachHostname` and `resolution` text; guarded `reteach(hostname:)` |+| `Asterism/Asterism/ViewModels/AppLibraryModel.swift` | Wired `libraryDiagnosticsModel(...)`'s `canReteach` to `canComposeTeaching(forHostname:)` |+| `Asterism/AsterismTests/LibraryDiagnosticsModelTests.swift` | Added regression tests; extended the `model(...)` test helper to accept `canReteach` |+| `specs/library-integrity-tolerance/decision_log.md` | Added Q65 recording the resolution, referencing Q53 |++## Verification++**Automated:**+- [x] Regression test passes (`siteTupleRowWithholdsReteachWithNoEntry`,+ `reteachRouteRefusedWithNoEntry`)+- [x] Full test suite passes (`make test-core`, `make test-quick`)+- [x] No linter configured for this project (per project CLAUDE.md); no new+ compiler warnings introduced by this change++**Manual verification:** not performed - this is a view-model-level fix with+no UI-visible change other than the button disappearing and the resolution+text differing for the affected row; existing UI/accessibility identifiers+(`diagnostics-reteach-N`, `diagnostics-row-resolution-N`) are unaffected in+shape, only in whether/what they render for this one row.++## Prevention++**Recommendations to avoid similar bugs:**+- When two screens route to the same nullable-returning factory+ (`composedTeachingModel(forHostname:)`), the guard belongs beside the+ factory (`canComposeTeaching(forHostname:)`), not duplicated per screen - it+ already lives there, but only one of the two consumers used it before this+ fix.+- Req 3.4's "no action offered where it cannot succeed" is only actually+ enforced where every button on a diagnostics-style screen is threaded back+ through a can-it-succeed check, not just the diagnosis-class-level one.++## Related++- Q52, Q53 in `specs/library-integrity-tolerance/decision_log.md`+- Q65 (this fix) in the same file+- `SiteDetailModel.canReteach` / `AppLibraryModel.canComposeTeaching(forHostname:)`+ in `Asterism/Asterism/ViewModels/SitesModels.swift` and `AppLibraryModel.swift`+ - the existing precedent this fix reuses
diff --git a/specs/library-integrity-tolerance/decision_log.md b/specs/library-integrity-tolerance/decision_log.mdindex a586dea..385d17c 100644--- a/specs/library-integrity-tolerance/decision_log.md+++ b/specs/library-integrity-tolerance/decision_log.md@@ -68,6 +68,7 @@ | Q62 | 2026-07-26 | The tolerated-state suite asserts **budgets** and a **coherent-vs-tolerated ratio**, never a hard-coded absolute baseline | Decision 10 records that the M4 numbers are host-only and "comparable to a later run of the same command on the same machine, and to nothing else", so `median <= 0.79` would be an assertion about one M1 Max. The machine-independent claim Req 5.3 actually makes is that tolerance does not multiply the cost, and that survives being measured anywhere: both fixtures are seeded and measured in the same test, in the same run. Bound set at 1.25×, against a measured 0.998×/1.019× | | Q63 | 2026-07-26 | Req 5.5's three assertions are wrapped in `withKnownIssue(isIntermittent: true)` rather than relaxed, deleted, or fixed | See Decision 11 | | Q64 | 2026-07-26 | Req 5.4's answer for `.duplicateSiteRows` and `.siteMissing` is the **capture projection** (57–66 ms), not the rule-application step | Q32 predicted the rule-application step would be vacuous for the quarantined state; it is, and measures under a microsecond in both — there is no rule to apply. The projection is where the state's cost actually lands, because the basis builder is what resolves the Site rows and fetches the hostname's 1,000 Works. Both are measured and both are labelled; `expectBasisMatchesState` pins the no-rule shape so a change that starts applying rules under quarantine fails rather than quietly changing what the fast number means **Superseded by cloudkit-mirroring Q36/Q39** — duplicate rows no longer quarantine; a capture into a duplicated hostname resolves through the winner row and applies its rules (re-pinned in `f84ad08`) |+| Q65 | 2026-08-29 | T-1948 fixed by withholding the diagnostics screen's re-teach button, not by adding a hostname-only teaching entry point | `LibraryDiagnosticsModel` now takes a `canReteach` closure (wired to `AppLibraryModel.canComposeTeaching(forHostname:)`, the same check `SiteDetailModel.canReteach` already used for the Sites screen). A `.siteTuple` row whose hostname resolves it to `false` gets `reteachHostname == nil` and a resolution sentence that names the class as clearable but explains there is nothing captured to re-teach with (Req 3.4). The real fix Q53 named — a hostname-only entry into the composed teaching surface — is out of scope: it is a new capability, not a guard, and no task authorises it | --- diff --git a/specs/library-integrity-tolerance/decision_log.md b/specs/library-integrity-tolerance/decision_log.mdindex 385d17c..e174829 100644--- a/specs/library-integrity-tolerance/decision_log.md+++ b/specs/library-integrity-tolerance/decision_log.md@@ -56,7 +56,7 @@ | Q50 | 2026-07-26 | A teaching commit invalidates the carried-forward tuple set, not just the quarantine map | `refreshDiagnostics()` unions the scan output with the tuple set held in `diagnostics`, which is a cache of the last full validation. `recordPostCommitDiagnosis` updated only `quarantined`, so a re-teach that cleared a `.siteTuple` left the cache holding it and the very next foreground refresh re-quarantined the hostname — Req 3.1 undone one foreground later, by the mechanism that exists to keep diagnoses fresh. The commit already knows the current answer for the hostname it wrote, so it writes it into `diagnostics` too (`LibraryDiagnostics.recordingTupleDiagnosis`). Not in design.md; it falls out of relaxing the guard in task 21 and would have been a silent hole. The `.duplicate(type: "Site")` reason `quarantineMap()` produces for a duplicated hostname (Q24) is deliberately **not** carried forward: it is not a tuple diagnosis and the scan re-derives its class on its own | | Q51 | 2026-07-26 | The four Req 3.4 write-path guards are **not** among the consequences of breaking the union invariant | design.md and task 24 both say a scan-only republish would "re-enable the four write paths that must refuse". It would not: since Q41 those guards read `diagnostics.diagnoses` for `.duplicateSiteRows`, a class the scan re-derives on every refresh, so they keep refusing either way. The consequences that are real are the two consumers of the quarantine *map*: capture's conservative no-rule path (`+ReparseCapture.swift:284`, `:396`) and the backup export gate (`BackupV4Exporter.swift:41`). `RefreshUnionInvariantTests` asserts all three — the write paths as a weaker regression pin, the other two as the ones that actually bite, verified by mutating the union and watching them fail | | Q52 | 2026-07-26 | The diagnostics screen is the **only** route to re-teach a genuinely tuple-diagnosed hostname, which makes Req 4.5 load-bearing rather than convenient | Task 23's text asserted a `.siteTuple` hostname "MUST keep offering Teach". In practice it cannot: `validatedRecentSiteMode` returns nil for an illegal tuple (task 15), so the Recent row gets `actionType == .none`, and Entry detail refuses wholesale (Q39). Task 23's pinning tests hold only because they inject a `.siteTuple` diagnosis over a *legal* store, which is the sole way to separate the two states — a genuinely illegal tuple resolves no mode anywhere. So the one clearable class has exactly one repair route, and it runs through the diagnosis screen. `composedTeachingModel(forHostname:)` exists because the row-based `composedTeachingModel(for:)` guards `actionType != .none` and would have refused every hostname the screen routes for |-| Q53 | 2026-07-26 | A `.siteTuple` hostname carrying **no Entry** shows a re-teach button that does nothing | The composed teaching surface is entered from an Entry, so `composedTeachingModel(forHostname:)` returns nil and the tap is a no-op. Reachable by teaching a hostname and then deleting all its Entries. Given Q52 this is the *only* route failing for that shape. The minimum fix is to withhold the button when no route exists — consistent with Req 3.4 and with what tasks 15/17/23 did elsewhere. A real fix needs a hostname-only teaching entry point, which no task authorises. Documented in code; left open deliberately |+| Q53 | 2026-07-26 | A `.siteTuple` hostname carrying **no Entry** shows a re-teach button that does nothing | The composed teaching surface is entered from an Entry, so `composedTeachingModel(forHostname:)` returns nil and the tap is a no-op. Reachable by teaching a hostname and then deleting all its Entries. Given Q52 this is the *only* route failing for that shape. The minimum fix is to withhold the button when no route exists — consistent with Req 3.4 and with what tasks 15/17/23 did elsewhere. A real fix needs a hostname-only teaching entry point, which no task authorises. Documented in code; left open deliberately. **Resolved by Q65** (T-1948) | | Q54 | 2026-07-26 | `LibraryProviding.diagnostics` is `{ get async }` | The concrete member is an actor-isolated stored property, and a synchronous witness cannot satisfy a non-async requirement. Verified the witness compiles for both the actor and `MockLibraryProvider` | | Q55 | 2026-07-26 | UI-test fixtures reopen the library after seeding | `.siteTuple` is derived only by the full `validate(graph:)` at open — `refreshDiagnostics()` cannot produce it (Decision 7) — and the fixture is written after the repository has already opened on an empty store. `AppLibraryModel.bootstrap` reopens for fixtures marked `requiresReopenAfterSeeding`. Also recorded in `docs/agent-notes/testing.md` | | Q56 | 2026-07-26 | `ToleratedStateFixtureKind` is compiled unconditionally; only its seeding is `#if DEBUG` | The UI-test launch parser names the shapes in code that is compiled for Release, so guarding the enum itself would break the `Personal` build — the configuration the performance targets require (Q22) |
canReteach is evaluated once per load(). An Entry synced in while Library Check is open does not restore the button until the screen is reopened. Same behaviour as the Sites screen; acceptable, but worth knowing.
LibraryDiagnosticsUITests taps diagnostics-reteach-0 on the invalidSiteTuple fixture, which seeds an Entry for tuple.test, so the button remains. No UI test exercises the new no-Entry branch.