PR #48 — git diff origin/main...HEAD, four commits. Second pre-push review after commit 2bc342b addressed the previous round's must/should items: archived .untaught is declined before the gate, siteTupleIsLegal is pinned directly, and the validator/junk-rule prose is corrected. Decision 10 of cloudkit-mirroring, Req 4.1.1.
guard record.mode != .untaught else { return } before the gate (LibraryRepository+ConfirmImport.swift:427) with archivedUntaughtRecordDoesNotUndesignate; siteTupleTable and siteTupleRetainedHistoryAndGroupCounting pin the shared predicate; Decision 10 / Req 4.1.1 / design.md / report all attribute the junk-rule clause to validatedRecentSiteMode, not the validator..taught archive over a row re-taught with a different active rule is declined whole at gate time (two active groups before step 5 demotes one), so its display name is not restored. Add to Decision 10's Negative consequences and, ideally, a test row.displayName of "" now overwrites a hostname-default name (the old code could not). Unreachable from the exporter today; a !record.displayName.isEmpty clause closes it..taught restore; no probe that a locally authored name (not the hostname) is left alone; idempotence asserted only for .articles by value equality.BackupArchiveReferenceChecks.swift:172-189 still holds a third copy of the tuple table over wire types, counting active rows where the store copy counts identity groups. Listed in the report's follow-ups.Set of active ids is built once per call.Ready to push
Every must/should item from the previous round is closed and verified: the archived .untaught early return exists with a regression test, siteTupleIsLegal has a direct 12-row table test, and the prose no longer claims the validator reads junkSuffixRule. make test-core exits 0. The validate(site:) lift was re-checked clause by clause and is behaviour-preserving. No code defects were found by any reviewer.
One behavioural consequence is undocumented and untested, raised independently by two reviewers: a .taught archive record landing on a row that was re-taught with a different rule after the export is declined whole (two active identity groups at gate time), so the display name is not restored even though step 5 settles the rules afterwards. This is the spec's letter, not a regression — main could not restore a name at all — but Decision 10's Negative consequences should list it. That, plus a few test-coverage nits, can go in this PR or the follow-ups; neither blocks pushing.
a51ebb5 T-2051: investigation report and failing regression tests for the Site designation restore 3301aa1 T-2051: restore a Site's designation from an archive where its rules permit it 09f6be4 T-2051: decline the designation whole, per row, against the validator's own table 2bc342b T-2051: archived untaught designation is no claim; pin siteTupleIsLegal; prose fixes (pre-push review) When you restore a backup, each website (a Site) in the archive carries a small "designation": a display name, a mode (untaught, taught, or articles), and an optional junk-suffix rule. Before this fix, restoring a backup never put that designation back onto a site the library already had — it only filled a blank name (which never happens) and a missing junk rule, and never touched the mode. So a site you had marked as "articles" that got damaged stayed damaged after a restore, even though the backup knew better.
A restore that silently skips part of the data is worse than one that fails loudly. Worse, the old code could write the junk rule without the mode, producing a combination the app then hides from the recent list.
LibraryValidator) saying which combinations of mode + title rules + URL rules are allowed. The import now asks that same rulebook before writing.untaught is not a claim; it is ignored so it cannot undo something you marked after the backup was taken.LibraryRepository.upsert step 1 no longer applies Site scalars inline while iterating payload.sites. Matched records are collected, the archive's title patterns and URL rules are inserted (and remembered per hostname in two dictionaries), and only then does the new applyDesignation(_:to:patterns:urlRules:) run per matched record, sorted by hostname. Ordering matters: a .taught designation is legal only because the active title rule arrives in the same step.
LibraryValidator.validate(site:)'s mode switch was lifted into siteTupleIsLegal(_:patterns:urlRules:) -> Bool, with the three diagnosis strings moved to a private illegalSiteTupleReason message table. Both the validator and the import call the one predicate.
SiteReconciler consolidates them in step 5; legality is asked of the survivor row's own records plus what this pass inserted for it.Sites have no modifiedAt, so there is no recency guard; the legality gate stands in. An older archive can replace a newer junk-suffix rule on an .articles site (accepted, re-teachable). A row re-taught with a different rule after the export declines the designation at gate time, so its name is not restored.
applyDesignation order: (1) guard record.mode != .untaught — the tuple gate would accept (.untaught, [], []) against a freshly marked .articles row, so this must precede it; (2) siteTupleIsLegal(record.mode, patterns:, urlRules:) over survivor.patternValues + insertedPatterns[hostname]; (3) name written only when local is empty or equals the hostname and differs; (4) mode and junk = mode == .articles ? record.junkSuffixRule : nil written together. The junk-nil-off-articles rule is the import's own restatement of validatedRecentSiteMode's read-side clause — the validator never reads junkSuffixRule.
The patterns array may contain an inserted pattern twice if SwiftData has already propagated the unsaved inverse onto patternValues; every clause (isEmpty, allSatisfy, Set(map(\.id)).count) is idempotent under repetition, so this is safe.
Old .untaught: patterns.isEmpty && currentRules.isEmpty && rules.allSatisfy { importedV2 && !isCurrent }; the allSatisfy subsumes currentRules.isEmpty. Old .articles: activePatternCount == 0 && currentRules.isEmpty ≡ allSatisfy { !$0.isCurrent }. currentRules is still used for the row-count and greatest-version checks that remain in validate(site:).
SiteUnionProjection.repairsInPlace demotes one, so the row ends legal .taught, but the name is not restored. Not a regression from main (which never restored names) but undocumented.displayName == "" passes the unnamed test and overwrites the hostname default; unreachable from the exporter, cheap to guard.SiteReconciler.applyUnion can still fill a survivor's nil junk rule from a loser row two steps later, so Decision 10's "can no longer manufacture the shape" holds for the designation pass only.BackupArchiveReferenceChecks counts active rows on wire types; the store copy counts groups. Diverges for a duplicated-row archive. Deferred.Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift
Why it matters. This is the fix. The order of the two guards is load-bearing: untaught before gate (the gate would accept it), gate before name (declined whole).
What to look at. LibraryRepository+ConfirmImport.swift:374-456, applyDesignation
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift
Why it matters. A .taught restore is only legal once the archive's active title rule is in the context. Reading inserted rows off an unsaved inverse would be timing-dependent, hence the dictionaries.
What to look at. LibraryRepository+ConfirmImport.swift:181-250
Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift
Why it matters. Behaviour-preserving lift verified clause by clause; gives the import a single shared definition of a legal tuple.
What to look at. LibraryValidator.swift:699-753
Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift
Why it matters. The import matrix had no Site-designation row at all; the untaught and per-row tests pin the two subtle guards.
What to look at. BackupImportTransactionTests.swift, articlesDesignationRestoredOntoUntaughtRow through designationLegalityIsPerRow
Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift
Why it matters. The .untaught arm is now unreachable from the import caller, so the shared predicate needs its own coverage.
What to look at. LibraryValidatorTests.swift, siteTupleTable and siteTupleRetainedHistoryAndGroupCounting
Adding a timestamp needs schema V10, container republication and a new archive format for four fields; and it would not fix the atomicity defect. The validator's own tuple table stands in. (Decision 10, Alternatives.)
No reader action writes .untaught, so an archive holding it records only that the site was not yet designated at export time; applying it would un-designate a site marked later, and the tuple gate cannot catch that. (Decision 10, added in 2bc342b.)
Duplicate Site rows are tolerated until step 5; validate(site:) filters === site for the same reason. Under the tolerated .taught + .taught merge shape a hostname-wide answer would decline designations each row was entitled to. (Decision 10.)
The validator never reads junkSuffixRule; the clause is validatedRecentSiteMode's. Restated in the import so the two halves of one designation cannot be written apart. (Decision 10, corrected in 2bc342b.)
Site.init defaults the name to the hostname and no non-import path writes the column, so the hostname is the constructor default, not an authored name. SiteUnionProjection.displayName spells unnamed as empty-only; the two agree in effect but not in code.
It runs over wire types, so it cannot call siteTupleIsLegal without a small protocol. Deferred to the report's follow-ups.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | Decision 10 consequences / tests | A .taught archive record over a row re-taught with a different active rule is declined whole at gate time (two active identity groups before step 5 demotes one), so the display name is not restored. Spec letter, not a regression from main, but unlisted in Decision 10's Negative consequences and untested. Raised independently by two reviewers. | Add a Negative consequence to Decision 10 and a test row; or evaluate the gate against the post-union shape. Not applied - review is report-only. |
| minor | applyDesignation display name | An archive displayName of "" passes the unnamed test and overwrites a hostname-default name. BackupArchiveReferenceChecks does not validate displayName. Unreachable from the exporter today. | Add !record.displayName.isEmpty to the name guard. Not applied - report-only. |
| minor | Test coverage | No test for junk rule forced nil on a .taught restore that carries one; no test that a locally authored name (not the hostname) is left alone; idempotence asserted only for .articles by value equality, not by a no-dirty probe. | Add rows via makeSiteDesignationPlan(mode: .taught, junkSuffixRule:). Not applied. |
| minor | Decision 10 claim scope | 'Import can no longer manufacture the junk-rule-outside-.articles shape' holds for applyDesignation only; SiteReconciler.applyUnion can still fill a survivor's nil junk rule from a loser row in step 5 under duplicate hostnames. Pre-existing. | Narrow the sentence or note it under follow-ups. Not applied. |
| nit | design.md:100 | 'applied whole where...' - the name is only taken onto an unnamed row. | Say 'applied - name only onto an unnamed row - where...'. Not applied. |
| nit | report.md | Investigation says 'the first draft' misattributed the junk-rule clause; Prevention says 'three drafts of the prose'. | Pick one. Not applied. |
| nit | BackupArchiveReferenceChecks.swift:172-189 | Third copy of the tuple table over wire types; counts active rows where the store copy counts groups. Deliberately deferred by the author. | Follow-up: generic siteTupleIsLegal over a tiny protocol both model and wire types adopt. |
| nit | Site 'unnamed' spelling | applyDesignation uses isEmpty || == hostname; SiteUnionProjection.displayName uses isEmpty only. Same effect, two spellings. | Optional Site.hasCustomDisplayName used by both. |
| nit | BackupImportTransactionTests helpers | createReadySiteStore / createReadyDuplicateSiteStore repeat the container bootstrap already in two sibling helpers (four copies). | Optional shared makeReadyContainer(at:). Pre-existing pattern. |
Click to expand.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swiftindex 96f4ddc..cd6fd40 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift@@ -178,12 +178,14 @@ extension LibraryRepository { var sitesByHostname = rowsByHostname.compactMapValues { SiteResolutionOrder.sorted($0).first }+ // A matched row's designation is applied **after** the rules below land+ // (Decision 10), because the archive's own rules are part of what makes+ // its designation legal: a `.taught` restore is legal precisely because+ // the active title rule arrives in this same step.+ var matchedRecords: [BackupV7Site] = [] for record in payload.sites {- if let existing = sitesByHostname[record.hostname] {- // Teaching state follows the union below, not the archive's- // wholesale claim; only an unnamed row takes a name.- if existing.displayName.isEmpty { existing.displayName = record.displayName }- if existing.junkSuffixRule == nil { existing.junkSuffixRule = record.junkSuffixRule }+ if sitesByHostname[record.hostname] != nil {+ matchedRecords.append(record) continue } let site = ArchiveRecordBuilders.makeSite(record)@@ -196,17 +198,55 @@ extension LibraryRepository { // library already holds is never overwritten, and one it lacks is // inserted. Their versions may collide with the ones already there — // both histories start at v1 — which the union below is what repairs.+ //+ // The inserted rows are collected per hostname rather than read back off+ // `Site.patterns`: the designation pass runs before this step's save, and+ // asking an unsaved inverse would make the answer depend on when SwiftData+ // propagates it.+ var insertedPatterns: [String: [TitlePattern]] = [:]+ var insertedURLRules: [String: [URLRulePattern]] = [:] var patternIDs = Set(try context.fetch(FetchDescriptor<TitlePattern>()).map(\.id)) for record in payload.titlePatterns where patternIDs.insert(record.id).inserted {- context.insert(- try ArchiveRecordBuilders.makeTitlePattern(- record, site: sitesByHostname[record.siteHostname]))+ let pattern = try ArchiveRecordBuilders.makeTitlePattern(+ record, site: sitesByHostname[record.siteHostname])+ context.insert(pattern)+ insertedPatterns[record.siteHostname, default: []].append(pattern) } var ruleIDs = Set(try context.fetch(FetchDescriptor<URLRulePattern>()).map(\.id)) for record in payload.urlRules where ruleIDs.insert(record.id).inserted {- context.insert(- try ArchiveRecordBuilders.makeURLRule(- record, site: sitesByHostname[record.siteHostname]))+ let rule = try ArchiveRecordBuilders.makeURLRule(+ record, site: sitesByHostname[record.siteHostname])+ context.insert(rule)+ insertedURLRules[record.siteHostname, default: []].append(rule)+ }++ // The designation of every hostname whose record matched a row already+ // there. Ordered by hostname, like everything else this pass writes, so+ // two runs over the same store do the same work in the same order.+ //+ // The rules handed to the guard are the **survivor row's own**, plus the+ // ones this pass inserted for that row — never the hostname's other+ // rows'. Legality is a per-row question (`LibraryValidator.validate`+ // filters `=== site` deliberately: "a second row's records belong to+ // that row's tuple, not to this one"), and this file tolerates duplicate+ // rows per hostname — `SiteReconciler` consolidates them in step 5,+ // after this pass has already saved. Asked over the hostname, the guard+ // would answer about a tuple no single row holds.+ //+ // A pattern this pass inserted may also have surfaced on+ // `survivor.patternValues` already, depending on when SwiftData+ // propagates an unsaved inverse. Harmless: every clause of the tuple+ // table is insensitive to a repeated row — emptiness, `allSatisfy`, and+ // a count of identity *groups*.+ for record in matchedRecords.sorted(by: { $0.hostname < $1.hostname }) {+ // Every matched record has its survivor: that is what put it in the list.+ let survivor = sitesByHostname[record.hostname]!+ applyDesignation(+ record, to: survivor,+ patterns: survivor.patternValues+ + (insertedPatterns[record.hostname] ?? []),+ urlRules: survivor.urlRuleValues+ + (insertedURLRules[record.hostname] ?? [])) } try saveStrategy.save(context) @@ -331,6 +371,90 @@ extension LibraryRepository { return try rowCounts(context: context) } + // MARK: - Site designation (Decision 10)++ /// The Site half of the upsert's update branch: an archive's designation —+ /// display name, mode, junk-suffix rule — applied to a row the library+ /// already holds.+ ///+ /// **Decision 10 of `cloudkit-mirroring`, amending Decision 8 and Req 4.1.**+ /// A `Site` carries no modification time and the wire record carries no+ /// timestamp, so the guard the Entry and Work paths use does not exist here.+ /// What stands in for it is the invariant the library already enforces: the+ /// designation is applied wherever the matched **row's** own title and URL+ /// rules make the resulting tuple legal, and declined **whole** — name+ /// included — where they do not. So an archive can restore a designation the+ /// library lost, and cannot claim one the library's own rules contradict.+ ///+ /// "The row's own", not "the hostname's": `LibraryValidator` asks the same+ /// question per Site row, and a hostname holding duplicate rows (tolerated+ /// here until `SiteReconciler` consolidates them, two steps later) has no+ /// single tuple for a hostname-wide answer to be about.+ ///+ /// An archived `.untaught` record is **never** applied. `.untaught` is the+ /// absence of a designation, not one: no reader action writes it (the only+ /// paths to it are `Site.init` and `SiteReconciler` stripping a loser row),+ /// so an archive saying "untaught" is not a claim the library lost, it is+ /// the archive having nothing to say. Applying it anyway un-designates+ /// whatever the reader marked after the export — and it would pass the+ /// tuple gate every time, because a freshly marked `.articles` row carries+ /// no patterns and no URL rules, which makes `(.untaught, [], [])`+ /// trivially legal.+ ///+ /// The old spelling filled an empty display name and a nil junk-suffix rule+ /// and never touched the mode at all, which meant three things: an+ /// `.articles` or `.taught` designation could not be restored (the union+ /// derives only `.taught` from the rules and reads the rest off this column);+ /// the display name could never be restored either, because `Site.init`+ /// defaults it to the hostname and nothing else ever writes it; and the junk+ /// rule could land on a row whose mode said `.taught` or `.untaught`, which+ /// is a shape no teaching path writes and which `RecentPresentation`+ /// quarantines out of the recent list.+ ///+ /// Mode and junk-suffix rule therefore move **together**, and the junk rule+ /// is forced nil off `.articles` — **this pass's own rule**, not something+ /// the tuple gate enforces. `LibraryValidator` never reads+ /// `Site.junkSuffixRule` at all; the "junk rule only in `.articles`" clause+ /// lives in `LibraryRepository.validatedRecentSiteMode`. The two fields are+ /// one fact stated twice, and writing half of it is what manufactured the+ /// illegal state.+ ///+ /// Every write is guarded by a comparison, so re-importing the same archive+ /// dirties nothing — the same property `SiteReconciler.applyUnion` keeps.+ internal static func applyDesignation(+ _ record: BackupV7Site,+ to site: Site,+ patterns: [TitlePattern],+ urlRules: [URLRulePattern]+ ) {+ // An archived untaught designation is the absence of a claim. Declining+ // it before the gate, because the gate would accept it.+ guard record.mode != .untaught else { return }+ // **The gate comes first, because the designation is declined whole.**+ // Display name is one of its three parts, so a contradicted designation+ // must not leave its name behind — that would be a partial application+ // of a record the library just refused.+ //+ // The table is `LibraryValidator`'s own, not a copy of it: one+ // definition, so import is structurally unable to write a state the+ // validator will quarantine.+ guard LibraryValidator.siteTupleIsLegal(+ record.mode, patterns: patterns, urlRules: urlRules)+ else {+ return+ }+ // An unnamed row takes the archive's name. "Unnamed" is empty *or* the+ // hostname itself: the constructor's default, and the only value any+ // path but an import has ever written.+ if site.displayName.isEmpty || site.displayName == site.hostname,+ site.displayName != record.displayName {+ site.displayName = record.displayName+ }+ if site.mode != record.mode { site.mode = record.mode }+ let junk = record.mode == .articles ? record.junkSuffixRule : nil+ if site.junkSuffixRule != junk { site.junkSuffixRule = junk }+ }+ // MARK: - Work commit /// The Work half of the upsert.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swiftindex 32d907b..1722a6c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift@@ -676,13 +676,7 @@ public enum LibraryValidator { catch { throw invalid("URLRulePattern", rule.id.uuidString, String(describing: error)) } } - // **One active flag per group** (Decision 15). Decision 5's invariant is- // that a taught Site holds exactly one active title *rule*, and a- // converged group is one rule however many rows carry it. Counting rows- // would restate the invariant as a claim about storage, which is what- // the membership clause above just stopped doing.- let activePatternCount = Set(patterns.filter(\.isActive).map(\.id)).count- // This one counts **rows** where the clause above counts groups (Q107).+ // This one counts **rows** where `siteTupleIsLegal` counts groups (Q107). // Deliberate: it is the stricter reading, so it fails safe, and no // shipped path produces two current rows of one group — // `demoteWithinSites` leaves at most one current row per Site row. It is@@ -702,25 +696,60 @@ public enum LibraryValidator { throw invalid("Site", id, "current URL rule must have the greatest retained version") } - switch site.mode {+ guard Self.siteTupleIsLegal(site.mode, patterns: patterns, urlRules: rules) else {+ throw invalid("Site", id, Self.illegalSiteTupleReason(site.mode))+ }+ }++ /// Whether one Site row's `(mode, title rules, URL rules)` tuple is legal —+ /// the closed table in its body, asked as a question rather than thrown.+ ///+ /// Exposed because the import path has to ask it *before* it writes: an+ /// archive's designation is applied only where the row's rules make the+ /// result legal and declined whole otherwise (Decision 10 of+ /// `cloudkit-mirroring`, Req 4.1.1). Sharing the definition is what makes+ /// "import cannot write a state the validator quarantines" a structural+ /// claim rather than two hand-maintained copies of one table.+ ///+ /// Per **row**, exactly like `validate(site:)`: the arrays are one Site+ /// row's own records. Passing a whole hostname's records answers a different+ /// question — see the `=== site` note in `validate(site:)`.+ internal static func siteTupleIsLegal(+ _ mode: SiteMode, patterns: [TitlePattern], urlRules: [URLRulePattern]+ ) -> Bool {+ // **One active flag per group** (Decision 15). Decision 5's invariant is+ // that a taught Site holds exactly one active title *rule*, and a+ // converged group is one rule however many rows carry it. Counting rows+ // would restate the invariant as a claim about storage, which is what+ // the membership clause in `validate(site:)` stopped doing.+ let activePatternCount = Set(patterns.filter(\.isActive).map(\.id)).count+ switch mode { case .untaught:- guard patterns.isEmpty, currentRules.isEmpty,- rules.allSatisfy({ $0.origin == .importedV2 && !$0.isCurrent }) else {- throw invalid("Site", id, "untaught tuple may retain only imported V2 URL history")- }+ // An untaught tuple may retain only imported-V2 URL history. "No+ // current rule" is implied by the `allSatisfy` rather than stated+ // twice.+ return patterns.isEmpty+ && urlRules.allSatisfy { $0.origin == .importedV2 && !$0.isCurrent } case .taught: // Decision 5: a taught non-articles Site always holds exactly one // active title rule. Any (WC/W) title form combined with any URL field // set (none/I/IS/S) is legal, including the acknowledged-unsettled // W + I/none combination (Req 2.1); at most one current URL rule is- // enforced above.- guard activePatternCount == 1 else {- throw invalid("Site", id, "taught tuple requires exactly one active title rule")- }+ // enforced by `validate(site:)`, which is a per-row storage claim and+ // not part of this tuple table.+ return activePatternCount == 1 case .articles:- guard activePatternCount == 0, currentRules.isEmpty else {- throw invalid("Site", id, "articles tuple cannot have active title or URL rules")- }+ return activePatternCount == 0 && urlRules.allSatisfy { !$0.isCurrent }+ }+ }++ /// The diagnosis text for a tuple `siteTupleIsLegal` refused. A message+ /// table, not a second copy of the rule.+ private static func illegalSiteTupleReason(_ mode: SiteMode) -> String {+ switch mode {+ case .untaught: "untaught tuple may retain only imported V2 URL history"+ case .taught: "taught tuple requires exactly one active title rule"+ case .articles: "articles tuple cannot have active title or URL rules" } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex 78cd526..03006ba 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -193,6 +193,197 @@ struct BackupImportTransactionTests { #expect(facts.last?.workURL == "https://imported.example.com/other") } + // MARK: - Req 4.1, Decision 10: the Site designation restores over an existing row++ /// T-2051. The archive's `.articles` designation reaching a row the library+ /// already holds.+ ///+ /// The failure this pins: a bad merge leaves `news.example` `.untaught` with+ /// no rules, the reader restores last week's archive, and the row keeps the+ /// damaged state — `modeRaw` was never applied to a matched row, `displayName`+ /// only where the local one was empty (which `Site.init` makes unreachable:+ /// it defaults the name to the hostname), and the junk-suffix rule only where+ /// the local one was nil. The reader was told the import committed.+ @Test("An archive's articles designation is restored onto an existing untaught row")+ func articlesDesignationRestoredOntoUntaughtRow() async throws {+ let env = try TestEnvironment()+ try createReadySiteStore(at: env.configuration, hostname: "news.example", mode: .untaught)+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)++ let junk = try junkSuffixRule(anchorCount: 1)+ _ = try await repository.confirmImport(+ plan: try makeSiteDesignationPlan(+ hostname: "news.example", displayName: "News", mode: .articles,+ junkSuffixRule: junk))++ #expect(+ try await repository.siteFacts() == [+ SiteFacts(+ hostname: "news.example", displayName: "News", mode: .articles,+ junkSuffixRule: junk, activePatterns: 0)+ ])+ }++ /// The same restore run twice. Convergence, not just correctness: the+ /// designation is a value write, so a second application of the same archive+ /// has to land on the same row unchanged.+ @Test("Restoring the same designation twice converges")+ func designationRestoreIsIdempotent() async throws {+ let env = try TestEnvironment()+ try createReadySiteStore(at: env.configuration, hostname: "news.example", mode: .untaught)+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)+ let plan = try makeSiteDesignationPlan(+ hostname: "news.example", displayName: "News", mode: .articles,+ junkSuffixRule: try junkSuffixRule(anchorCount: 1))++ _ = try await repository.confirmImport(plan: plan)+ let first = try await repository.siteFacts()+ _ = try await repository.confirmImport(plan: plan)++ #expect(try await repository.siteFacts() == first)+ }++ /// The `.taught` half of the same defect, and the sharper one: the archive's+ /// active title rule *was* inserted onto the matched row while the mode was+ /// not, which leaves `.untaught` retaining a pattern — the tuple the+ /// validator quarantines. Import manufactured it.+ @Test("An archive's taught designation is restored with the rule that justifies it")+ func taughtDesignationRestoredOntoUntaughtRow() async throws {+ let env = try TestEnvironment()+ try createReadySiteStore(at: env.configuration, hostname: "novels.example", mode: .untaught)+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)++ _ = try await repository.confirmImport(+ plan: try makeSiteDesignationPlan(+ hostname: "novels.example", mode: .taught, activePattern: true))++ #expect(+ try await repository.siteFacts() == [+ SiteFacts(+ hostname: "novels.example", displayName: "novels.example", mode: .taught,+ junkSuffixRule: nil, activePatterns: 1)+ ])+ }++ /// A junk-suffix rule the reader re-taught and then lost. The old spelling+ /// wrote the archive's rule only onto a row holding none, so the corrected+ /// one could never come back.+ @Test("An archive's junk-suffix rule replaces the one an articles row holds")+ func archiveJunkSuffixRuleReplacesTheLocalOne() async throws {+ let env = try TestEnvironment()+ try createReadySiteStore(+ at: env.configuration, hostname: "news.example", mode: .articles,+ junkSuffixRule: try junkSuffixRule(anchorCount: 1))+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)++ let corrected = try junkSuffixRule(anchorCount: 2)+ _ = try await repository.confirmImport(+ plan: try makeSiteDesignationPlan(+ hostname: "news.example", mode: .articles, junkSuffixRule: corrected))++ #expect(try await repository.siteFacts().first?.junkSuffixRule == corrected)+ }++ /// The other side of Decision 10: the rules the library holds outrank the+ /// archive's claim about them.+ ///+ /// A `.taught` row holding an active title rule cannot legally be `.articles`+ /// and cannot legally retain a junk-suffix rule, so an older archive+ /// describing the hostname as `.articles` is declined rather than written.+ /// The old spelling wrote the junk rule anyway — mode and junk rule moved+ /// independently — and left a `.taught` row retaining one, which is the+ /// quarantine tuple again.+ ///+ /// **Declined whole includes the name.** The archive's display name differs+ /// from the local one here deliberately: a spelling that gated only the mode+ /// and the junk rule still overwrote `displayName`, which is a partial+ /// application of a record the library refused.+ @Test("A designation the row's rules contradict is declined, name included")+ func contradictedDesignationIsDeclined() async throws {+ let env = try TestEnvironment()+ try createReadySiteStore(+ at: env.configuration, hostname: "novels.example", mode: .taught, activePattern: true)+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)++ _ = try await repository.confirmImport(+ plan: try makeSiteDesignationPlan(+ hostname: "novels.example", displayName: "Novels Online", mode: .articles,+ junkSuffixRule: try junkSuffixRule(anchorCount: 1)))++ #expect(+ try await repository.siteFacts() == [+ SiteFacts(+ hostname: "novels.example", displayName: "novels.example", mode: .taught,+ junkSuffixRule: nil, activePatterns: 1)+ ])+ }++ /// An archived `.untaught` record is not a designation to restore.+ ///+ /// The tuple gate alone would let it through: the reader marks+ /// `news.example` as articles *after* the export, and marking a never-taught+ /// site leaves no patterns and no URL rules, so `(.untaught, [], [])` is+ /// trivially legal. Applying it would write `mode = .untaught,+ /// junkSuffixRule = nil` and silently un-designate the site the reader just+ /// marked. `.untaught` is the absence of a claim — no reader action writes+ /// it — so the record is declined before the gate, name included.+ @Test("An archived untaught record does not un-designate a newly marked articles row")+ func archivedUntaughtRecordDoesNotUndesignate() async throws {+ let env = try TestEnvironment()+ let junk = try junkSuffixRule(anchorCount: 1)+ try createReadySiteStore(+ at: env.configuration, hostname: "news.example", mode: .articles,+ junkSuffixRule: junk)+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)++ _ = try await repository.confirmImport(+ plan: try makeSiteDesignationPlan(+ hostname: "news.example", displayName: "News", mode: .untaught))++ #expect(+ try await repository.siteFacts() == [+ SiteFacts(+ hostname: "news.example", displayName: "news.example", mode: .articles,+ junkSuffixRule: junk, activePatterns: 0)+ ])+ }++ /// Legality is the **survivor row's own** question, not the hostname's.+ ///+ /// Two Site rows for one hostname is a state this pass tolerates — an+ /// unconsolidated CloudKit merge — and `SiteReconciler` only repairs it two+ /// steps later, after the designation has already been saved. Each row here+ /// carries its own active title rule, so the hostname's rules taken together+ /// hold *two* active rules and no row could legally be `.taught`; the+ /// survivor's own tuple holds exactly one and legally is.+ ///+ /// Asked over the hostname, the guard declines a designation the validator+ /// would accept. The display name is what shows it: `SiteReconciler` writes+ /// the union's mode onto the survivor regardless, but never touches a name.+ @Test("Designation legality is asked of the survivor row, not the hostname")+ func designationLegalityIsPerRow() async throws {+ let env = try TestEnvironment()+ try createReadyDuplicateSiteStore(+ at: env.configuration, hostname: "novels.example",+ patternIDs: [Self.lowerPatternID, Self.higherPatternID])+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)++ _ = try await repository.confirmImport(+ plan: try makeSiteDesignationPlan(+ hostname: "novels.example", displayName: "Novels Online", mode: .taught))++ // The survivor is the row owning the lower title-rule id (Decision 5,+ // steps 1–3): both rows hold an active rule, so the id decides.+ #expect(try await repository.survivorDisplayName(hostname: "novels.example")+ == "Novels Online")+ #expect(try await repository.siteFacts().count == 2)+ }++ /// Fixed rather than minted, so `SiteResolutionOrder`'s step-3 tiebreak picks+ /// a known row as the survivor.+ private static let lowerPatternID = UUID(uuidString: "00000000-0000-0000-0000-0000000000C1")!+ private static let higherPatternID = UUID(uuidString: "00000000-0000-0000-0000-0000000000D2")!+ @Test("Re-importing the same archive changes nothing") func reimportIsIdempotent() async throws { let env = try TestEnvironment()@@ -514,6 +705,137 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic) } +/// A certified library holding exactly one Site in the given state, for the+/// designation assertions — the shape a restore lands on when the library is not+/// empty.+private func createReadySiteStore(+ at configuration: LibraryConfiguration,+ hostname: String,+ displayName: String? = nil,+ mode: SiteMode,+ junkSuffixRule: JunkSuffixRule? = nil,+ activePattern: Bool = false+) throws {+ let fileManager = FileManager.default+ try fileManager.createDirectory(+ at: configuration.storeURL.deletingLastPathComponent(),+ withIntermediateDirectories: true+ )+ let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let storeConfig = ModelConfiguration(+ "AsterismV3",+ schema: schema,+ url: configuration.storeURL,+ cloudKitDatabase: .none+ )+ let container = try ModelContainer(+ for: schema,+ migrationPlan: AsterismV9MigrationPlan.self,+ configurations: [storeConfig]+ )+ let context = ModelContext(container)+ let site = Site(hostname: hostname, displayName: displayName)+ context.insert(site)+ site.mode = mode+ site.junkSuffixRule = junkSuffixRule+ if activePattern {+ let pattern = try TitlePattern(+ version: 1, isActive: true, createdAt: Date(timeIntervalSince1970: 1000),+ definition: .wholeTitle, site: site)+ context.insert(pattern)+ }+ try context.save()+ try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+}++/// A certified library holding **two** Site rows for one hostname, each taught+/// with its own active title rule — the unconsolidated-merge shape the import+/// upsert tolerates until `SiteReconciler` runs.+///+/// The rule ids are the caller's, because `SiteResolutionOrder` decides the+/// survivor on the lowest owned title-rule id once both rows tie on "has an+/// active rule" (Decision 5, steps 1 and 3).+private func createReadyDuplicateSiteStore(+ at configuration: LibraryConfiguration,+ hostname: String,+ patternIDs: [UUID]+) throws {+ try FileManager.default.createDirectory(+ at: configuration.storeURL.deletingLastPathComponent(),+ withIntermediateDirectories: true+ )+ let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let storeConfig = ModelConfiguration(+ "AsterismV3",+ schema: schema,+ url: configuration.storeURL,+ cloudKitDatabase: .none+ )+ let container = try ModelContainer(+ for: schema,+ migrationPlan: AsterismV9MigrationPlan.self,+ configurations: [storeConfig]+ )+ let context = ModelContext(container)+ for patternID in patternIDs {+ let site = Site(hostname: hostname)+ context.insert(site)+ site.mode = .taught+ let pattern = try TitlePattern(+ id: patternID, version: 1, isActive: true,+ createdAt: Date(timeIntervalSince1970: 1000),+ definition: .wholeTitle, site: site)+ context.insert(pattern)+ }+ try context.save()+ try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+}++/// A junk-suffix rule with `anchorCount` end-anchored positions — the shape+/// `JunkSuffixRule` requires (offsets `n-1 … 0`, every origin `.end`). Two+/// different counts give two distinguishable rules.+private func junkSuffixRule(anchorCount: Int) throws -> JunkSuffixRule {+ try JunkSuffixRule(+ version: 1,+ anchors: try (0..<anchorCount).reversed().map {+ try SegmentPositionSpec(origin: .end, offset: $0)+ })+}++/// An archive describing one Site and nothing else — no entries, no works, so+/// what the assertions read is the designation alone.+private func makeSiteDesignationPlan(+ hostname: String,+ displayName: String? = nil,+ mode: SiteMode,+ junkSuffixRule: JunkSuffixRule? = nil,+ activePattern: Bool = false+) throws -> BackupImportPlan {+ let epoch = Date(timeIntervalSince1970: 1_800_000_000)+ let site = BackupV7Site(+ hostname: hostname, displayName: displayName ?? hostname,+ mode: mode, junkSuffixRule: junkSuffixRule)+ let patterns: [BackupV7TitlePattern] = activePattern+ ? [+ BackupV7TitlePattern(+ // Fixed, not minted: two applications of one archive must match+ // the same rule row rather than insert a second one.+ id: UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!,+ siteHostname: hostname, version: 1, isActive: true, createdAt: epoch,+ definition: StoredPatternDefinition(definition: .wholeTitle))+ ]+ : []+ let payload = BackupImportPayload(+ entries: [], works: [], sites: [site], titlePatterns: patterns, urlRules: [])+ let metadata = BackupImportMetadata(+ formatVersion: 7, schemaVersion: 8, appBuild: "test-1.0", exportedAt: epoch,+ capabilityGate: "multi-site", entryCount: 0, workCount: 0)+ return BackupImportPlan(+ metadata: metadata, payload: payload,+ counts: LibraryRecordCounts(+ entries: 0, works: 0, sites: 1, titlePatterns: patterns.count, urlRulePatterns: 0))+}+ /// An archive with `entryCount` Entries on one untaught Site, for the chunking /// assertions. Deliberately plain: what is under test is the commit boundaries, /// not the record shapes.@@ -743,10 +1065,44 @@ private struct MembershipFacts: Equatable, Sendable { var createdAt: Date } +/// What one Site row holds after an import — its designation, plus the active+/// title-rule count the designation has to agree with.+private struct SiteFacts: Equatable, Sendable {+ var hostname: String+ var displayName: String+ var mode: SiteMode+ var junkSuffixRule: JunkSuffixRule?+ var activePatterns: Int+}+ // MARK: - Repository probes extension LibraryRepository { + /// The display name of the row a hostname resolves to — the row capture,+ /// teaching and import all write, when the hostname holds more than one.+ fileprivate func survivorDisplayName(hostname: String) async throws -> String? {+ try await withLockedContext(mode: .shared, operation: "reading sites") { context in+ SiteResolutionOrder.sorted(+ try context.fetch(FetchDescriptor<Site>())+ .filter { $0.hostname == hostname }+ ).first?.displayName+ }+ }++ fileprivate func siteFacts() async throws -> [SiteFacts] {+ try await withLockedContext(mode: .shared, operation: "reading sites") { context in+ try context.fetch(FetchDescriptor<Site>())+ .map {+ SiteFacts(+ hostname: $0.hostname, displayName: $0.displayName, mode: $0.mode,+ junkSuffixRule: $0.junkSuffixRule,+ activePatterns: $0.patternValues.count(where: \.isActive))+ }+ .sorted { $0.hostname < $1.hostname }+ }+ }+ fileprivate func membershipFacts() async throws -> [MembershipFacts] { try await withLockedContext(mode: .shared, operation: "reading memberships") { context in try context.fetch(FetchDescriptor<WorkSiteMembership>())
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swiftindex f82b16b..26fa987 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift@@ -25,6 +25,87 @@ struct LibraryValidatorTests { } } + // MARK: - The shared tuple table (Decision 10 of `cloudkit-mirroring`)++ /// `siteTupleIsLegal` is the closed table `validate(site:)` throws on, and+ /// since T-2051 it is also what the backup import asks *before* it writes a+ /// designation. It has two callers with different coverage, so it is pinned+ /// here directly rather than inferred from either of them.+ ///+ /// Note what the table does **not** mention: `Site.junkSuffixRule`. The+ /// validator never reads that column — the "a junk rule belongs only to+ /// `.articles`" clause lives in `LibraryRepository.validatedRecentSiteMode`,+ /// and the import enforces it as its own rule.+ @Test("The Site tuple table answers every mode against every rule combination")+ func siteTupleTable() throws {+ let timestamp = Date(timeIntervalSince1970: 1000)+ let activePattern = try TitlePattern(+ version: 1, isActive: true, createdAt: timestamp, definition: .wholeTitle)+ let currentRule = try URLRulePattern(+ version: 1, isCurrent: true, createdAt: timestamp, origin: .readerTaught,+ definition: .sequence(locator: .query(name: ExactScalarString("chapter"))))++ let table: [(mode: SiteMode, patterns: [TitlePattern], rules: [URLRulePattern], legal: Bool)] = [+ // Untaught: no title rule at all, and no current URL rule.+ (.untaught, [], [], true),+ (.untaught, [activePattern], [], false),+ (.untaught, [], [currentRule], false),+ (.untaught, [activePattern], [currentRule], false),+ // Taught: exactly one active title rule; URL rules are unconstrained.+ (.taught, [], [], false),+ (.taught, [activePattern], [], true),+ (.taught, [], [currentRule], false),+ (.taught, [activePattern], [currentRule], true),+ // Articles: no active title rule and no current URL rule.+ (.articles, [], [], true),+ (.articles, [activePattern], [], false),+ (.articles, [], [currentRule], false),+ (.articles, [activePattern], [currentRule], false),+ ]+ for row in table {+ let label: Comment = """+ \(row.mode) with \(row.patterns.count) active pattern(s), \+ \(row.rules.count) current URL rule(s)+ """+ #expect(+ LibraryValidator.siteTupleIsLegal(+ row.mode, patterns: row.patterns, urlRules: row.rules) == row.legal,+ label)+ }+ }++ /// The three clauses the four-way table cannot reach: an untaught row may+ /// retain imported-V2 URL history and only that, an articles row may retain+ /// historical rules of any origin, and "exactly one active title rule"+ /// counts identity groups rather than rows (Decision 15).+ @Test("Retained URL history and converged rule groups are legal tuples")+ func siteTupleRetainedHistoryAndGroupCounting() throws {+ let timestamp = Date(timeIntervalSince1970: 1000)+ let importedHistory = try URLRulePattern(+ version: 1, isCurrent: false, createdAt: timestamp, origin: .importedV2,+ definition: .work(locator: .query(name: ExactScalarString("story"))))+ let taughtHistory = try URLRulePattern(+ version: 1, isCurrent: false, createdAt: timestamp, origin: .readerTaught,+ definition: .work(locator: .query(name: ExactScalarString("story"))))++ #expect(+ LibraryValidator.siteTupleIsLegal(.untaught, patterns: [], urlRules: [importedHistory]))+ #expect(+ !LibraryValidator.siteTupleIsLegal(.untaught, patterns: [], urlRules: [taughtHistory]))+ #expect(+ LibraryValidator.siteTupleIsLegal(.articles, patterns: [], urlRules: [taughtHistory]))++ // One rule materialised twice — a converged CloudKit group, not two+ // teachings — is still one active title rule.+ let sharedID = UUID()+ let converged = try (0..<2).map { _ in+ try TitlePattern(+ id: sharedID, version: 1, isActive: true, createdAt: timestamp,+ definition: .wholeTitle)+ }+ #expect(LibraryValidator.siteTupleIsLegal(.taught, patterns: converged, urlRules: []))+ }+ // MARK: - Closed tuple invariants (per-Site diagnoses) @Test("A taught Site with no active title rule is diagnosed (Decision 5)")
diff --git a/specs/bugfixes/backup-import-cannot-restore-site-scalars/report.md b/specs/bugfixes/backup-import-cannot-restore-site-scalars/report.mdnew file mode 100644index 0000000..1e8350c--- /dev/null+++ b/specs/bugfixes/backup-import-cannot-restore-site-scalars/report.md@@ -0,0 +1,310 @@+# Bugfix Report: Backup Import Cannot Restore a Site's Designation++**Date:** 2026-08-29+**Status:** Fixed+**Ticket:** T-2051++## Description of the Issue++Restoring an archive over a **non-empty** library could not put a Site's+designation back. `upsert` step 1 in `LibraryRepository+ConfirmImport.swift`+matched a Site by hostname and then:++- applied `displayName` only where the local one was **empty**;+- applied `junkSuffixRule` only where the local one was **nil**;+- never applied `modeRaw` at all.++`Site.init` defaults `displayName` to the hostname, and nothing in the app ever+writes that column, so "empty" was unreachable in practice: the archive's name+could never land on an existing row. The mode was simply dropped.++**Reproduction steps:**++1. The reader marks `news.example` as `.articles` and teaches a junk-suffix rule.+2. A bad merge (or a T-2288-class defect) leaves the row `.untaught` with no+ rules.+3. The reader restores last week's archive, which describes `news.example` as+ `.articles` with the junk-suffix rule and a display name.+4. `sitesByHostname` matches the existing row, `modeRaw` is skipped, and+ `SiteUnionProjection.mode` returns `survivor.mode` — `.untaught`. The+ designation is silently not restored, and the import reports success.++**Impact:** Medium. Independent of mirroring; only bites when restoring over a+non-empty library — which is when restores actually happen. Silent: the reader is+told the import committed. Two sharper defects were found alongside it and are+fixed with it (below).++## Investigation Summary++- **Symptoms examined:** the ticket's scenario, plus what the store and the+ validator say a Site row is allowed to hold.+- **Code inspected:** `LibraryRepository+ConfirmImport.swift` (`upsert` step 1),+ `SiteUnionProjection.swift` (`mode`, `displayName`, `junkSuffixRule`),+ `SiteReconciler.swift` (`applyUnion`), `LibraryValidator.swift` (the Site tuple+ table), `LibraryRepository+RecentPresentation.swift` (the quarantine read of+ the same table), `Models.swift` (`Site` at schema V9), `BackupV7Types.swift`+ (`BackupV7Site`), `ArchiveRecordBuilders.swift`.+- **Hypotheses tested and ruled out:**+ - *The reconciler repairs it afterwards.* It does not. `SiteReconciler.run`+ writes `survivor.mode = projection.mode` only when+ `projection.consolidates` is true, which for an ordinary single-row hostname+ with nothing to renumber is false. Nothing corrects the dropped mode.+ - *The union derives the mode from the rules, so the column does not matter.*+ Only for `.taught`. `SiteUnionProjection.mode` falls back to `survivor.mode`+ whenever no active title rule is kept, so `.articles` and `.untaught` are+ carried by the column alone.+ - *A modification guard could be applied, as Decision 8 does for Entry/Work.*+ It cannot: `Site` carries **no** modification column at schema V9, and+ `BackupV7Site` carries no timestamp.++### Two further defects found in the same lines++1. **Import could manufacture a junk rule outside `.articles`.** The junk-suffix+ rule and the mode moved independently. A non-nil `junkSuffixRule` belongs only+ to `.articles` — `LibraryRepository.validatedRecentSiteMode` drops a Site from+ the recent list for holding one under any other mode, and no teaching path+ writes that shape — so an `.articles` archive reaching a `.untaught` or+ `.taught` row wrote the junk rule, left the mode alone, and produced it.+ (`LibraryValidator` is *not* the enforcer here: its closed tuple table reads+ mode, title rules and URL rules, and never reads `junkSuffixRule`. The prose+ in the first draft of this report and of Decision 10 said otherwise; both are+ corrected.)+2. **A `.taught` restore left the row `.untaught` holding a pattern.** The+ archive's active title rule *was* inserted onto the matched row (rules are+ inserted by UUID) while the mode was not — `.untaught` retaining a pattern+ *is* a tuple `LibraryValidator` quarantines, arrived at from the other side.++## Discovered Root Cause++**Defect type:** Incomplete update path / one invariant split across two writes.++`upsert`'s Site branch was a set of independent "fill if absent" field rules,+when a Site's mode and junk-suffix rule are **one designation** constrained by+the rules the hostname holds. Two consequences follow: an absent-only rule cannot+express *restore*, so a designation lost locally could never come back; and+writing one half of the designation without the other manufactures an illegal+tuple.++**Why it occurred:** Decision 8 of `specs/cloudkit-mirroring` settled the+Entry/Work half of the update guard against `modifiedAt` and disposed of Sites in+one clause — "Site scalar fields follow the winner row's teaching state via the+union merge". That clause is true for `.taught`, which the union derives from the+kept active rule, and false for `.articles`, `.untaught`, the display name and+the junk-suffix rule, which the union reads off the survivor row and therefore+cannot restore.++**Contributing factors:** `Site` has no `modifiedAt`, so the guard Decision 8+uses everywhere else was not available and the question was deferred rather than+answered. The import matrix suite (now `BackupImportTransactionTests`) had no+Site-designation row at all.++## The Design Decision++Recorded as **Decision 10** in `specs/cloudkit-mirroring/decision_log.md`, with+Req 4.1 amended to carry it (new sub-clause 4.1.1).++> An archive's Site designation — display name, mode, junk-suffix rule — is+> applied to a matched row wherever **that row's own** title and URL rules make+> the resulting tuple legal, and declined whole — name included — where they do+> not. Mode and junk-suffix rule move together; a row whose display name is empty+> or is its own hostname takes the archive's name. An archive record whose mode is+> `.untaught` is never applied: `.untaught` is the absence of a designation rather+> than one.++The alternative the ticket named first — a `modifiedAt`-style guard for Site+scalars — was rejected because `Site` carries no modification column and+`BackupV7Site` no timestamp, so it would cost a schema V10 bump, a CloudKit+schema republication, and an 8/9 wire format for four fields on a table with tens+of rows; it would also not have fixed the manufactured-quarantine defect, which+is an atomicity problem rather than a recency one. The ticket's second option —+letting Decision 8's clause stand and amending Req 4.1 to match — was rejected+because it makes the loss the spec's stated behaviour *and* leaves import able to+write a state the validator quarantines.++The tuple table is the right guard because it is the invariant the library+already enforces: "the archive cannot make the library illegal" is then checked+by *calling* the validator's own predicate, so there is one definition rather+than two that can drift. And+`.untaught` is never a newer state a restore could regress — no reader action+writes it (only the initial value and `SiteReconciler` stripping a loser row),+so a row sitting `.untaught` with no rules is either never-taught or damaged, and+the archive is the better witness in both cases. Full rationale, five+alternatives, and consequences are in the decision log entry.++**The same fact read from the archive's side is why an archived `.untaught`+record is declined outright.** The pre-push review found this as a regression the+first spelling of the fix introduced: the old code never wrote `mode` at all, and+the new one did, so an archive describing a hostname as `.untaught` could+un-designate a row the reader marked `.articles` *after* the export. The tuple+gate does not catch it — marking a never-taught site articles leaves no title+patterns and no URL rules, so `(.untaught, [], [])` is trivially legal — and the+gate is the wrong place to look anyway: if no reader action writes `.untaught`,+an archive holding it records only that the hostname had not been designated when+the export ran, which is not evidence about now. `applyDesignation` therefore+returns before the gate on `record.mode == .untaught`.++## Resolution for the Issue++**Changes made:**++- `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift`+ — `upsert` step 1 now collects the archive records that matched an existing row+ and, **after** the archive's title and URL rules are inserted, applies their+ designation in one hostname-ordered pass before the step's save. Each write is+ comparison-guarded, so a re-import dirties nothing.+- Same file — new `applyDesignation(_:to:patterns:urlRules:)`. An archived+ `.untaught` record returns immediately, before anything else: it is the absence+ of a claim, and the gate would accept it. The legality gate runs **next**, and+ before any write, so a contradicted designation leaves the display name alone+ too: the designation is declined whole, and its name is one of its three parts.+- `Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift` — the mode+ switch in `validate(site:)` is lifted into+ `siteTupleIsLegal(_:patterns:urlRules:)`, the closed tuple table asked as a+ question instead of thrown as an error, which `applyDesignation` calls. The+ validator keeps its per-mode diagnosis texts as a message table beside it.+ Behaviour is unchanged: the two `.untaught` / `.articles` clauses that stated+ "no current URL rule" twice now state it once, via the `allSatisfy`.+- `specs/cloudkit-mirroring/requirements.md` — Req 4.1 gains sub-clause 4.1.1.+- `specs/cloudkit-mirroring/decision_log.md` — Decision 10; Decision 8's status+ line now points at it.+- `specs/cloudkit-mirroring/design.md` — the Site upsert bullet list gains the+ matched-Site designation rule.++**Approach rationale:** The designation pass runs after the rule inserts because+the archive's own rules are part of what makes its designation legal — a+`.taught` restore is legal precisely because the active title rule lands in the+same step. The rules it asks over are the **matched row's own**, plus the ones+this pass inserted for that row: `LibraryValidator.validate(site:)` filters+`=== site` deliberately, and this file tolerates duplicate Site rows per hostname+(`SiteReconciler` consolidates them two steps later, after this pass has saved),+so a hostname-wide reading would answer about a tuple no single row holds. The+inserted rows are carried in a per-hostname dictionary rather than read back off+the `Site.patterns` inverse, so the answer does not depend on SwiftData having+propagated an unsaved inverse; every clause of the tuple table is insensitive to+a row appearing twice, so an inverse that *has* propagated is harmless.++**Alternatives considered:**++- *Apply the archive's designation unconditionally.* Rejected: an `.articles`+ archive over a `.taught` row holding an active title rule produces an illegal+ tuple, and an old archive would revert a newer teach fleet-wide.+- *Apply only where the hostname holds no teaching at all.* Rejected as narrower+ than the failure — it restores the headline scenario but still cannot put back+ a corrected junk-suffix rule on a row that already holds one, which the ticket+ names.+- *Add `Site.modifiedAt` (schema V10) and a timestamp on the wire Site.* Rejected+ as disproportionate; see the decision log.+- *Have the reconciler repair it afterwards.* Rejected: it has no access to what+ the archive said, so it can only preserve what import wrote.++## Regression Test++**Test file:** `Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift`++| Test | What it verifies |+|------|------------------|+| `articlesDesignationRestoredOntoUntaughtRow` | The ticket's scenario: mode, display name and junk-suffix rule all restore onto an existing `.untaught` row |+| `designationRestoreIsIdempotent` | The same archive applied twice leaves the same row |+| `taughtDesignationRestoredOntoUntaughtRow` | A `.taught` restore sets the mode alongside the rule that justifies it, instead of leaving `.untaught` retaining a pattern |+| `archiveJunkSuffixRuleReplacesTheLocalOne` | A corrected junk-suffix rule replaces the one an `.articles` row holds |+| `contradictedDesignationIsDeclined` | An `.articles` archive over a `.taught` row holding an active rule is declined whole — no mode change, no junk rule, **and no display name** |+| `designationLegalityIsPerRow` | A hostname holding two taught Site rows: legality is the survivor row's own question, so a designation each row is individually entitled to is applied rather than declined |+| `archivedUntaughtRecordDoesNotUndesignate` | An archived `.untaught` record over a freshly marked `.articles` row with no rules leaves the row `.articles` with its junk rule and its own name |++Four of the first five failed before the fix. `designationRestoreIsIdempotent`+passed trivially before it (nothing was written at all) and non-trivially after.+The two later rows were added under review: `contradictedDesignationIsDeclined`+gained a differing archive display name (it previously used the same name on both+sides, so an ungated name write was indistinguishable from a declined one), and+`designationLegalityIsPerRow` is new. Both were re-checked against the pre-fix+spelling and fail under it.++`archivedUntaughtRecordDoesNotUndesignate` came out of the pre-push review and+pins a regression this fix introduced rather than one it inherited: it fails+against the first spelling of `applyDesignation` (which wrote+`mode = .untaught, junkSuffixRule = nil` onto the row) and passes against the+released one. It is the only test here that would have passed against the+*original*, pre-fix code.++**Test file:** `Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift`++| Test | What it verifies |+|------|------------------|+| `siteTupleTable` | `siteTupleIsLegal` over all three modes × (no rules / active title rule / current URL rule / both) — the extracted predicate pinned directly, not through either caller |+| `siteTupleRetainedHistoryAndGroupCounting` | The three clauses that table cannot reach: untaught retains imported-V2 history and only that, articles retains historical rules of any origin, and "one active title rule" counts identity groups rather than rows |++**Run command:** `make test-core CORE_TEST='BackupImportTransactionTests'`++## Affected Files++| File | Change |+|------|--------|+| `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift` | Designation pass in `upsert` step 1; `applyDesignation` |+| `Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift` | Site tuple table lifted into `siteTupleIsLegal`, shared with import |+| `Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift` | Seven regression tests, two Site-seeding helpers, a Site-only plan builder, `siteFacts` and `survivorDisplayName` probes |+| `Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift` | Two direct table tests pinning `siteTupleIsLegal` |+| `specs/cloudkit-mirroring/requirements.md` | Req 4.1 amended (sub-clause 4.1.1) |+| `specs/cloudkit-mirroring/decision_log.md` | Decision 10; Decision 8 status points at it |+| `specs/cloudkit-mirroring/design.md` | Matched-Site designation bullet under the import upsert |++## Verification++**Automated:**++- [x] Regression tests pass+- [x] `make test-core` passes (exit 0, the whole AsterismCore package — the+ repo's stated pre-commit bar), with no compiler warnings+- [x] `make verify-identity` passes (a `test-core` prerequisite)++There is no code-style linter configured in this repo. No device target was run,+and no simulator target was needed: the path is host-testable end to end through+`confirmImport`.++**Manual verification:** none required.++## Prevention++- **Fields constrained by a shared invariant should be written together.** The+ mode and the junk-suffix rule were two writes for one fact, which is what let+ half of it land and manufacture a quarantine.+- **An "update if absent" rule cannot express a restore.** Where a field's only+ writer is an archive, an absent-only guard silently makes restore a no-op. Ask+ what the reader is trying to do before choosing the guard.+- **A write path that chooses a state should ask the validator's table — by+ calling it.** Import now calls `LibraryValidator.siteTupleIsLegal`, so it+ cannot write a state that will then be quarantined. The first spelling of this+ fix re-stated the validator's switch in the import file; two hand-maintained+ copies of one closed table agree until they do not.+- **A per-row invariant has to be asked per row.** The same gate asked over a+ hostname answers about a tuple no row holds, and this file deliberately+ tolerates duplicate Site rows per hostname.+- **A decision clause that disposes of a whole entity in one sentence deserves a+ test row.** Decision 8's Site clause had none, which is why this stood from+ 2026-07-29 to now.+- **A legality gate is not a "should I write this" gate.** `(.untaught, [], [])`+ is a perfectly legal tuple; writing it was still wrong. Making a field writable+ where it previously was not creates a new way to lose data, and "the validator+ accepts the result" does not answer it.+- **Name the enforcer, not the nearest plausible one.** Three drafts of the prose+ attributed the "junk rule only in `.articles`" clause to `LibraryValidator`,+ which has never read that column. A cited invariant is worth a grep.++## Follow-ups++- **A third copy of the Site tuple table lives in+ `Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift`**+ (the per-mode switch around line 174), checking an *archive's* records rather+ than the store's. Pre-existing, out of scope here, and not obviously mergeable+ with `siteTupleIsLegal` — it answers over wire records, not model rows — but it+ is now the one hand-maintained copy of a table that has a shared definition+ elsewhere, and a refinement to the per-mode rule would have to reach it by+ hand.++## Related++- `specs/cloudkit-mirroring/decision_log.md` — Decision 8 (the guard this amends),+ Decision 10 (this one)+- `specs/cloudkit-mirroring/requirements.md` — Req 4.1, 4.1.1+- `specs/multi-site-works/` — Req 9.x, the membership half of the same upsert+- T-2288 (`specs/bugfixes/title-gate-blocks-identity-attachment/`) — a producer of+ the damaged-row state this restore now repairs
diff --git a/specs/cloudkit-mirroring/decision_log.md b/specs/cloudkit-mirroring/decision_log.mdindex 1bb394e..363e97e 100644--- a/specs/cloudkit-mirroring/decision_log.md+++ b/specs/cloudkit-mirroring/decision_log.md@@ -390,7 +390,7 @@ Rule UUIDs are unique per rule row, so `citedVersion = newVersion(citedID)` is a ## Decision 8: Import Updates Only Records the Archive Knows Better **Date**: 2026-07-29-**Status**: accepted — user-approved; amends Req 4.1+**Status**: accepted — user-approved; amends Req 4.1. **Its Site clause is amended by Decision 10**, which replaces "Site scalar fields follow the winner row's teaching state via the union merge" with a per-row legality gate; the two must be read together. ### Context @@ -478,3 +478,73 @@ Re-validating only repaired hostnames is the same per-Site arm the full pass run ### Impact `LibraryRepository.reconcileAfterSync` / `reconcileWorkLists` / `RuleTally`; `SiteReconciler.run`'s signature; `V4LibraryValidator.validate(hostnames:context:)`; `confirmImport`'s reconcile call; `design.md` §Site reconciliation and the refresh-trigger parity table; task 24's `reconcile-noop-coherent` band, re-measured at 1.71–1.88 ms.++---++## Decision 10: An Archive's Site Designation Is Restored Where the Matched Row's Rules Permit It++**Date**: 2026-08-29+**Status**: accepted — amends Decision 8's Site clause and Req 4.1 (T-2051)++### Context++Decision 8 guarded the import's update branch with `archive.modifiedAt >= local.modifiedAt` for Entry and Work, and disposed of Sites in a single clause: "Site scalar fields follow the winner row's teaching state via the union merge." The design review of the implementation found what that clause leaves out, and filed it as T-2051 rather than settling it in flight.++The clause is true for `.taught`, which `SiteUnionProjection.mode` derives from the kept active title rule. It is false for everything else. The union reads `.articles`, `.untaught`, the display name and the junk-suffix rule **off the survivor row**, so if the row does not carry them the union cannot invent them — and `upsert` never wrote `modeRaw` onto a matched row at all. It wrote `displayName` only onto an empty one, which `Site.init` makes unreachable (it defaults the name to the hostname, and nothing in the app has ever written that column), and `junkSuffixRule` only onto a nil one. So: a reader marks `news.example` as `.articles`, a bad merge leaves the row `.untaught` with no rules, they restore last week's archive, and the designation does not come back. The import reports success.++Two sharper defects sit in the same lines. A non-nil `junkSuffixRule` belongs only to `.articles` — `LibraryRepository.validatedRecentSiteMode` drops a Site from the recent list for holding one under any other mode, and no teaching path writes that shape. (`LibraryValidator` does **not** say so: its closed tuple table reads mode, title rules and URL rules, and never reads `junkSuffixRule` at all.) Yet the mode and the junk rule moved independently, so an `.articles` archive reaching a `.untaught` or `.taught` row wrote the junk rule alone and **manufactured** the shape the recent list quarantines. And a `.taught` restore inserted the archive's active title rule (rules are inserted by UUID) while dropping the mode, leaving `.untaught` retaining a pattern — the same illegal tuple from the other side. The reconciler does not clean either up: `SiteReconciler.run` writes the union's mode only when `projection.consolidates`, which for an ordinary single-row hostname with nothing to renumber is false.++`Site` carries no modification column at schema V9 and `BackupV7Site` carries no timestamp, so the guard Decision 8 uses everywhere else does not exist for this table.++### Decision++An archive's Site designation — display name, mode, and junk-suffix rule — is applied to a matched row wherever **that row's own** title and URL rules make the resulting tuple legal, and is declined **whole** — name included — where they do not. The mode and the junk-suffix rule move together as one designation; a row whose display name is empty or is its own hostname takes the archive's name. Req 4.1 gains sub-clause 4.1.1 saying so.++**An archive record whose mode is `.untaught` is never applied**, gate or no gate. `.untaught` is the absence of a designation, not one: no reader action writes it, so the record is not a claim the library lost but the archive having nothing to say. Applying it would un-designate whatever the reader marked after the export — and the tuple gate cannot catch that, because a freshly marked `.articles` row carries no title patterns and no URL rules, which makes `(.untaught, [], [])` trivially legal. The record is therefore declined before the gate is asked.++The legality question is asked **per Site row**, not per hostname, and is `LibraryValidator`'s own predicate rather than a copy of it: the validator exposes its closed tuple table as `siteTupleIsLegal(_:patterns:urlRules:)` and both callers ask it.++Forcing the junk-suffix rule to nil off `.articles` is **the import's own rule**, not a consequence of that shared table. The table reads mode, title rules and URL rules; `LibraryValidator` never reads `Site.junkSuffixRule`. The "a junk rule belongs only to `.articles`" clause is `LibraryRepository.validatedRecentSiteMode`'s, and the import restates it deliberately so that the two halves of one designation cannot be written apart.++### Rationale++Sites have no modification time, so the question is not "which side is newer" but "which side is entitled to speak". The library's own rules are: a taught hostname holds exactly one active title rule, an articles hostname holds none and no current URL rule, an untaught hostname retains only imported-V2 URL history. Those rules are reader-authored, timestamped by their own `createdAt`, and enforced by `LibraryValidator`. Asking the same closed table lets import restore anything the rules do not contradict, and makes it structurally unable to write a state the validator will then quarantine — which is a strictly better position than the one it was in, where it could manufacture that state and did.++`.untaught` is never a newer state a restore could regress: no reader action writes it. The only paths to `.untaught` are the initial value and `SiteReconciler` stripping a loser row. Teaching moves `.untaught → .taught` (`LibraryRepository+Contracts`, `+ComposedTeaching`) or `→ .articles` (`+Articles`). So a row sitting `.untaught` with no rules is either never-taught or damaged, and in both cases the archive is the better witness.++The same fact read from the archive's side is why an archived `.untaught` record says nothing. If no reader action writes `.untaught`, then an archive holding it records only that the hostname had not been designated when the export ran — which is not evidence about now. The local row is the better witness in that direction, and it is the only one that could have changed since.++The one regression the rule permits is small and bounded: two archives of one `.articles` hostname, where the older one's junk-suffix rule replaces a newer one. That is a single value the reader re-teaches in two taps from the site's own screen — unlike an Entry note, whose only route back is a restore. The ticket names the re-taught junk-suffix rule as something a restore *should* be able to put back, and under any never-overwrite rule it could not.++Mode and junk-suffix rule move together because they are one fact stated twice. Splitting them is exactly what produced the manufactured quarantine. The display name is inside the same gate for the same reason: it is one of the designation's three parts, so writing it while refusing the other two would be a partial application of a record the library declined.++Per **row** rather than per hostname, because that is the question `LibraryValidator.validate(site:)` asks — it filters `=== site` deliberately, "a second row's records belong to that row's tuple, not to this one" — and because import tolerates duplicate Site rows per hostname: `SiteReconciler` consolidates them in step 5, *after* the designation pass has already saved. A hostname holding two rows has no single tuple for a hostname-wide answer to be about, and under the tolerated `.taught` + `.taught` merge shape the hostname-wide reading declined designations each row was individually entitled to.++### Alternatives Considered++- **Add `Site.modifiedAt` (schema V10) plus a timestamp on the wire Site (format 8/9), and apply Decision 8's guard unchanged** — the ticket's first option, and the most faithful to Decision 8. Rejected as disproportionate: a schema migration, a container schema republication, and a new archive format for four fields on a table with tens of rows. It also would not have fixed the manufactured-quarantine defect, which is an atomicity problem rather than a recency one.+- **Leave Decision 8's clause standing and amend Req 4.1 to say Site scalars follow the winner row** — the ticket's second option. Rejected: it makes the loss the spec's stated behaviour, and it leaves import able to write the tuple the validator quarantines. Documenting a defect is not deciding it.+- **Apply the archive's designation unconditionally** — the straightforward reading of "restore". Rejected: an `.articles` archive over a `.taught` row holding an active title rule produces an illegal tuple, and an old archive would revert a newer teach on every device.+- **Apply the designation only where the hostname carries no teaching at all** (untaught, no rules, no junk rule). Rejected as narrower than the failure: it restores the ticket's headline scenario but still cannot put back a corrected junk-suffix rule on a row that already holds one, which the ticket also names — and it would have needed a second, different predicate from the one the validator already owns.+- **Have the reconciler repair it afterwards, by making it consider every archived hostname** — no change to import. Rejected: the reconciler derives mode from rules and the survivor row, so it has no access to what the archive said; it would repair nothing that import had not already written.++### Consequences++**Positive:**+- A lost `.articles` (or `.taught`) designation, display name, and junk-suffix rule all come back from a backup, which is what a restore is for.+- Import can no longer manufacture a `.siteTuple` quarantine, nor the junk-rule-outside-`.articles` shape the recent list drops a Site for. Two of the three defects fixed here were states import *created*, not states it failed to repair.+- A designation the reader made **after** the export survives the import: an archived `.untaught` record is declined outright, so it cannot un-designate a site the archive simply predates.+- The legality question *calls* `LibraryValidator.siteTupleIsLegal`, so there is literally one definition of a legal Site tuple. A refinement to the validator's per-mode rule reaches import without anyone remembering to copy it.+- Re-importing the same archive still dirties nothing: every write is comparison-guarded, as `SiteReconciler.applyUnion`'s are.++**Negative:**+- An older archive can replace a newer junk-suffix rule on an `.articles` hostname. Accepted, and re-teachable; there is no timestamp that could distinguish the two.+- A hostname the reader deliberately returned to `.untaught` cannot be restored to `.untaught` by an import. Nothing in the app offers that action today, so the case is currently unreachable; if un-designating ever ships it will need its own answer, because the archive still holds no way to tell "not yet designated" from "designated as nothing".+- The `.untaught` arm of `siteTupleIsLegal` is now unreachable from the import caller, so the shared table is shared for two of its three modes. Keeping the arm in the shared predicate is still right — the validator needs it — but the import's own coverage no longer exercises it, which is why it is pinned directly in `LibraryValidatorTests`.+- A display name the reader authored to something other than the hostname would not be overwritten by the archive's — currently unreachable, because no path but an import writes that column, but it is a rule that will need revisiting if renaming ever ships.+- The designation now has to be applied **after** the archive's rules are inserted, so `upsert` step 1 keeps a small amount of per-hostname bookkeeping (the rows it inserted) that it did not before.+- Decision 8's one-line Site clause is no longer sufficient on its own; the two must be read together.++### Impact++`LibraryRepository.upsert` step 1 and its new `applyDesignation`; `LibraryValidator.validate(site:)`, whose mode switch is lifted into the shared `siteTupleIsLegal` (behaviour unchanged; the diagnosis texts become a message table beside it); Req 4.1 (new sub-clause 4.1.1); `BackupImportTransactionTests` (the import matrix, which had no Site-designation row at all) and `LibraryValidatorTests` (the tuple table, pinned directly now that it has two callers). No schema change, no archive format change, no migration.
diff --git a/specs/cloudkit-mirroring/design.md b/specs/cloudkit-mirroring/design.mdindex fe1aa14..5bc66d9 100644--- a/specs/cloudkit-mirroring/design.md+++ b/specs/cloudkit-mirroring/design.md@@ -97,6 +97,7 @@ One `confirmImport(plan:)` on `LibraryRepository` replaces the two static commit - **Runs on the repository's live container** under the bulk-operation flag — no second container over the store, no history-replay detour for the mirror. - **Upsert, never delete** (Req 4.1, 4.2): Sites match by hostname — under coexisting duplicates, the deterministic winner row — everything else by application UUID. Missing records insert; matched Entries and Works update **only where `archive.modifiedAt >= local.modifiedAt`** (Decision 8) — a restore adds what is missing and repairs what is older, and cannot regress newer edits fleet-wide. Relationships wire from the hostname/UUID maps, so imported records arrive linked and the `"5"` marker needs no reset.+- **A matched Site's designation** — display name, mode, junk-suffix rule — has no `modifiedAt` to judge it by, so it takes a different gate (**Decision 10**, Req 4.1.1): it is applied whole where the *matched row's own* title and URL rules make the tuple legal (`LibraryValidator.siteTupleIsLegal`, the validator's own predicate), declined whole where they do not, and never applied at all when the archive's mode is `.untaught`, which is the absence of a designation rather than one. It therefore runs **after** step 1's rules are inserted — a `.taught` restore is legal precisely because the active title rule lands in the same step. - **Rule merge preserves the invariants**: archive rules joining existing rules take the same union path as reconciliation — version renumbering, citation rewrite, single active/current by the deterministic order. - **Commit order**: (1) Sites, TitlePatterns, URLRules in one save; (2) Works in chunks of 500; (3) Entries in chunks of 500, wired before each save. Every boundary is a legal library — a committed Entry's Site and Work precede it (Req 4.3, 4.4). The constant is shared with the reconciler's re-pin chunking, and the host measurement task Q32 asked for **settled it at 500** (Q53, `implementation.md`): the re-pin does not depend on the size at all and import pays ~20 ms per boundary, so the size is chosen for boundary granularity rather than throughput. - **Interruption**: an `AsterismImport.inProgress` sidecar (archive display name, start date) is written before the first save, removed after the last, and reported — at open or in Settings, however old — as "an import of *{name}* did not complete" (Req 4.4). It is a report, not a resume token: the repair is the reader re-running the import, which the upsert makes idempotent and convergent.
diff --git a/specs/cloudkit-mirroring/requirements.md b/specs/cloudkit-mirroring/requirements.mdindex 698bd06..f87c159 100644--- a/specs/cloudkit-mirroring/requirements.md+++ b/specs/cloudkit-mirroring/requirements.md@@ -81,6 +81,7 @@ Much of [2.1](#2.1) landed with relational-references: at HEAD an absent Site an **Acceptance Criteria:** 1. <a name="4.1"></a>Import SHALL add every record the archive describes that the library lacks, and update every record it describes that the library already holds, matched by application UUID — except a record whose local modification is newer than the archive's, which SHALL be left as it is, because regressing a newer edit would propagate to every device (Decision 8).+ 1. <a name="4.1.1"></a>A **Site** is matched by hostname and carries no modification time, so it is not judged by that guard. Import SHALL apply the archive's designation — display name, mode, and junk-suffix rule — to the matched row wherever **that row's own** title and URL rules make the resulting tuple legal, and SHALL decline it whole — name included — where they do not; the mode and the junk-suffix rule SHALL move together, and a row whose display name is empty or is its own hostname SHALL take the archive's name (Decision 10, amending Decision 8's Site clause). An archive record whose mode is `.untaught` SHALL NOT be applied at all — `.untaught` is the absence of a designation rather than one, and applying it would un-designate a site the reader marked after the export. 2. <a name="4.2"></a>Import SHALL NOT delete a record because the archive does not describe it. 3. <a name="4.3"></a>Importing the 5,000-Entry fixture SHALL commit in more than one save. 4. <a name="4.4"></a>Every commit boundary SHALL leave a library the app can open, and IF an import stops partway THEN the app SHALL report that it did not complete and which archive it was applying.
Verified in the diff: guard record.mode != .untaught else { return } at the top of applyDesignation; archivedUntaughtRecordDoesNotUndesignate test; siteTupleTable direct table; Decision 10, Req 4.1.1, design.md and the report all attribute the junk-rule clause to validatedRecentSiteMode.
Old and new .untaught / .articles arms are logically identical; currentRules remains in use for the row-count and greatest-version checks; nothing left in validate(site:) references the moved activePatternCount.
sitesByHostname[record.hostname]! is safe: matchedRecords is populated only when the lookup was non-nil and the dictionary only grows afterwards.