PR #44 — git diff origin/main...HEAD plus the uncommitted review fixes. Backup export no longer refuses a library whose only defect is a retired, uncited TitlePattern with no definition blob.
partitionUnreadableTitlePatterns omits a retired, uncited title row whose definition will not decode; a cited row or an active row still refuses by name (Q26).requireRepresentableValues, the site-less citer attachment and the wire loop — the same three points the URL-rule side already uses.mapV7TitlePatternRecord decode guard was unreachable (the requireRepresentableValues loop already decodes every archivable row) and was removed; the report now says so.unified-teaching-composition Req 1.1 in code, tests, report and Q26. Stale "refuse-always" text in docs/agent-notes/rule-wire-format.md and the url-locator implementation.md updated.Ready to push
The fix is correct and mirrors the URL-rule omission path (Q14) faithfully; make test-core is green for every export test including the three new ones. Review found no correctness defect in the committed code but three things worth fixing before push, all applied and left uncommitted: a fetch-order dependence in the duplicate-id branch of the partition, an unreachable guard in mapV7TitlePatternRecord whose justification in the report was wrong, and a wrong Decision 5 citation plus two stale docs that still said the title side refuses always. One unrelated bootstrap test failed once in the full run and passed twice in isolation.
4059a8f T-2290: backup export omits an unreadable retired title pattern 58091c9 T-2290: add bugfix report working-tree Fixes applied in this review (uncommitted) Asterism keeps title rules — small saved instructions for reading a page title on a given website. When you re-teach a site, the old rule is kept as history ("retired"). An older version of the app stored those rules differently, and after a recent database change some retired rules ended up with no stored instructions at all. They are harmless: nothing uses them any more.
The backup exporter, though, insisted on being able to read every rule, including those empty retired ones, and refused to write a backup at all if it hit one — while the app's own "Check Library" said the library was fine. This change teaches the exporter to leave such a rule out (and log that it did), as long as the rule is retired and no saved page still points at it.
Without this, a library holding one of those rows could never be backed up, and the two surfaces disagreed about whether anything was wrong.
BackupArchiveProjection.swift: new partitionUnreadableTitlePatterns(_:entries:citations:) and citedTitlePatternIDs; projectCommonArchiveRecords computes the omitted set before requireRepresentableValues and filters it from the representable check, the site-less citer loop and the wire loop.BackupExportDegradedRefusalTests.swift: three tests — retired-uncited omitted, retired-cited refuses with no file, active-uncited refuses.url-locator-generalisation/decision_log.md reverses Q21; bugfix report under specs/bugfixes/backup-export-retired-title-pattern/.The partition is a near-copy of partitionUnreadableURLRules: an id is unreadable only if no row holding it decodes (duplicate-id rows are one rule), the citation walk only runs when there is at least one unreadable id, and refusals are thrown in sorted-id order for determinism. The one policy difference is the guard: !pattern.isActive && !cited.contains(id) instead of citation alone.
Omitted rows still flow into SiteUnionProjection through Site.patterns, so they are dropped at the wire loop rather than before the union — identical to the URL-rule wiring. That leaves a gap in the version numbering, which the validator tolerates.
Three decodes per title row per export now happen (partition, requireRepresentableValues, mapper) versus two on the URL-rule side, because the title-side representable loop decodes definitions whereas the URL side checks only origin. A StoredPatternDefinition blob is small; at a few hundred rows this is milliseconds. The consequence that matters is reachability: since every non-omitted row is decoded in requireRepresentableValues and union representatives are drawn from those same rows, the committed diff's decode guard in mapV7TitlePatternRecord could never fire. It was removed in review, and the report's claim that it turned an uncaught ModelInvariantError into a named refusal — a path that never existed — was corrected. Whether the representable loop should instead be dropped for parity with Q14 is an open policy question (see decisions) and was not changed.
The duplicate-id case: unreadable[id] kept the first unreadable row seen, so a group with a corrupt active row and a corrupt retired row would refuse or omit depending on fetch order. In the omit case RuleMembership.reduced still picks the marked (active) row as representative, requireProjectedTuplesRepresentable counts it as the taught site's active pattern, and the wire loop then drops it — a .taught site with no active rule on the wire, surfacing at the verify-decode gate as a generic encoding failure. The review fix prefers the active row when populating unreadable, so any active row in the group refuses.
The validator (post-T-2289) and the exporter now agree on this row shape. The two partitions are parallel but separate; a future change to citation targets (e.g. memberships citing title patterns) must update citedTitlePatternIDs, which currently walks entries only and says so.
referencesStillArriving; acceptable because nothing cites it, and identical to URL rules.Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift
Why it matters. The behavioural change. Decides which unreadable title rows the archive may drop and which refuse the export by name.
What to look at. partitionUnreadableTitlePatterns, ~L311-358; citedTitlePatternIDs ~L360-375
Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift
Why it matters. Missing any one of the three filters would either re-refuse the omitted row (representable check), throw referencesStillArriving (site-less loop) or write it to the wire.
What to look at. L107-133 and the wire loop at L174-178
Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift
Why it matters. Without it the refuse/omit outcome for a group holding both an active and a retired corrupt row depended on SwiftData fetch order, and the omit outcome produced a taught site with no active rule on the wire.
What to look at. the else-if at ~L335
Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift
Why it matters. The committed diff added a do/catch that could never fire and the report described it as closing a hole that did not exist.
What to look at. mapV7TitlePatternRecord, ~L737-748 (now the original one-liner)
Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift
Why it matters. Behavioural tests over payload contents, named refusal and the absence of a staged file; the omitted case also asserts the returned id set directly.
What to look at. unreadableUncitedTitlePatternIsOmitted, unreadableCitedTitlePatternRefusesTheExport, unreadableActiveTitlePatternRefusesEvenUncited
A site's active title rule is its one live rule (unified-teaching-composition Req 1.1). Silently dropping it would understate the site's teaching to a reader of the archive; dropping a retired uncited row loses only provenance nobody references. Stated in Q26 and the report.
The URL and title partitions differ in accessor, citation sources (memberships cite URL rules only), guard and strings. A generic would save ~25 lines and hide the asymmetry. Review consensus; not stated by the author.
(inferred — not stated by the author.)That loop is what makes the mapper guard unreachable and gives the title side three decodes per row versus two for URL rules. The Req 3.6 comment ("over rows, not groups") justifies it, and it fails closed, so it was left as is. Removing it would give true Q14 parity with the mapper guard as the backstop. Author's call; not changed in review.
(inferred — not stated by the author.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | partitionUnreadableTitlePatterns duplicate-id groups | The active/retired guard used the first unreadable row seen for an id, so a group with one active and one retired corrupt row refused or omitted depending on fetch order; the omit outcome left a taught site with no active rule on the wire. | Prefer the active row when populating the unreadable map, with a comment. |
| minor | mapV7TitlePatternRecord guard | The new do/catch is unreachable: requireRepresentableValues already decodes every archivable title row and union representatives come from those rows. The report claimed it replaced an uncaught ModelInvariantError path that never existed. | Reverted the mapper to its original one-liner; report's Changes and Affected Files updated to say the mapper is unchanged and why. |
| major | Decision 5 citation (code, test, report, Q26) | Decision 5 of url-locator-generalisation is about the fabricating URLRule accessor and the export hole; it says nothing about one active title rule per site. The invariant is unified-teaching-composition Req 1.1. | Replaced the citation in all four places; Decision 5 dropped from the report's Related list. |
| major | Stale docs | docs/agent-notes/rule-wire-format.md:53 and specs/url-locator-generalisation/implementation.md:191 still said the title side keeps refuse-always and that no undecodable title row has been observed. | Both updated to point at Q26 and the bugfix report. |
| minor | Report accuracy: verified red | Only unreadableUncitedTitlePatternIsOmitted is a regression test; the two refusal tests pass on the old code too. The file as a whole did not compile only because of the new symbol. | Reworded the Regression Test section; noted the untested all-unreadable duplicate-id group. |
| nit | Value-string duplication | "N bytes that do not decode" / "no stored definition" was spelled twice; requireRepresentableValues uses String(describing: error) for the same field. | Removing the mapper guard left one spelling; the pre-existing inconsistency with the representable loop is out of scope. |
| nit | Three decodes per title row per export | Partition + representable loop + mapper, versus two on the URL side. Milliseconds at a few hundred rows. | Consistency point, recorded as an open decision; not changed. |
| nit | Generic partition over both rule kinds | Near-duplicate of partitionUnreadableURLRules and citedURLRuleIDs. | Deliberately explicit; would hide the guard asymmetry. |
Click to expand.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swiftindex 16a5264..eb023b3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift@@ -104,20 +104,30 @@ extension LibraryRepository { let omittedURLRuleIDs = try partitionUnreadableURLRules( urlRules, entries: entries, memberships: membershipRows, citations: citations) let archivableURLRules = urlRules.filter { !omittedURLRuleIDs.contains($0.id) }+ // T-2290: the title-pattern counterpart to Req 4.5/Q14. A V8-era+ // retired row can hold no definition blob at all (`Models.swift`,+ // `TitlePattern.definitionData`) — a shape `requireRepresentableValues`+ // used to refuse unconditionally, historical rows included, so a+ // library holding one could never be backed up. A retired, uncited row+ // is omitted with the omission recorded instead; a cited or active one+ // still refuses by name (Q26).+ let omittedTitlePatternIDs = try partitionUnreadableTitlePatterns(+ patterns, entries: entries, citations: citations)+ let archivablePatterns = patterns.filter { !omittedTitlePatternIDs.contains($0.id) } // Req 3.6 second: naming the record and the value is only possible before // the mappers coerce or throw over it. Over **rows**, not groups: a value // the format cannot represent is a fact about the row that holds it, and // a losing row's is still in the library after the export. try requireRepresentableValues( entries: entries, works: works, memberships: membershipRows, sites: sites,- patterns: patterns, urlRules: archivableURLRules, citations: citations)+ patterns: archivablePatterns, urlRules: archivableURLRules, citations: citations) // Rules whose Site has not arrived are attached through a citing record's // hostname (Q41). One that nothing cites cannot be placed at all. let citers = citerHostnames( entries: entries, memberships: membershipRows, citations: citations) var additionalPatterns: [String: [TitlePattern]] = [:]- for pattern in patterns where pattern.site == nil {+ for pattern in archivablePatterns where pattern.site == nil { guard let hostname = citers[pattern.id] else { throw BackupV7ExportError.referencesStillArriving( detail: "title rule \(pattern.id) has no site and no entry naming one")@@ -161,7 +171,8 @@ extension LibraryRepository { for site in projected { rewrites.merge(site.versionRewrites) { lhs, _ in lhs } wireSites.append(mapV7SiteRecord(site))- for projectedPattern in site.patterns {+ for projectedPattern in site.patterns+ where !omittedTitlePatternIDs.contains(projectedPattern.pattern.id) { wirePatterns.append( try mapV7TitlePatternRecord(projectedPattern, hostname: site.hostname)) }@@ -297,6 +308,76 @@ extension LibraryRepository { return omitted } + /// T-2290, Q26. The title-pattern counterpart to `partitionUnreadableURLRules`+ /// (Req 4.5, Q14): splits the title patterns that will not decode into the+ /// ones the archive can leave out and the ones it cannot, and returns the+ /// omitted ids — the record of the omission.+ ///+ /// An id counts as unreadable only when **no** row holding it decodes, for+ /// the same reason as the URL-rule side: rows sharing an application UUID+ /// are one rule, and the projection archives one of them.+ ///+ /// Unlike a URL rule, an **active** pattern refuses even when nothing cites+ /// it: it is the site's one live title rule (`unified-teaching-composition`+ /// Req 1.1), so omitting it silently would understate+ /// the site's teaching to a reader who checks the archive later. Only a+ /// retired row — the V8-era residue with no definition blob at all+ /// (`Models.swift`, `TitlePattern.definitionData`) — can be left out, and+ /// only when no Entry cites it.+ internal static func partitionUnreadableTitlePatterns(+ _ patterns: [TitlePattern], entries: [Entry], citations: EntryCitationsCache? = nil+ ) throws -> Set<UUID> {+ var readable: Set<UUID> = []+ var unreadable: [UUID: TitlePattern] = [:]+ for pattern in patterns {+ if (try? pattern.storedDefinition) != nil {+ readable.insert(pattern.id)+ } else if unreadable[pattern.id] == nil || pattern.isActive {+ // Keep the active row for a duplicate-id group so the refusal+ // below does not depend on fetch order: the union prefers the+ // marked row, so omitting the group would leave a taught site+ // with no active rule on the wire.+ unreadable[pattern.id] = pattern+ }+ }+ for id in readable { unreadable.removeValue(forKey: id) }+ guard !unreadable.isEmpty else { return [] }++ let cited = citedTitlePatternIDs(+ entries: entries, citations: citations ?? EntryCitationsCache(entries))+ var omitted: Set<UUID> = []+ for (id, pattern) in unreadable.sorted(by: { $0.key.uuidString < $1.key.uuidString }) {+ guard !pattern.isActive, !cited.contains(id) else {+ throw BackupV7ExportError.unrepresentableValue(+ record: "Title rule \(id)", field: "definition",+ value: pattern.definitionData.map { "\($0.count) bytes that do not decode" }+ ?? "no stored definition")+ }+ omitted.insert(id)+ exportLogger.error(+ "Backup omits title rule \(id.uuidString, privacy: .public): its stored definition does not decode and no record cites it")+ }+ return omitted+ }++ /// Every title pattern id any Entry cites (`citation.target == .titlePattern`).+ ///+ /// No `WorkSiteMembership` carries a title-pattern citation — it names a URL+ /// rule only (`urlIdentityRuleID`) — so unlike `citedURLRuleIDs` this walks+ /// entries alone.+ private static func citedTitlePatternIDs(+ entries: [Entry], citations: EntryCitationsCache+ ) -> Set<UUID> {+ var cited: Set<UUID> = []+ for entry in entries {+ for citation in citations.ruleCitations(of: entry)+ where citation.target == .titlePattern {+ if let id = citation.id { cited.insert(id) }+ }+ }+ return cited+ }+ /// Every URL rule id any Entry or membership names. /// /// A row whose citation blob will not decode contributes nothing: the
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swiftindex 51ed69d..0720ebd 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift@@ -365,6 +365,115 @@ struct BackupExportDegradedRefusalTests { #expect((staged ?? []).isEmpty) } + // MARK: - T-2290: a title pattern whose stored definition will not decode++ /// The title-pattern counterpart to `unreadableUncitedRuleIsOmitted`. The+ /// V8-era residue is a retired row holding no definition blob at all+ /// (`Models.swift`, `TitlePattern.definitionData`) rather than corrupt+ /// bytes, but the omission path is the same: nothing cites it, so it can be+ /// left out of the archive without orphaning any record's provenance.+ @Test("An unreadable retired title pattern no Entry cites is omitted, and the omission is recorded")+ func unreadableUncitedTitlePatternIsOmitted() async throws {+ let fixture = try DegradedExportFixture()+ let retiredID = UUID()+ let activeID = UUID()+ try fixture.seed { store in+ let site = store.insertSite(hostname: "tth.example")+ site.mode = .taught+ let retired = try store.insertPattern(+ site: site, version: 1, active: false, id: retiredID)+ retired.definitionData = nil+ try store.insertPattern(site: site, version: 2, active: true, id: activeID)+ store.insertEntry(hostname: "tth.example", title: "Chapter 1", offset: 0)+ }+ let repository = try fixture.diagnosedRepository()++ // The omission is a returned fact, not only a log line.+ let context = fixture.freshContext()+ let omitted = try LibraryRepository.partitionUnreadableTitlePatterns(+ try context.fetch(FetchDescriptor<TitlePattern>()),+ entries: try context.fetch(FetchDescriptor<Entry>()))+ #expect(omitted == [retiredID])++ let payload = try await repository.backupV7Snapshot()++ #expect(payload.titlePatterns.map(\.id) == [activeID])+ #expect(payload.titlePatterns.first?.siteHostname == payload.sites.first?.hostname)+ }++ /// The counter-case: a record's work-assignment provenance cites the+ /// retired row, so dropping it would orphan that provenance. The export+ /// must refuse rather than silently omit, exactly as+ /// `unreadableCitedRuleRefusesTheExport` does for a URL rule.+ @Test("An unreadable retired title pattern that a record cites refuses the export and writes no file")+ func unreadableCitedTitlePatternRefusesTheExport() async throws {+ let fixture = try DegradedExportFixture()+ let patternID = UUID()+ try fixture.seed { store in+ let site = store.insertSite(hostname: "tth.example")+ site.mode = .taught+ try store.insertPattern(site: site, version: 1, active: true, rank: 1)+ let retired = try store.insertPattern(+ site: site, version: 2, active: false, id: patternID)+ retired.definitionData = nil+ let entry = store.insertEntry(hostname: "tth.example", title: "Chapter 1", offset: 0)+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: patternID, version: 2))+ }+ }+ let repository = try fixture.diagnosedRepository()+ let staging = fixture.directory.appending(path: "staging")+ let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)++ let error = try await expectRefusal {+ _ = try await exporter.export(+ metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ }++ guard case .unrepresentableValue(let record, let field, _) = error else {+ Issue.record("expected .unrepresentableValue, got \(error)")+ return+ }+ #expect(record.contains(patternID.uuidString))+ #expect(field == "definition")+ // The absence of the file, not only the thrown error.+ let staged = try? FileManager.default.contentsOfDirectory(atPath: staging.path)+ #expect((staged ?? []).isEmpty)+ }++ /// The asymmetry with the URL-rule policy: an **active** title rule refuses+ /// even when nothing cites it, because it is the site's one live rule+ /// (`unified-teaching-composition` Req 1.1) and omitting it silently+ /// would understate the site's teaching.+ @Test("An unreadable active title pattern refuses even when nothing cites it")+ func unreadableActiveTitlePatternRefusesEvenUncited() async throws {+ let fixture = try DegradedExportFixture()+ let activeID = UUID()+ try fixture.seed { store in+ let site = store.insertSite(hostname: "tth.example")+ site.mode = .taught+ let active = try store.insertPattern(+ site: site, version: 1, active: true, id: activeID)+ active.definitionData = nil+ store.insertEntry(hostname: "tth.example", title: "Chapter 1", offset: 0)+ }+ let repository = try fixture.diagnosedRepository()+ let staging = fixture.directory.appending(path: "staging")+ let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)++ let error = try await expectRefusal {+ _ = try await exporter.export(+ metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ }++ guard case .unrepresentableValue(let record, let field, _) = error else {+ Issue.record("expected .unrepresentableValue, got \(error)")+ return+ }+ #expect(record.contains(activeID.uuidString))+ #expect(field == "definition")+ }+ // MARK: - Req 3.7: references still arriving @Test("A citation of a rule no row holds refuses as records still arriving")
diff --git a/specs/bugfixes/backup-export-retired-title-pattern/report.md b/specs/bugfixes/backup-export-retired-title-pattern/report.mdnew file mode 100644index 0000000..3816b97--- /dev/null+++ b/specs/bugfixes/backup-export-retired-title-pattern/report.md@@ -0,0 +1,160 @@+# Bugfix Report: Backup export refuses on an unreadable retired title pattern++**Date:** 2026-08-29+**Status:** Fixed+**Ticket:** T-2290++## Description of the Issue++Backup export refused outright, naming a title rule with an undecodable+definition, on a library that Check Library reported clean. The row named was+a retired (`isActive == false`) `TitlePattern` no Entry cites — historical+residue that should have no bearing on whether the archive can be written.++**Reproduction steps:**+1. Hold a library with a retired `TitlePattern` row that has no+ `definitionData` blob — the V8-era shape `drop-superseded-columns` (schema+ V9) left behind when it dropped the decomposed columns such a row used to+ fall back to.+2. Run Check Library: the site is reported clean (T-2289 stopped quarantining+ this shape).+3. Run Backup export: it refuses with `unrepresentableValue` for+ `Title rule <id>`, field `definition`, and writes no file.++**Impact:** Any library holding one such row cannot be backed up at all, and+the two surfaces disagree — the validator says the library is fine, the+exporter says it is not — with no action a reader can take from either.++## Investigation Summary++- **Symptoms examined:** a named refusal from the exporter on a library the+ validator passes; the named row is retired and uncited.+- **Code inspected:** `BackupArchiveProjection.requireRepresentableValues`,+ `partitionUnreadableURLRules` and `mapV7URLRuleRecord` (the URL-rule+ equivalents), `TitlePattern.storedDefinition` / `definitionData` in+ `Models.swift`, Q14 and Q21 of `specs/url-locator-generalisation`.+- **Hypotheses tested:**+ - The validator is wrong to pass the row — ruled out; T-2289 deliberately+ stopped quarantining it, and a retired uncited row has no reader-visible+ effect.+ - A migration pass should have filled in `definitionData` — ruled out; no+ pass converts retired rows, and V9 deleted the columns a conversion would+ read from.+ - The exporter checks title patterns more strictly than URL rules —+ confirmed.++## Discovered Root Cause++`BackupArchiveProjection.requireRepresentableValues` decoded every+`TitlePattern` row unconditionally — active, retired, cited or not — and threw+on the first that did not decode. URL rules had already been given an+archive-reachability path (`partitionUnreadableURLRules`, Req 4.5 / Q14 of+`url-locator-generalisation`): an unreadable row no record cites is omitted+and the omission recorded; a cited row refuses. Title patterns were left on+"refuse always" by Q21, which assumed no undecodable historical title row had+ever been observed. Schema V9 made that shape real.++**Defect type:** Logic error (over-broad guard whose premise was invalidated+by a later schema change).++**Why it occurred:** Q21 was a reasonable call at the time; nothing linked it+to `drop-superseded-columns` so the decision was not revisited when the+columns went.++**Contributing factors:** the validator and the exporter apply their own+independent checks to the same rows, so a change on one side (T-2289) can+leave them disagreeing.++## Resolution for the Issue++**Changes made:**+- `Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift` —+ new `partitionUnreadableTitlePatterns(_:entries:citations:)` mirroring+ `partitionUnreadableURLRules`: an id is unreadable only when no row holding+ it decodes; an unreadable **retired, uncited** row is omitted (id returned,+ OSLog line written); a **cited** row, or an **active** row regardless of+ citation, still refuses by name.+- Same file — `projectCommonArchiveRecords` computes the omitted set before+ `requireRepresentableValues` and filters it out of both the site-less+ citer-attachment loop and the wire-output loop, the way `archivableURLRules`+ already is.+- `mapV7TitlePatternRecord` is unchanged: unlike the URL-rule side,+ `requireRepresentableValues` still decodes every archivable title row, so a+ corrupt representative of a duplicate-id group is refused there by name+ before the mapper runs.+- `specs/url-locator-generalisation/decision_log.md` — Q26 records the+ reversal of Q21 and the parity with Q14.++**Approach rationale:** the URL-rule side already had the right policy and+the same wiring points; copying it keeps the two paths auditable against each+other. The one deliberate asymmetry — active rows always refuse — is because+a site's active title rule is its one live rule (`unified-teaching-composition`+Req 1.1); silently omitting it would understate the+site's teaching to a reader who checks the archive later, whereas omitting a+retired uncited row only loses provenance nobody references. See Q26.++**Alternatives considered:**+- Omit any unreadable title pattern, active included, for full parity with+ URL rules — rejected; an archive that silently drops a site's live rule is+ worse than one that refuses and names the row.+- Have the validator quarantine the row again so both surfaces refuse —+ rejected; T-2289 removed that on purpose, and the row is harmless to the+ reader.++## Regression Test++**Test file:** `Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift`+**Test names:** `unreadableUncitedTitlePatternIsOmitted`,+`unreadableCitedTitlePatternRefusesTheExport`,+`unreadableActiveTitlePatternRefusesEvenUncited`++**What it verifies:** the three branches of the new partition — a retired+uncited row is omitted and the omission recorded while the export succeeds;+a retired row an Entry cites refuses by name and writes no file; an active+row refuses even when nothing cites it. The file does not compile against+the previous production code (`partitionUnreadableTitlePatterns` is new);+`unreadableUncitedTitlePatternIsOmitted` is the regression test, the other+two pin the refusal branches (the cited case already refused before the fix).+A duplicate-id group whose rows are all unreadable with one active is not+covered by a test; the partition keeps the active row so that group refuses+regardless of fetch order. All green with the fix.++**Run command:** `make test-core CORE_TEST='BackupExportDegradedRefusalTests'`++## Affected Files++| File | Change |+|------|--------|+| `Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift` | `partitionUnreadableTitlePatterns`, wiring in `projectCommonArchiveRecords` |+| `Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift` | three new regression tests |+| `specs/url-locator-generalisation/decision_log.md` | Q26 |++## Verification++**Automated:**+- [x] Regression tests pass (`make test-core CORE_TEST='BackupExportDegradedRefusalTests'`)+- [x] Full test suite passes (`make test-core`, 2026-08-29)+- [x] No new compiler warnings (no code-style linter is configured in this repo)++**Manual verification:**+- Not performed on device in this session. To verify: on a library whose+ Check Library passes but whose export named a retired title rule, run+ export again; it should write the archive, and the omission should appear+ in Console under the backup export log.++## Prevention++**Recommendations to avoid similar bugs:**+- When a quick decision rests on "this shape has never been observed", note+ which change would make it observable, so a later schema migration prompts+ a revisit.+- Keep the validator's and the exporter's tolerance for the same row shape in+ one place, or at least cross-reference them, so a relaxation on one side+ is checked against the other.++## Related++- T-2290 (this fix), T-2289 (validator stopped quarantining the row)+- `specs/url-locator-generalisation/decision_log.md` Q14, Q21, Q26+- `specs/unified-teaching-composition/requirements.md` Req 1.1 (one active title rule per taught site)+- `specs/drop-superseded-columns/` — schema V9, the origin of the row shape
diff --git a/specs/url-locator-generalisation/decision_log.md b/specs/url-locator-generalisation/decision_log.mdindex 03f64ee..a7eac58 100644--- a/specs/url-locator-generalisation/decision_log.md+++ b/specs/url-locator-generalisation/decision_log.md@@ -29,6 +29,7 @@ | Q23 | 2026-08-09 | `ComposedURLRuleState.unreadable` carries `URLRuleDecodeFailure` (the error's description), not `any Error` as the design sketched | `ComposedTeachingBasis` is `Sendable` and `Equatable` — it is compared for staleness between approval and commit — and nothing branches on the error's type; only the diagnosis reads it, as text | | Q24 | 2026-08-09 | The record of an omitted uncited unreadable rule (Req [4.5](requirements.md#45)) is the returned id set plus an OSLog error line | Nothing durable marks the omission because there is nowhere durable it belongs: the archive cannot carry a row it exists to omit, and the store row is untouched — still present, still diagnosed by the validator on every open. The log line plus the standing `unreadableURLRule` finding are the visible record | | Q25 | 2026-08-09 | The anchoring-control non-goal over-claimed, its trigger named only one side, and tapas is already the first live case — recorded on the first real repair | Repairing tapas, the teach screen's chapter-unsourced nudge invites selecting the episode component, whose left neighbour is the story's own slug — so the builder authors a chapter locator pinned to one story, caught at teach time by the Req [2.2](requirements.md#22) description (the net working as designed). "The last component of the path" is representable as `(unanchored, .end)` and valid, but no gesture can author it: the builder's left side always scans to a literal or `.start`. The non-goal's "no site in the library needs one" was true only of the components existing rules select, and its trigger said "right literal" because it generalised from the `m.fanfiction.net` case — the trigger wording is now corrected to either side. The tapas repair itself stays work-only (its old rule supplied no chapter either; Req [3.5](requirements.md#35) holds); the anchoring capability — full control or a narrow last-component default for the chapter slot — is deferred to a future spec, which also owns the chapter-nudge/builder collision this surfaced |+| Q26 | 2026-08-29 | T-2290 reverses Q21: the title-rule export side gains the same archive-reachability omission `partitionUnreadableURLRules` gives URL rules, restricted to retired (non-active) rows | Q21 assumed no undecodable historical title row had ever been observed; `drop-superseded-columns` (schema V9) made one a real shape — a V8-era retired `TitlePattern` with no `definitionData` blob at all, which no pass converts — so `requireRepresentableValues`'s unconditional check refused every export of a library holding one, while the validator (post-T-2289) no longer quarantines the row. `partitionUnreadableTitlePatterns` omits a retired, uncited row and records the omission; it still refuses a **cited** row (orphaning a citation is unacceptable, as for URL rules) and, unlike the URL-rule policy, an **active** one too — a site's active title rule is its one live rule (`unified-teaching-composition` Req 1.1), so silently dropping it would understate the site's teaching rather than merely lose provenance | ---
diff --git a/specs/url-locator-generalisation/implementation.md b/specs/url-locator-generalisation/implementation.mdindex faf8064..de9911d 100644--- a/specs/url-locator-generalisation/implementation.md+++ b/specs/url-locator-generalisation/implementation.md@@ -188,10 +188,10 @@ review found — see Potential Issues below. archive ever enters the repo. - The four manual re-teaches in `prerequisites.md` are still the user's to perform in the app; nothing on this branch touches a device.-- The title-rule export side deliberately keeps refuse-always (Q21). If an- undecodable historical *title* row ever appears, export dead-ends until that- policy is generalised — accepted, since it fails closed and has never been- observed.+- The title-rule export side kept refuse-always (Q21) until schema V9 made an+ undecodable historical title row real; T-2290 (Q26) gave it the Q14+ omission for retired, uncited rows — see+ `specs/bugfixes/backup-export-retired-title-pattern/report.md`. - `V4LibraryValidator`'s replay loop still decodes the cited rule once per Entry (documented at the site). Memoization was considered in review and skipped: microseconds against a budget whose cost lives elsewhere, at the
diff --git a/docs/agent-notes/rule-wire-format.md b/docs/agent-notes/rule-wire-format.mdindex 7027f53..39c45f7 100644--- a/docs/agent-notes/rule-wire-format.md+++ b/docs/agent-notes/rule-wire-format.md@@ -50,7 +50,8 @@ All of that is gone since `specs/url-locator-generalisation/` phase 1 (Decision (Q13). - Export refuses over an unreadable row that records cite, and omits (with a logged record) one that nothing cites — archive **reachability**, not `isCurrent`, is the boundary (Q14).- The title side deliberately keeps its stricter refuse-always check.+ Since T-2290 (Q26) the title side omits an unreadable **retired, uncited** row the same+ way; a cited row, or an active one regardless of citation, still refuses. The library is single-device, so no mixed-fleet apparatus was built around any of this (Q9). If the library ever becomes genuinely multi-device, revisit — an un-updated device
BootstrapActionTests "A failed open leaves the store, the marker and every artefact unchanged" failed once in the full make test-core run (the only failure) and passed twice when the suite was run alone with CORE_TEST='BootstrapActionTests'. The diff does not touch bootstrap. The first full run (before the review edits) also exited non-zero, most likely the same test. Worth a look if it recurs on main.
Decide whether the title-side definition decode in requireRepresentableValues should stay (fails closed, Req 3.6 comment) or go (Q14 parity, two decodes per row, mapper guard becomes the live backstop). Left unchanged.
The report says so. On the library that triggered T-2290, a Personal-configuration export should now write the archive and log the omission under the backup export category.