Citations name a rule by UUID alone; the version invariants and their repair machinery are deleted; archive 8/9. Spec: specs/rule-citation-by-uuid/.
CitedRule {id}, old blobs decode tolerantly, archive bumps to 8/9.RuleSelection.current (newest createdAt, then lowest UUID) behind Site.activePattern / currentURLRule, pinned by a whole-file source scan.PendingPatternProvenance.patternVersion is written but unread (Q36).Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift:212 defines the regex /first\s*(?:\(\s*where:|\{)[\s\S]{0,120}?is(?:Active|Current)/ and :272 runs the anti-vacuity fixtures before reporting offenders as path:line.Q29: DuplicateReconciler's others.first(where: \.isCurrent) was the one tolerated selecting read; task 6.2 deleted it with the version machinery and the withKnownIssue block went with it, leaving the scan with no carve-out at all.BackupGoldenExportTests: with ASTERISM_RECORD_GOLDEN=1 it writes backup-8-9-golden.json and calls Issue.record("recorded …; re-run without ASTERISM_RECORD_GOLDEN=1 to compare against it.")Q17 and EntryCitationsTests.oldShapeBlob: a hand-pasted JSON literal with every citation slot populated and its version keys intact, asserted to decode equal to oldShapeEquivalent. V8RecordedStoreFixture was explicitly *not* used, because it encodes through the live CitedRule.Q31 then Q37: version > 0 checks were kept on the reasoning that nothing produces a non-positive row, then had to go from the validator, the archive check, validatedRecentSiteMode and finally the teaching and re-parse refusals once the renumbering that repaired such a row was deleted. The model-init guards stayed, so the state still cannot be minted locally.Q8 plus prerequisites.md: no rollout gate or transitional encoding for a lagging device, with the ordering — install the new build, then immediately export an 8/9 archive, because the pre-upgrade 7/8 archive is refused by the new build — left as the owner's checklist.Ready to push
All 33 numbered requirements are implemented with behavioural tests; every divergence from the design is recorded (Q25–Q37). The branch is rebased onto origin/main, four review agents' findings are fixed (10 fixed, 2 skipped by decision), and make test-core / make test-quick pass with no new warnings. Two host-only performance runs sit inside every ceiling.
105c344 T-2281: pre-push review — annotate design as built (Q31/Q37), add implementation.md a63f228 T-2281: pre-push review fixes c2eba2f T-2281: mark rule-citation-by-uuid Done in specs overview 443c1f4 T-2281: changelog for phase 5 1be26a1 T-2281: record Q35–Q36 and correct task 8.1's inverted wording 0e6109c T-2281: drop the falsified rationale from the citation comments 49dca0a T-2281: full verification — gates, grep gate and two performance runs 21ee64d T-2281: rewrite the stale invariant comments and the agent notes 3a83f26 T-2281: suggestion fingerprint keys on rule UUIDs 8bea2a9 T-2281: changelog for phase 4 3f7ac3f T-2281: record Q34 (strict validation of the restored golden stays) adb7f7b T-2281: review fixes for the 8/9 archive generation 71d4f48 T-2281: archive generation 8/9 — BackupV8*, citations by UUID on the wire 5a5c7bb T-2281: changelog for phase 3 07bacee T-2281: record phase 3 decisions (Q31–Q33) and narrow task 7.2 1b8af78 T-2281: phase 3 review fixes — drop the read-side positivity clauses e79ebf9 T-2281: duplicate consolidation converges definitions, not versions 712ee80 T-2281: site union re-parents and demotes, and renumbers nothing 56a4a52 T-2281: resolve citations by UUID and retire the version invariants 3fa734f T-2281: changelog for phase 2 331ed61 T-2281: record phase 2 review decisions (Q29–Q30) c99f6a2 T-2281: review fixes for the rule-selection tests and history split c6b3ae0 T-2281: current rule by tiebreak, orderings by (createdAt, id) a2f72f8 T-2281: mark rule-citation-by-uuid In Progress in specs overview 67a04af T-2281: changelog for phase 1 e3d571b T-2281: record phase 1 deviations (Q25–Q28) and correct tasks 4/10 3411a08 T-2281: answer the phase 1 review findings 238cefe T-2281: drop the version from every rule citation 20e2c95 T-2281: spec rule-citation-by-uuid — requirements, design, tasks When Asterism learns how to read a website — where the chapter number sits in a title, which part of a URL identifies a book — it stores that knowledge as a rule. Every entry it captures records which rule it used. That record is a citation, exactly like a footnote.
Until this branch a citation was written as "rule ABC, revision 4". Now it is written as "rule ABC". The revision number is gone from the footnote.
That sounds small, and the code deleted is not. Pinning a revision number meant the app had to guarantee those numbers stayed unique and in order within one site — and a whole apparatus existed to keep that promise: renumbering rules when two site records merged, walking every entry afterwards to rewrite its footnotes, and refusing to open a library where two numbers had collided. The branch deletes that apparatus and the promise it kept.
Teaching the same site twice — on two devices, or by importing a backup into a library that already knows the site — used to produce two rules claiming the same number. That is not damage; it is what happens when the same thing is learnt twice. But the old rule called it damage, quarantined the site and sent it for repair.
A UUID is already unique forever. Nothing can collide, so nothing needs repairing, so the repair machinery has no reason to exist. Provenance now survives a site merge and a backup round trip without anything being rewritten.
EntryCitations.swift, ValueObjects.swift, URLIdentityTypes.swift: CitedRule, FieldProvenance and URLRuleReference become UUID-only; rewritingVersions deleted; URLRuleReference.init stops throwing.RuleSelection.swift (MarkableRule, current, precedes, citedRow) plus Site.activePattern and a new Site.currentURLRule.LibraryValidator, BackupArchiveReferenceChecks, +RecentPresentation, TitleProjectionPlanner and +WorkMerge match on id alone, scoped to the citing record's Site or hostname.validatedRecentSiteMode, the teaching basis and RuleTally.SiteUnionProjection, SiteReconciler and DuplicateReconciler lose renumbering, rewrite maps and both citation walks; flag drift is the only in-place repair left.BackupV7* renamed BackupV8*, format 8 / schema 9, rule records carry the row's stored version.CorpusFingerprint keys on titleRuleID/urlRuleID.Codable value in an opaque column and in the archive, so dropping a field moves both. The SwiftData entity graph is untouched, and the synthesized decoder simply ignores the old version key.MarkableRule plus two accessors. A protocol over the row shapes lets one generic RuleSelection.current serve both kinds; only the accessors read a marked flag to pick a rule, while counting and demoting reads stay as they were.ASTERISM_RECORD_GOLDEN=1 writes the fixture and then fails, so recording is never mistaken for passing.version as an advisory integer; removing the column is a later schema bump.createdAt defaults to epoch on a row that never set it, so such a row always loses the tiebreak.Tiebreak semantics. RuleSelection.current is rows.lazy.filter(\.isMarked).min { … } with the comparator reading precedes from the recent end: newer createdAt wins, and among equal dates the lower uuidString wins — the same total order a listing uses, read backwards, so a list and the row it highlights cannot disagree. On a full tie (same date and same uuidString, which only an unconverged same-UUID group produces) the returned id is stable but which object comes back follows array order; the doc comment states this rather than hiding it.
Same-UUID groups. RuleSelection.citedRow collects every row carrying the cited id and hands a group of more than one to GroupOrdering.representativePattern / representativeURLRule. The single-match path allocates nothing. Delegating rather than deciding is deliberate: same-UUID groups belong to T-2282, and that ordering still carries version as its third key — harmless while converged groups are required.
Why no schema stage. EntryCitations is a blob in an opaque column and the archive's Entry-citation record, so both sides move with the type. The only store-side footprint is a baseline text file that had one key deleted by hand rather than regenerated.
Checksum-based refusal. BackupV8Codec.decode re-encodes the decoded payload canonically and compares the SHA-256 against the checksum over the bytes as they arrived, so a field the codec does not re-encode fails as checksumMismatch. An 8/9 file carrying a citation version cannot import with the key silently dropped — no new check was needed.
What the reconcilers still do. repin points records at the survivor and nothing else; applyUnion writes owner and mark; renumberedRules is renamed reparentedRules. convergeRules copies the representative's definition down and demotes within Sites; alignVersions, the two *VersionIsFree predicates and the Entry-walk rewrite are gone. repairsInPlace is flag drift alone, so hasWork still fires on a single double-marked Site.
The positivity story. Q24 kept version > 0 because nothing produced the state; task 5 then deleted the pass that repaired it, so a version: 0 row over CloudKit would have quarantined its hostname forever. Q31 dropped the read-side checks and Q37 the teach-side refusals; the model-init guards stand, so an archive still cannot materialise such a row.
One predicate defines "current" for readers and repair alike, which is what makes the demotion invisible. Resolution collapses from five hand-written id && version matches to one helper plus two dictionary lookups, and the validator's per-Site version sets and the five-field RuleTally are gone — reported as a 2–4% fall across nine store-walk labels, without claiming credit.
PendingPatternProvenance.patternVersion is now written at four sites and read by no production code. Inside the Non-Goals, flagged for T-2282; the Non-Goal's stated reason is itself inexact.createdAt, the duplicate reconciler the first in representative order. They can disagree on a same-UUID double-marked group but cannot oscillate; recorded rather than resolved.Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift
Why it matters. The (id, version) pair is what forced every Site to hold unique, ordered rule versions, and that invariant is what two teaches of one site collide on. The version carried no information the UUID did not.
What to look at. Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift:1-30 (CitedRule) and the deleted EntryCitations.rewritingVersions walk
Packages/AsterismCore/Sources/AsterismCore/RuleSelection.swift
Why it matters. Once the version invariant went, two marked rows became a valid-looking state that two concurrent teaches or an import into a non-empty library can produce, and the read-time pick had to be deterministic because the suggestion fingerprint keys on it.
What to look at. RuleSelection.current at RuleSelection.swift:65-72, with SiteUnionProjection.keptRule at SiteUnionProjection.swift:312 delegating to it
Packages/AsterismCore/Sources/AsterismCore/RuleSelection.swift
Why it matters. A cited UUID can name more than one row in a Site, and a to-many relationship hands rows over in no particular order, so first(where:) would let two reads of one unchanged Site resolve different rows.
What to look at. RuleSelection.citedRow at RuleSelection.swift:87-101, called from LibraryValidator.citedRule and citedPattern
Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift
Why it matters. Site-unique versions existed to make (id, version) a key. With a bare UUID citation, two rows sharing a version carry no ambiguity — and requiring uniqueness is exactly what two teaches of one site collided on.
What to look at. LibraryValidator.swift:656-695 (the fused ownership guards) and the deleted current-holds-greatest clause around :724-732, claimVersion and groupVersion
Packages/AsterismCore/Sources/AsterismCore/BackupV8Codec.swift
Why it matters. EntryCitations is the archive's Entry-citation record, so the citation shape change is a wire-format change. Req 5.4 needed no new check: the existing re-encode checksum already catches a field the codec does not re-encode.
What to look at. BackupV8Codec.decode at BackupV8Codec.swift:97-104 (re-encode, SHA-256, compare)
Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swift
Why it matters. assignVersions, versionRewrites, owningRank and the version arm of repairsInPlace all existed to keep (id, version) pairs resolvable across a merge. A merge changes no UUID, so Req 1.4 holds without any walk.
Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift
Why it matters. The fingerprint was the last consumer reading version as meaningful, and a site merge's renumbering could spuriously invalidate a hostname.
What to look at. CorpusFingerprint.titleRuleID / urlRuleID at RuleSuggestionTypes.swift:156-176, fed from Site.activePattern?.id and Site.currentURLRule?.id
Shared by every reader of the current rule and by the site union's demotion repair, so the demotion is invisible to whoever was already reading. Alternatives rejected: keeping keptRule's owning-Site rank (arbitrary with respect to which teach the reader meant) and greatest version (keeps the integer meaningful, which is the thing being removed). Cost: createdAt defaults to epoch on a row that never set it, so such a row loses to any dated row.
Still minted as max + 1 with its overflow refusals, but no validator rule depends on it. Dropping the column is a schema stage that post-v8-convergence Q3 keeps out of this spec; keeping it enforced would keep the renumbering machinery alive, which is the thing being deleted.
Task 1.2 asked LibraryValidator and BackupArchiveReferenceChecks to keep temporary id && version resolution until task 4. Once CitedRule and URLRuleReference are {id} there is no version left to compare, so the instruction was unsatisfiable. Four version-only tests scheduled for later deletion went in phase 1 for the same reason.
It also repaired an undecodable citationsData blob as a side effect, but only ever from inside one of the two bulk walks Q3 deletes. An undecodable blob now stays as it is until the Entry is next edited through the value-guarded editCitations; the tolerant decoder keeps the reader working. DuplicateReconciler short-circuited its Entry walk in the meantime rather than scanning the table to do nothing.
The version-free encoder cannot reproduce the old bytes, so the byte test could not stay green through phase 1. The intermediate 7/8-archive-with-version-free-citations is exactly the state the design rules out; tolerable only because the branch is squashed and no mid-branch build is installed (Q8). Task 7.2 then deleted that golden and recorded backup-8-9-golden.json.
Per-line matching missed the repo's own multi-line call style (a wrapped first(\n where:). The one tolerated offender was keyed on the line's text inside a withKnownIssue, not on the file name — a file-level carve-out would let a new selecting read into that file pass.
Written as $0 !== pickedPattern rather than !$0.isActive, so on a double-marked Site the tiebreak loser lands in the history list instead of vanishing. Identity rather than id, because rows of one same-UUID group share a UUID and an id comparison would hide the whole group's history. Behaviour-preserving today: validatedRecentSiteMode refuses a double-marked Site wholesale (Q12).
Q24 had kept them on the grounds that nothing produces a non-positive row. Task 5 then deleted the tally/union arm that repaired one, so a version: 0 row arriving over CloudKit would have quarantined its hostname with no pass able to clear it. Dropped from LibraryValidator, BackupArchiveReferenceChecks and validatedRecentSiteMode; the model-init guards and the minting refusals stay.
SiteUnionProjection.keptRule keeps the newest createdAt (RuleSelection.current); DuplicateReconciler.demoteWithinSites keeps the first in GroupOrdering representative order. For a same-UUID double-marked group they disagree. They cannot oscillate — RuleSelection.current only ever demotes among marked rows — and a same-UUID group is the duplicate reconciler's domain. Recorded so T-2282 does not rediscover it.
Deleting ProjectedSite.versionRewrites and the projected version fields forced BackupArchiveProjection's map, its Entry-mapper parameter and the record writes out at the same time. Task 7.2 was left as the BackupV7* → BackupV8* rename, the 8/9 bump, the golden record mode and the re-record.
Q23's "already covered" was wrong: byte-identity of a re-export proves record fidelity, not validity, and the import gate is the tolerant validator. With the deleted 7/8 case gone, exportImportExportIsByteIdentical's strictDiagnoses().isEmpty assertion is the only strict validation of a library built from an archive — which is the claim the whole feature rests on.
Task 8.1 said a nil titleRuleID/urlRuleID should block auto-eligibility. Req 6.3 says a missing id means "not fully taught, as today", and the pre-branch predicate was titleRuleVersion == nil || urlRuleVersion == nil ⇒ eligible. Following the task text would have made the sweep fire only on fully-taught sites, i.e. never.
Extends Q31 to the write path (LibraryRepository+Contracts and +ReparseCapture). Tolerating the state on read while hard-refusing it on teach would leave a hostname that nothing can heal and nothing can re-teach. Minting max + 1 over a 0 still yields ≥ 1, and Q24's model-init guards still stand.
One device is in use. A lagging device would fail to decode version-free blobs, quarantine the hostname and overwrite them with defaults via editCitations; the blob is CloudKit-mirrored, so reinstalling the old build does not undo it. The restorable archive is one exported after upgrading. Recorded so a second device is only added once it runs this build.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | branch base | Branch was one commit behind origin/main (df1b3e5, T-2295); the two-dot diff showed an apparent revert and a real conflict in SiteReconcilerTests.swift. | Rebased onto origin/main; conflict resolved keeping Req 3.4's version assertions plus T-2295's nothing-diagnosed assertion. |
| major | reuse: rule ordering | The (createdAt, id) order was hand-written four times in three spellings, with its reverse a fifth time inside RuleSelection.current. | One RuleSelection.precedes comparator; PatternBasisEntry conforms to MarkableRule; all four sorts and current use it. |
| minor | consistency: positivity | Teaching and re-parse still hard-refused a non-positive row version after Q31 made the validator tolerate it — a hostname nothing could heal or re-teach. | Both refusals dropped (Q37); the planner's minted-version guard is unaffected since max+1 >= 1. |
| minor | efficiency: +EntryDetail | activePattern (a full RuleSelection.current scan) computed five times per detail load, definition decoded up to four times. | Picked once with its decoded definition and threaded through presentationTitle. |
| minor | test gap: Req 3.7 | Only the reconcile half of the double-marked repair was tested; the import path had no case. | importedMarkedRuleDemotesTheOlderOne in BackupImportTransactionTests: recency wins over id and arrival order, versions untouched. |
| minor | redundant state | URLRuleBasisEntry.reference stored what is now URLRuleReference(id: id); mapV8EntryRecord kept a nil-cache default no caller used. | Computed property; default and nil path removed. |
| minor | leaky abstraction: +WorkMerge | Last raw-flag selector (rules.filter(\.isCurrent).first) outside the accessors. | Count for the refusal, RuleSelection.current for the pick. |
| nit | stringly-typed / dead code | "V8" hardcoded four times; FieldProvenance.tolerant had a try? that cannot throw; three one-line forwarders; a stranded doc line break. | BackupV8Codec.label; try? collapsed; forwarders inlined; comment rewrapped. |
| nit | docs | design.md contradicted the code in four places (Q29/Q30/Q31/Q34); docs/asterism-design.md said consolidation re-points citations; verification-run.md header wording; three stale (id, version) test comments; one tautological expect. | Annotated as built; corrected; reworded. |
| nit | reuse: URLRuleReference vs CitedRule | Now structurally identical single-UUID wrappers; Q16 keeps them distinct for the name threaded through ProjectionContract. | Left as decided in Q16. |
| nit | efficiency: uuidString in comparators | uuidString allocates on a createdAt tie; the tuple form in URLIdentityPlanner built both strings eagerly (pre-existing). | Superseded by the shared comparator, which only reaches uuidString on a tie. |
| nit | comparator style | RuleSelection.precedes is the one rule ordering not expressed through GroupOrdering's OrderComponent primitive. | Left: a two-key comparator is readable as written; GroupOrdering's machinery exists for five-component keys. |
Click to expand.
diff --git a/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift b/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swiftindex 18c3d06..905160d 100644--- a/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift+++ b/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift@@ -418,7 +418,7 @@ final class RuleSuggestionCoordinator { /// and a rule missing on one side. private static func isAutoEligible(_ candidate: RuleSuggestionCandidate) -> Bool { candidate.siteMode != .articles && candidate.entryCount > 0- && (candidate.titleRuleVersion == nil || candidate.urlRuleVersion == nil)+ && (candidate.titleRuleID == nil || candidate.urlRuleID == nil) } /// Most recent capture first; a hostname with no captures is never eligible,@@ -454,7 +454,7 @@ final class RuleSuggestionCoordinator { CorpusFingerprint( siteMode: candidate.siteMode, entryCount: candidate.entryCount, latestCaptureAt: candidate.latestCaptureAt,- titleRuleVersion: candidate.titleRuleVersion,- urlRuleVersion: candidate.urlRuleVersion)+ titleRuleID: candidate.titleRuleID,+ urlRuleID: candidate.urlRuleID) } }
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 888786c..1e5a500 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -133,8 +133,8 @@ public final class AppLibraryModel { /// The resolved configuration after successful bootstrap. private var resolvedConfiguration: LibraryConfiguration? private var repository: (any LibraryProviding)?- /// Retains the concrete repository for backup export (conforms to BackupV7SnapshotProviding).- private var backupRepository: (any BackupV7SnapshotProviding)?+ /// Retains the concrete repository for backup export (conforms to BackupV8SnapshotProviding).+ private var backupRepository: (any BackupV8SnapshotProviding)? /// A pre-bootstrap failure used to fail closed on invalid debug launch input. private let startupFailureMessage: String? /// Seeds only a fresh, explicit temporary configuration used by UI tests.@@ -1429,7 +1429,7 @@ public final class AppLibraryModel { guard let repo = backupRepository, let config = resolvedConfiguration else { return nil } let stagingDir = config.rootDirectory .appending(path: "Library/Caches/BackupExports")- let exporter = BackupV7Exporter(+ let exporter = BackupV8Exporter( repository: repo, stagingDirectory: stagingDir )
diff --git a/Asterism/Asterism/ViewModels/ConflictRecentPresentation.swift b/Asterism/Asterism/ViewModels/ConflictRecentPresentation.swiftindex 10185de..112f9c0 100644--- a/Asterism/Asterism/ViewModels/ConflictRecentPresentation.swift+++ b/Asterism/Asterism/ViewModels/ConflictRecentPresentation.swift@@ -78,8 +78,8 @@ public struct ChapterPresentation: Equatable, Sendable { /// Presentation helpers for URL rule disclosure in Entry detail. /// Shows current/historical badge and identity basis without UUID diagnostics. public enum EntryDetailRulePresentation {- public static func badge(isCurrent: Bool, version: Int) -> String {- isCurrent ? "Current rule" : "Historical rule v\(version)"+ public static func badge(isCurrent: Bool) -> String {+ isCurrent ? "Current rule" : "Historical rule" } public static func identityBasisLabel(for basis: EntryIdentityBasis) -> String {
diff --git a/Asterism/Asterism/ViewModels/ReparseViewModel.swift b/Asterism/Asterism/ViewModels/ReparseViewModel.swiftindex 914d13d..5e4a866 100644--- a/Asterism/Asterism/ViewModels/ReparseViewModel.swift+++ b/Asterism/Asterism/ViewModels/ReparseViewModel.swift@@ -36,6 +36,27 @@ public final class ReparseViewModel { public var worksToReuse: Set<UUID> { contract?.outcome.plan.worksToReuse ?? [] } public var hasAmbiguity: Bool { contract?.outcome.plan.hasAmbiguity ?? false } + /// The label the re-parse sheet shows for a projected field's provenance.+ ///+ /// Lives here rather than in the view body so the wording is testable. Since+ /// T-2281 a citation is the rule's UUID, so the pattern labels carry no+ /// version (Q13) — that is the part worth pinning.+ ///+ /// `nonisolated` and pure: it reads only its `Sendable` argument.+ nonisolated public static func provenanceLabel(_ provenance: ProjectedProvenance) -> String {+ switch provenance {+ case .preserved(let fieldProvenance):+ switch fieldProvenance.kind {+ case .none: "None (preserved)"+ case .pattern: "Pattern (preserved)"+ case .urlRule: "URL rule (preserved)"+ case .manual: "Manual (preserved)"+ }+ case .cleared: "Cleared"+ case .pendingPattern: "Pending pattern"+ }+ }+ private let entryID: UUID private let library: any LibraryProviding private let onMutation: @Sendable () async -> Void
diff --git a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift b/Asterism/Asterism/ViewModels/SettingsBackupModel.swiftindex 7ba3c61..de27999 100644--- a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift+++ b/Asterism/Asterism/ViewModels/SettingsBackupModel.swift@@ -5,19 +5,19 @@ import OSLog // MARK: - Backup Exporting Protocol /// Test seam abstracting the exporter's operations needed by the Settings-/// surface. Conforms `BackupV7Exporter` to this protocol via extension below.+/// surface. Conforms `BackupV8Exporter` to this protocol via extension below. ///-/// Settings exports 7/8 (`multi-site-works` Req 9.1): the archive has to carry-/// a Work's site memberships and the reader's dismissed pairs, which no earlier-/// format has a place for. It is also the only format the app reads-/// (Decision 2), so there is one exporter and one importer.+/// Settings exports 8/9 (`rule-citation-by-uuid` Req 5.1): the archive carries a+/// Work's site memberships, the reader's dismissed pairs, and citations that+/// name a rule by UUID alone. It is also the only format the app reads, so there+/// is one exporter and one importer. public protocol BackupExporting: Sendable {- func export(metadata: BackupV7Metadata) async throws -> BackupExportResult+ func export(metadata: BackupV8Metadata) async throws -> BackupExportResult func cleanup(_ result: BackupExportResult) func scavengeStaleFiles() } -extension BackupV7Exporter: BackupExporting {}+extension BackupV8Exporter: BackupExporting {} // MARK: - Settings Backup View Model @@ -78,7 +78,7 @@ public final class SettingsBackupModel { currentResult = nil do {- let metadata = BackupV7Metadata(+ let metadata = BackupV8Metadata( appBuild: Self.currentAppBuild(), exportedAt: Date() )@@ -91,7 +91,7 @@ public final class SettingsBackupModel { state = .failed // Privacy-safe: log only the error category, never user content errorMessage = Self.privacySafeMessage(for: error)- if let exportError = error as? BackupV7ExportError,+ if let exportError = error as? BackupV8ExportError, case .tornGroups = exportError { routesToCheckLibrary = true }@@ -139,7 +139,7 @@ public final class SettingsBackupModel { switch error { case is BackupCodecError: "Backup export failed due to an encoding error. Please try again."- case let error as BackupV7ExportError:+ case let error as BackupV8ExportError: exportMessage(for: error) default: "Backup export failed. Please try again."@@ -163,7 +163,7 @@ public final class SettingsBackupModel { /// (`character-extraction` Req 6.5, Q105). It needs no new sentence: the /// payload carries a count and a route, not a record kind, and Check Library /// is where every torn group is resolved.- private static func exportMessage(for error: BackupV7ExportError) -> String {+ private static func exportMessage(for error: BackupV8ExportError) -> String { switch error { case .tornGroups(let payload): tornGroupsMessage(payload)@@ -203,7 +203,7 @@ public final class SettingsBackupModel { switch error { case let e as BackupCodecError: "codec: \(e)"- case let e as BackupV7ExportError:+ case let e as BackupV8ExportError: "export: \(e)" case let e as LibraryRepositoryError: "repository: \(e)"
diff --git a/Asterism/Asterism/Views/ReparseView.swift b/Asterism/Asterism/Views/ReparseView.swiftindex b4b7f7f..a39141a 100644--- a/Asterism/Asterism/Views/ReparseView.swift+++ b/Asterism/Asterism/Views/ReparseView.swift@@ -113,13 +113,13 @@ struct ReparseView: View { // Provenance Section(header: ConstellationSectionHeader("Projected Provenance", accent: .violet)) { LabeledContent("Chapter Provenance") {- Text(provenanceLabel(projection.chapterProvenance))+ Text(ReparseViewModel.provenanceLabel(projection.chapterProvenance)) .font(.caption) } .accessibilityIdentifier("reparse-chapter-provenance") LabeledContent("Assignment Provenance") {- Text(provenanceLabel(projection.assignmentProvenance))+ Text(ReparseViewModel.provenanceLabel(projection.assignmentProvenance)) .font(.caption) } .accessibilityIdentifier("reparse-assignment-provenance")@@ -248,18 +248,4 @@ struct ReparseView: View { case .ambiguous: "Ambiguous (unassigned)" } }-- private func provenanceLabel(_ provenance: ProjectedProvenance) -> String {- switch provenance {- case .preserved(let fp):- switch fp.kind {- case .none: "None (preserved)"- case .pattern: "Pattern v\(fp.patternVersion ?? 0) (preserved)"- case .urlRule: "URL rule (preserved)"- case .manual: "Manual (preserved)"- }- case .cleared: "Cleared"- case .pendingPattern(let p): "Pending pattern v\(p.patternVersion)"- }- } }
diff --git a/Asterism/AsterismTests/AppLibraryModelTests.swift b/Asterism/AsterismTests/AppLibraryModelTests.swiftindex 29a22a3..ba37bc4 100644--- a/Asterism/AsterismTests/AppLibraryModelTests.swift+++ b/Asterism/AsterismTests/AppLibraryModelTests.swift@@ -752,7 +752,7 @@ struct AppLibraryModelSyncArrivalTests { _ = try await mock.confirmImport( plan: BackupImportPlan( metadata: BackupImportMetadata(- formatVersion: 7, schemaVersion: 8, appBuild: "test",+ formatVersion: 8, schemaVersion: 9, appBuild: "test", exportedAt: .now, capabilityGate: "multi-site", entryCount: 0, workCount: 0), payload: BackupImportPayload( entries: [], works: [], sites: [], titlePatterns: [], urlRules: []),
diff --git a/Asterism/AsterismTests/ComposedTeachingPreviewTests.swift b/Asterism/AsterismTests/ComposedTeachingPreviewTests.swiftindex 285b88e..6f5df3b 100644--- a/Asterism/AsterismTests/ComposedTeachingPreviewTests.swift+++ b/Asterism/AsterismTests/ComposedTeachingPreviewTests.swift@@ -65,7 +65,7 @@ struct ComposedTeachingPreviewTests { outcome: outcome)) mock.commitComposedTeachingResult = .success( .committed(- titleRuleID: UUID(), titleRuleVersion: 1, urlRuleID: nil, urlRuleVersion: nil))+ titleRuleID: UUID(), urlRuleID: nil)) return ComposedTeachingViewModel( entry: TestFixtures.makeEntry(captureTitle: "Episode 1", hostname: hostname), library: mock, capabilities: .multiSite, entryContext: .urlFocused, onMutation: {})
diff --git a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swiftindex cbfe4d8..e95db5b 100644--- a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift+++ b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift@@ -68,7 +68,7 @@ struct ComposedTeachingViewModelTests { private func makeSUT( contract: ComposedTeachingContract? = nil, commitOutcome: ComposedTeachingCommitOutcome = .committed(- titleRuleID: UUID(), titleRuleVersion: 1, urlRuleID: nil, urlRuleVersion: nil),+ titleRuleID: UUID(), urlRuleID: nil), entryContext: ComposedTeachingViewModel.EntryContext = .titleFocused, entry: EntrySnapshot? = nil, suggestions: RuleSuggestionCoordinator? = nil@@ -1065,8 +1065,8 @@ struct ComposedTeachingViewModelTests { let mock = MockLibraryProvider() mock.ruleSuggestionCandidatesResult = .success([ RuleSuggestionCandidate(- hostname: Self.hostname, siteMode: .untaught, titleRuleVersion: nil,- urlRuleVersion: nil, entryCount: 3,+ hostname: Self.hostname, siteMode: .untaught, titleRuleID: nil,+ urlRuleID: nil, entryCount: 3, latestCaptureAt: Date(timeIntervalSince1970: 1)), ]) let suggester = RuleSuggestionCoordinatorTests.StubSuggester()
diff --git a/Asterism/AsterismTests/ComposedURLRuleDescriptionTests.swift b/Asterism/AsterismTests/ComposedURLRuleDescriptionTests.swiftindex a1747a0..188ed48 100644--- a/Asterism/AsterismTests/ComposedURLRuleDescriptionTests.swift+++ b/Asterism/AsterismTests/ComposedURLRuleDescriptionTests.swift@@ -174,7 +174,7 @@ struct ComposedURLRuleDescriptionSlotTests { titleVersion: .available(1), urlVersion: nil, entries: [], works: [], issues: [], prospectiveWorks: [], requiresUnsettledAcknowledgment: false))) mock.commitComposedTeachingResult = .success(- .committed(titleRuleID: UUID(), titleRuleVersion: 1, urlRuleID: nil, urlRuleVersion: nil))+ .committed(titleRuleID: UUID(), urlRuleID: nil)) return ComposedTeachingViewModel( entry: TestFixtures.makeEntry( captureTitle: "TtH - Story - Real Title", hostname: hostname),@@ -214,7 +214,7 @@ struct ComposedURLRuleDescriptionSlotTests { titleVersion: .available(1), urlVersion: nil, entries: [], works: [], issues: [], prospectiveWorks: [], requiresUnsettledAcknowledgment: false))) mock.commitComposedTeachingResult = .success(- .committed(titleRuleID: UUID(), titleRuleVersion: 1, urlRuleID: nil, urlRuleVersion: nil))+ .committed(titleRuleID: UUID(), urlRuleID: nil)) let vm = ComposedTeachingViewModel( entry: TestFixtures.makeEntry(captureTitle: "A", hostname: Self.hostname), library: mock, capabilities: .multiSite, entryContext: .titleFocused, onMutation: {})
diff --git a/Asterism/AsterismTests/ConflictRecentEntryDetailPresentationTests.swift b/Asterism/AsterismTests/ConflictRecentEntryDetailPresentationTests.swiftindex 10a820c..f87fd85 100644--- a/Asterism/AsterismTests/ConflictRecentEntryDetailPresentationTests.swift+++ b/Asterism/AsterismTests/ConflictRecentEntryDetailPresentationTests.swift@@ -126,11 +126,11 @@ struct ConflictRecentEntryDetailPresentationTests { @Test("Entry detail rule disclosure distinguishes current from historical") func ruleDisclosureBadge() {- let currentBadge = EntryDetailRulePresentation.badge(isCurrent: true, version: 3)+ let currentBadge = EntryDetailRulePresentation.badge(isCurrent: true) #expect(currentBadge == "Current rule") - let historicalBadge = EntryDetailRulePresentation.badge(isCurrent: false, version: 2)- #expect(historicalBadge == "Historical rule v2")+ let historicalBadge = EntryDetailRulePresentation.badge(isCurrent: false)+ #expect(historicalBadge == "Historical rule") } @Test("Entry detail discloses identity basis without exposing rule UUID")
diff --git a/Asterism/AsterismTests/EntryDetailModelTests.swift b/Asterism/AsterismTests/EntryDetailModelTests.swiftindex 8621d36..9e8bedb 100644--- a/Asterism/AsterismTests/EntryDetailModelTests.swift+++ b/Asterism/AsterismTests/EntryDetailModelTests.swift@@ -603,7 +603,6 @@ struct EntryDetailModelTests { siteMode: .taught, activePatternSummary: PatternRuleSummary( id: patternID,- version: 2, form: .segment, isActive: true, plainLanguageRule: "Work: 1 segment(s) at offset 1 from end; Ignored: end+0; Chapter: remaining segments"@@ -646,7 +645,7 @@ struct EntryDetailModelTests { entry: entry, siteMode: .taught, activePatternSummary: PatternRuleSummary(- id: UUID(), version: 1, form: .segment, isActive: true,+ id: UUID(), form: .segment, isActive: true, plainLanguageRule: "Work: 1 segment(s) at offset 1 from end; Chapter: remaining segments" ), historicalPatternSummaries: [],@@ -669,24 +668,23 @@ struct EntryDetailModelTests { entry: entry, siteMode: .taught, activePatternSummary: PatternRuleSummary(- id: activeID, version: 2, form: .segment, isActive: true,+ id: activeID, form: .segment, isActive: true, plainLanguageRule: "Work: 1 segment(s) at offset 1 from end; Chapter: remaining segments" ), historicalPatternSummaries: [ PatternRuleSummary(- id: historicalID, version: 1, form: .segment, isActive: false,+ id: historicalID, form: .segment, isActive: false, plainLanguageRule: "Work: 1 segment(s) at offset 0 from start; Chapter: remaining segments" ) ],- chapterSettlement: .patternSettled(patternID: activeID, version: 2),- assignmentSettlement: .patternSettled(patternID: activeID, version: 2),+ chapterSettlement: .patternSettled(patternID: activeID),+ assignmentSettlement: .patternSettled(patternID: activeID), availableActions: [.reTeach, .reParse], unresolvedCandidateTitle: nil ) let (model, _, _) = makeSUT(entry: entry, detail: detail) await model.load() #expect(model.teachingDetail?.activePatternSummary?.id == activeID)- #expect(model.teachingDetail?.activePatternSummary?.version == 2) #expect(model.teachingDetail?.historicalPatternSummaries.count == 1) #expect(model.teachingDetail?.historicalPatternSummaries[0].id == historicalID) #expect(model.teachingDetail?.historicalPatternSummaries[0].isActive == false)
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex 6786e3c..78272fa 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -546,7 +546,7 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable { return try projectInitialTeachingResult.get() } - func projectReplacement(hostname: String, patternDefinition: PatternDefinition, boundaryEntryID: UUID, historicalPatternID: UUID?, historicalPatternVersion: Int?) async throws -> TeachingContract {+ func projectReplacement(hostname: String, patternDefinition: PatternDefinition, boundaryEntryID: UUID, historicalPatternID: UUID?) async throws -> TeachingContract { projectReplacementCallCount += 1 return try projectReplacementResult.get() }
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftindex 5fbbe26..a831bd3 100644--- a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -149,10 +149,10 @@ struct IntegrationSafetyNetTests { ) let stagingDirectory = fixture.baseDirectory.appending(path: "validated-backups")- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: stagingDirectory)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: stagingDirectory) let exportedAt = Date(timeIntervalSince1970: 1_784_246_400) let result = try await exporter.export(- metadata: BackupV7Metadata(+ metadata: BackupV8Metadata( appBuild: "integration-1", exportedAt: exportedAt )@@ -160,8 +160,8 @@ struct IntegrationSafetyNetTests { defer { exporter.cleanup(result) } let encoded = try Data(contentsOf: result.fileURL)- let decoded = try BackupV7Codec.decode(encoded)- let source = try await repository.backupV7Snapshot()+ let decoded = try BackupV8Codec.decode(encoded)+ let source = try await repository.backupV8Snapshot() #expect(decoded.payload == source) #expect(decoded.payload.entries.count == 1)@@ -234,12 +234,12 @@ struct IntegrationSafetyNetTests { Issue.record("Expected Backup export for \(environment) \(capabilities.gate.rawValue)") continue }- // Settings writes 7/8 now (`multi-site-works` Req 9.1): the- // archive has to carry a Work's site memberships and the- // reader's dismissed pairs, so the round-trip is reachable from+ // Settings writes 8/9 now (Req 5.1): the archive has to carry a+ // Work's site memberships, the reader's dismissed pairs and+ // version-free citations, so the round-trip is reachable from // the surface the reader uses. The gate the file declares is // still the running one.- let document = try BackupV7Codec.decode(Data(contentsOf: backupURL))+ let document = try BackupV8Codec.decode(Data(contentsOf: backupURL)) #expect(document.capabilityGate == AsterismCapabilities.current.gate.rawValue) backup.handleShareCancellation() }@@ -324,9 +324,9 @@ struct IntegrationSafetyNetTests { try await sourceRepo.moveEntry(entry.id, to: .existing(work.id)) let stagingDir = fixture.baseDirectory.appending(path: "export-stage")- let exporter = BackupV7Exporter(repository: sourceRepo, stagingDirectory: stagingDir)+ let exporter = BackupV8Exporter(repository: sourceRepo, stagingDirectory: stagingDir) let exportResult = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "fill-test", exportedAt: Date())+ metadata: BackupV8Metadata(appBuild: "fill-test", exportedAt: Date()) ) defer { exporter.cleanup(exportResult) } let backupData = try Data(contentsOf: exportResult.fileURL)@@ -389,9 +389,9 @@ struct IntegrationSafetyNetTests { ) ) let stagingDir = fixture.baseDirectory.appending(path: "restore-stage")- let exporter = BackupV7Exporter(repository: sourceRepo, stagingDirectory: stagingDir)+ let exporter = BackupV8Exporter(repository: sourceRepo, stagingDirectory: stagingDir) let exportResult = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "restore-test", exportedAt: Date())+ metadata: BackupV8Metadata(appBuild: "restore-test", exportedAt: Date()) ) defer { exporter.cleanup(exportResult) } let plan = try BackupImporter.plan(from: try Data(contentsOf: exportResult.fileURL))@@ -896,15 +896,15 @@ struct IntegrationSafetyNetTests { ) let stagingDir = fixture.baseDirectory.appending(path: "corrupt-stage")- let exporter = BackupV7Exporter(repository: repo, stagingDirectory: stagingDir)+ let exporter = BackupV8Exporter(repository: repo, stagingDirectory: stagingDir) let exportResult = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "corrupt-test", exportedAt: Date())+ metadata: BackupV8Metadata(appBuild: "corrupt-test", exportedAt: Date()) ) defer { exporter.cleanup(exportResult) } // Verify good backup decodes let goodData = try Data(contentsOf: exportResult.fileURL)- let decoded = try BackupV7Codec.decode(goodData)+ let decoded = try BackupV8Codec.decode(goodData) #expect(decoded.payload.entries.count == 1) // Corrupt the data by flipping bytes in the payload area@@ -917,7 +917,7 @@ struct IntegrationSafetyNetTests { // Corrupted backup should fail decode/checksum do {- _ = try BackupV7Codec.decode(corruptData)+ _ = try BackupV8Codec.decode(corruptData) Issue.record("Expected corrupted backup to fail validation") } catch { // Expected: checksum or decode failure@@ -1004,13 +1004,13 @@ struct IntegrationSafetyNetTests { // Export the archive let stagingDir = fixture.baseDirectory.appending(path: "url-backup-stage")- let exporter = BackupV7Exporter(repository: repo, stagingDirectory: stagingDir)+ let exporter = BackupV8Exporter(repository: repo, stagingDirectory: stagingDir) let exportResult = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "url-backup-test", exportedAt: Date())+ metadata: BackupV8Metadata(appBuild: "url-backup-test", exportedAt: Date()) ) defer { exporter.cleanup(exportResult) } let backupData = try Data(contentsOf: exportResult.fileURL)- let decoded = try BackupV7Codec.decode(backupData)+ let decoded = try BackupV8Codec.decode(backupData) // Site should be present in the payload. let site = decoded.payload.sites.first { $0.hostname == "backupurl.test" }
diff --git a/Asterism/AsterismTests/OptionalSequenceTeachingMessagesTests.swift b/Asterism/AsterismTests/OptionalSequenceTeachingMessagesTests.swiftindex aaef049..2f1418d 100644--- a/Asterism/AsterismTests/OptionalSequenceTeachingMessagesTests.swift+++ b/Asterism/AsterismTests/OptionalSequenceTeachingMessagesTests.swift@@ -55,7 +55,7 @@ struct OptionalSequenceTeachingMessagesTests { requiresUnsettledAcknowledgment: false))) mock.commitComposedTeachingResult = .success( .committed(- titleRuleID: UUID(), titleRuleVersion: 1, urlRuleID: nil, urlRuleVersion: nil))+ titleRuleID: UUID(), urlRuleID: nil)) return ComposedTeachingViewModel( entry: TestFixtures.makeEntry( captureTitle: storyTitle, hostname: hostname, rawURLString: exampleURL),@@ -321,7 +321,7 @@ struct OptionalSequencePreviewTests { requiresUnsettledAcknowledgment: false))) mock.commitComposedTeachingResult = .success( .committed(- titleRuleID: UUID(), titleRuleVersion: 1, urlRuleID: nil, urlRuleVersion: nil))+ titleRuleID: UUID(), urlRuleID: nil)) return ComposedTeachingViewModel( entry: TestFixtures.makeEntry( captureTitle: storyTitle, hostname: hostname,
diff --git a/Asterism/AsterismTests/ReparseViewModelTests.swift b/Asterism/AsterismTests/ReparseViewModelTests.swiftindex 4c1b4d8..3a136cc 100644--- a/Asterism/AsterismTests/ReparseViewModelTests.swift+++ b/Asterism/AsterismTests/ReparseViewModelTests.swift@@ -27,8 +27,8 @@ struct ReparseViewModelTests { } private static func makeEntryBasis(- chapterProvenance: FieldProvenance = try! FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1),- assignmentProvenance: FieldProvenance = try! FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1)+ chapterProvenance: FieldProvenance = try! FieldProvenance(kind: .pattern, patternID: patternID),+ assignmentProvenance: FieldProvenance = try! FieldProvenance(kind: .pattern, patternID: patternID) ) -> EntryBasisEntry { EntryBasisEntry( id: entryID,@@ -223,6 +223,28 @@ struct ReparseViewModelTests { _ = await (first, second) #expect(mock.commitReparseCallCount == 1) }++ /// Q13: a citation is the rule's UUID, so neither pattern label carries a+ /// version any more. The wording is what the sheet shows, so pin it.+ @Test("Pattern provenance labels carry no version")+ func patternProvenanceLabelsAreVersionFree() {+ #expect(+ ReparseViewModel.provenanceLabel(+ .preserved(try! FieldProvenance(kind: .pattern, patternID: Self.patternID)))+ == "Pattern (preserved)")+ #expect(+ ReparseViewModel.provenanceLabel(.pendingPattern(PendingPatternProvenance(patternVersion: 7)))+ == "Pending pattern")+ // The other arms are unchanged — asserted so a future edit to the switch+ // cannot quietly reword them.+ #expect(+ ReparseViewModel.provenanceLabel(.preserved(try! FieldProvenance(kind: .none)))+ == "None (preserved)")+ #expect(+ ReparseViewModel.provenanceLabel(.preserved(try! FieldProvenance(kind: .manual)))+ == "Manual (preserved)")+ #expect(ReparseViewModel.provenanceLabel(.cleared) == "Cleared")+ } } final class ReparseCallbackTracker: @unchecked Sendable {
diff --git a/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift b/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swiftindex ee6f395..eb55e35 100644--- a/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift+++ b/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift@@ -134,11 +134,11 @@ struct RuleSuggestionCoordinatorTests { private static func candidate( _ hostname: String, capturedAt: TimeInterval = 0, mode: SiteMode = .untaught,- titleRuleVersion: Int? = nil, urlRuleVersion: Int? = nil, entryCount: Int = 3+ titleRuleID: UUID? = nil, urlRuleID: UUID? = nil, entryCount: Int = 3 ) -> RuleSuggestionCandidate { RuleSuggestionCandidate(- hostname: hostname, siteMode: mode, titleRuleVersion: titleRuleVersion,- urlRuleVersion: urlRuleVersion, entryCount: entryCount,+ hostname: hostname, siteMode: mode, titleRuleID: titleRuleID,+ urlRuleID: urlRuleID, entryCount: entryCount, latestCaptureAt: Date(timeIntervalSince1970: capturedAt)) } @@ -191,7 +191,7 @@ struct RuleSuggestionCoordinatorTests { Self.candidate("empty.test", capturedAt: 70, entryCount: 0), Self.candidate( "taught.test", capturedAt: 80, mode: .taught,- titleRuleVersion: 1, urlRuleVersion: 1),+ titleRuleID: UUID(), urlRuleID: UUID()), ] let (coordinator, _, suggester, _, _) = makeSUT(candidates: candidates) @@ -200,6 +200,26 @@ struct RuleSuggestionCoordinatorTests { #expect(suggester.calls == ["newest.test", "second.test", "middle.test"]) } + @Test("A site is fully taught only when both rule ids are present (Req 6.3)")+ func autoEligibilityKeysOnTheRuleIDs() async {+ let candidates = [+ // Both sides stored: fully taught, so nothing to suggest.+ Self.candidate(+ "both.test", capturedAt: 40, mode: .taught,+ titleRuleID: UUID(), urlRuleID: UUID()),+ // One side missing on each: still eligible, as with a nil version.+ Self.candidate("title-only.test", capturedAt: 30, mode: .taught,+ titleRuleID: UUID()),+ Self.candidate("url-only.test", capturedAt: 20, mode: .taught,+ urlRuleID: UUID()),+ ]+ let (coordinator, _, suggester, _, _) = makeSUT(candidates: candidates)++ await coordinator.activationSweep()++ #expect(suggester.calls == ["title-only.test", "url-only.test"])+ }+ @Test("A hostname attempted this run is not swept again") func sweepSkipsAttemptedHostnames() async { let (coordinator, _, suggester, _, _) = makeSUT(
diff --git a/Asterism/AsterismTests/SettingsBackupModelTests.swift b/Asterism/AsterismTests/SettingsBackupModelTests.swiftindex c9dcdfb..578a0f4 100644--- a/Asterism/AsterismTests/SettingsBackupModelTests.swift+++ b/Asterism/AsterismTests/SettingsBackupModelTests.swift@@ -213,7 +213,7 @@ struct SettingsBackupModelTests { @MainActor func tornGroupsMessageStatesTheCount() async { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV7ExportError.tornGroups(+ BackupV8ExportError.tornGroups( TornGroupsPayload(count: 3, blockingWorkSet: nil))) let model = SettingsBackupModel(exporter: mock)@@ -234,7 +234,7 @@ struct SettingsBackupModelTests { @MainActor func tornGroupsMessageReadsSingular() async { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV7ExportError.tornGroups(+ BackupV8ExportError.tornGroups( TornGroupsPayload(count: 1, blockingWorkSet: nil))) let model = SettingsBackupModel(exporter: mock)@@ -252,7 +252,7 @@ struct SettingsBackupModelTests { @MainActor func tornGroupsMessagePointsAtTheBlockingWorkSet() async { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV7ExportError.tornGroups(+ BackupV8ExportError.tornGroups( TornGroupsPayload( count: 1, blockingWorkSet: DuplicateSetKey(@@ -288,7 +288,7 @@ struct SettingsBackupModelTests { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV7ExportError.tornGroups(+ BackupV8ExportError.tornGroups( TornGroupsPayload(count: 1, blockingWorkSet: nil))) let model = SettingsBackupModel(exporter: mock) await model.startExport()@@ -302,19 +302,19 @@ struct SettingsBackupModelTests { #expect(!model.routesToCheckLibrary) } - // MARK: - Archive generation 7/8 (multi-site-works Req 9.1)+ // MARK: - Archive generation 8/9 (rule-citation-by-uuid Req 5.1) /// The Settings surface is the only place the app *writes* an archive, so a- /// repository that reaches 7/8 while this seam still asks for 6/7 leaves the- /// round-trip Req 9.2 promises unreachable. The metadata type is the tell:- /// the exporter this model holds is the one whose payload carries- /// memberships, dismissed pairs and the coverage fingerprints.- @Test("The export surface asks the 7/8 exporter for the archive")- @MainActor func exportsArchiveGenerationSevenEight() async {+ /// repository that reaches 8/9 while this seam still asks for 7/8 leaves the+ /// round-trip `multi-site-works` Req 9.2 promises unreachable. The metadata+ /// type is the tell: the exporter this model holds is the one whose payload+ /// carries version-free citations.+ @Test("The export surface asks the 8/9 exporter for the archive")+ @MainActor func exportsArchiveGenerationEightNine() async { let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) }- let fakeURL = tempDir.appending(path: "Asterism-backup-78.json")+ let fakeURL = tempDir.appending(path: "Asterism-backup-89.json") try? Data("{}".utf8).write(to: fakeURL) let mock = MockBackupExporting()@@ -323,7 +323,7 @@ struct SettingsBackupModelTests { let model = SettingsBackupModel(exporter: mock) await model.startExport() - let metadata: BackupV7Metadata? = mock.lastMetadata+ let metadata: BackupV8Metadata? = mock.lastMetadata #expect(metadata != nil) #expect(metadata?.appBuild.isEmpty == false) }@@ -331,14 +331,14 @@ struct SettingsBackupModelTests { /// Req 6.5, through Q105: a torn **character** group refuses the export the /// same way a torn Work or Entry does, and the reader is sent to the same /// place. The payload carries a count, not a record kind, so what this pins- /// is that the 7/8 refusal reaches a message arm at all — an unhandled case+ /// is that the 8/9 refusal reaches a message arm at all — an unhandled case /// would fall through to the generic "please try again", which is the dead /// end Decision 20 already removed once.- @Test("A 7/8 torn refusal routes the reader to Check Library")- @MainActor func sevenEightTornRefusalRoutes() async {+ @Test("An 8/9 torn refusal routes the reader to Check Library")+ @MainActor func eightNineTornRefusalRoutes() async { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV7ExportError.tornGroups(+ BackupV8ExportError.tornGroups( TornGroupsPayload(count: 2, blockingWorkSet: nil))) let model = SettingsBackupModel(exporter: mock)@@ -351,18 +351,18 @@ struct SettingsBackupModelTests { #expect(model.routesToCheckLibrary) } - /// Every case of the 7/8 refusal has a message of its own. A case that fell+ /// Every case of the 8/9 refusal has a message of its own. A case that fell /// through to the default arm would be indistinguishable from an error the /// app has never heard of.- @Test("Every 7/8 export refusal has its own message", arguments: [- BackupV7ExportError.referencesStillArriving(detail: "rule 1"),- BackupV7ExportError.unrepresentableValue(+ @Test("Every 8/9 export refusal has its own message", arguments: [+ BackupV8ExportError.referencesStillArriving(detail: "rule 1"),+ BackupV8ExportError.unrepresentableValue( record: "Character", field: "factsData", value: "…"),- BackupV7ExportError.snapshotFailed(reason: "read"),- BackupV7ExportError.encodingFailed(reason: "encode"),- BackupV7ExportError.stagingFailed(reason: "stage"),+ BackupV8ExportError.snapshotFailed(reason: "read"),+ BackupV8ExportError.encodingFailed(reason: "encode"),+ BackupV8ExportError.stagingFailed(reason: "stage"), ])- @MainActor func everySevenEightRefusalHasAMessage(error: BackupV7ExportError) async {+ @MainActor func everyEightNineRefusalHasAMessage(error: BackupV8ExportError) async { let mock = MockBackupExporting() mock.exportResult = .failure(error) @@ -397,12 +397,12 @@ final class MockBackupExporting: BackupExporting, @unchecked Sendable { var cleanupCallCount = 0 var scavengeCallCount = 0 var lastCleanupURL: URL?- var lastMetadata: BackupV7Metadata?+ var lastMetadata: BackupV8Metadata? var exportResult: Result<BackupExportResult, Error> = .failure(MockBackupError.notConfigured) var exportDelay: Duration? - func export(metadata: BackupV7Metadata) async throws -> BackupExportResult {+ func export(metadata: BackupV8Metadata) async throws -> BackupExportResult { exportCallCount += 1 lastMetadata = metadata if let delay = exportDelay {
diff --git a/Asterism/AsterismTests/SettingsImportTests.swift b/Asterism/AsterismTests/SettingsImportTests.swiftindex 6a817e7..729e579 100644--- a/Asterism/AsterismTests/SettingsImportTests.swift+++ b/Asterism/AsterismTests/SettingsImportTests.swift@@ -62,14 +62,14 @@ enum MockSetupError: Error, LocalizedError { @Suite("SettingsBackupImportModel") struct SettingsBackupImportModelTests { - /// A real 7/8 document, because the model plans the bytes it is handed —+ /// A real 8/9 document, because the model plans the bytes it is handed — /// stubbing the planner would leave the preview untested. static let minimalBackupData: Data = { let hostname = "settings-import.example" let rawURL = "https://\(hostname)/read?chapter=1"- let payload = BackupV7Payload(+ let payload = BackupV8Payload( entries: [- BackupV7Entry(+ BackupV8Entry( id: UUID(), captureTitle: "Chapter 1", captureTitleSource: .host, rawURL: rawURL, canonicalURL: nil, hostname: hostname, entryIdentityKey: rawURL,@@ -84,14 +84,14 @@ struct SettingsBackupImportModelTests { ], works: [], sites: [- BackupV7Site(+ BackupV8Site( hostname: hostname, displayName: hostname, mode: .untaught, junkSuffixRule: nil) ], titlePatterns: [], urlRules: [], workTypes: [])- return try! BackupV7Codec.encode(+ return try! BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(+ metadata: BackupV8Metadata( appBuild: "test", exportedAt: Date(timeIntervalSince1970: 1_800_000_000))) }()
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 16a6452..afa303c 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -26,6 +26,79 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Suggestion ledger and cleanup (rule-citation-by-uuid, phase 5,+ T-2281).** `CorpusFingerprint` and the suggestion candidate key on the+ current rules' UUIDs, read through the Decision 1 accessors: a newly+ taught rule invalidates a held suggestion, a recalculated rule (same+ UUID) does not, and a site missing either id stays auto-eligible as+ not fully taught (Req 6.3, Q35). Doc comments, validator messages and+ the schema-migration / rule-wire-format agent notes no longer describe+ the Site-unique and greatest-version invariants, renumbering or the+ 7/8 archive as live. Verification (`verification-run.md`): core, unit+ and build gates green with no new warnings, the Req 7.1 grep gate+ clean, and two host-only `make test-performance-m4` runs at `ca77c17`+ inside every ceiling with the same eight known issues as V9 — the+ Req 5.4 capture-projection arms moved −1.4% to +1.2%, so the+ `RuleSelection.current` risk did not materialise.+ `PendingPatternProvenance.patternVersion` is now written but unread+ and is left for T-2282 (Q36).++- **Archive generation 8/9 (rule-citation-by-uuid, phase 4, T-2281).**+ `BackupV7Types/Codec/Exporter` are `BackupV8*`; the exporter stamps+ format 8 over schema 9 and the importer accepts only that pair, so a+ 7/8 archive is refused naming both numbers (Req 5.2, Q8) and an 8/9+ archive carrying a citation `version` fails as `checksumMismatch`+ (Req 5.4). Rule rows round-trip `(id, isMarked, createdAt, version)`+ as stored, including retired rows at higher versions than the marked+ one (Req 5.3), and an archive holding duplicate versions imports+ (Req 3.2). `backup-7-8-golden.json` is replaced by+ `backup-8-9-golden.json`, recorded through the new+ `ASTERISM_RECORD_GOLDEN=1` mode (Q22); the byte test also runs the+ strict validator over the restored library (Q34).++- **Invariant retirement (rule-citation-by-uuid, phase 3, T-2281).** A+ citation resolves by UUID alone through `RuleSelection.citedRow`, which+ hands a same-UUID group to `GroupOrdering`'s representative (Req 2.6,+ Q6). The Site-unique and greatest-version clauses are gone from+ `LibraryValidator`, `BackupArchiveReferenceChecks`,+ `validatedRecentSiteMode` and the teaching basis, and so are the+ read-side positivity checks on a row's `version` — the model-init+ guards and minting refusals stay (Q24, Q31). `SiteUnionProjection`+ no longer renumbers (`assignVersions`, `versionRewrites`,+ `owningRank` deleted; `repairsInPlace` is flag drift only; `keptRule`+ is `RuleSelection.current`), `SiteReconciler` re-parents and demotes+ without touching a citation, and `DuplicateReconciler` converges+ definitions without `alignVersions`, the `*VersionIsFree` predicates+ or the citation rewrite walk. The export rewrite map went with them and+ archived rule records carry each row's stored version (Req 3.8, Q33).++- **Current rule and orderings (rule-citation-by-uuid, phase 2,+ T-2281).** `RuleSelection.current` picks the marked row with the newest+ `createdAt`, then the lowest UUID (Decision 1); `Site.activePattern`+ and the new `Site.currentURLRule` route through it and every selecting+ flag read in capture, reparse, composed teaching, entry detail, rule+ suggestion and URL identity uses those accessors — the count/demote/+ refusal reads stay as they were (Q12). A whole-file source scan pins+ Req 4.2 with the one `DuplicateReconciler` site tolerated as a known+ issue until 6.2 deletes it (Q29). The four `(version, id)` orderings+ on the Sites screen, Entry-detail history, teaching basis and URL+ basis are `(createdAt, id)`; `URLRuleBasisEntry` carries `createdAt`+ and no version, and `URLSiteEvidenceBasis` no longer refuses+ Site-duplicate versions.++- **Citation types (rule-citation-by-uuid, phase 1, T-2281).**+ `CitedRule`, `FieldProvenance` and `URLRuleReference` cite a rule by+ UUID alone; the citation `version` integer, `rewritingVersions`,+ `Entry.RuleCitation.version`, `WorkURLIdentityMint.ruleVersion`, the+ `historicalPatternVersion` chain and the version payloads on both+ `committed` outcomes are gone (Q15, Q16, Q20). Old-shape blobs decode+ tolerantly against a fixture captured from the previous encoder (Q17),+ and the encoder is asserted version-free at every depth. The reparse+ labels read "Pattern (preserved)" / "Pending pattern" (Q13).+ `SiteReconciler.rewriteCitations` is a no-op and `DuplicateReconciler`+ skips its Entry walk until 5.2/6.2 delete the plumbing (Q26); the 7/8+ golden was re-recorded without version keys pending 7.2 (Q27).+ - **Deletions and verification (drop-superseded-columns, phase 3, T-2280).** The V7-shaped test facade (`MembershipTestSupport`'s column doors, `WorkMergeOutcome.workURL/identityEvidence/identityDisposition`)
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift b/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swiftindex c8ae59f..eabfaa6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift@@ -25,7 +25,7 @@ import SwiftData /// archive's own. internal enum ArchiveRecordBuilders { - static func makeSite(_ record: BackupV7Site) -> Site {+ static func makeSite(_ record: BackupV8Site) -> Site { let site = Site(hostname: record.hostname, displayName: record.displayName) site.modeRaw = record.mode.rawValue site.junkSuffixRule = record.junkSuffixRule@@ -33,7 +33,7 @@ internal enum ArchiveRecordBuilders { } static func makeTitlePattern(- _ record: BackupV7TitlePattern, site: Site?+ _ record: BackupV8TitlePattern, site: Site? ) throws -> TitlePattern { return try TitlePattern( id: record.id,@@ -48,7 +48,7 @@ internal enum ArchiveRecordBuilders { } static func makeURLRule(- _ record: BackupV7URLRule, site: Site?+ _ record: BackupV8URLRule, site: Site? ) throws -> URLRulePattern { try URLRulePattern( id: record.id,@@ -64,7 +64,7 @@ internal enum ArchiveRecordBuilders { /// The wire timestamps are what an import-created row carries on both fields /// (Q33), and an unrecognised state coerces to `.active` rather than /// refusing — a type row from a later build's wider set is legal data.- static func makeWorkType(_ record: BackupV7WorkType) -> WorkTypeEntity {+ static func makeWorkType(_ record: BackupV8WorkType) -> WorkTypeEntity { makeWorkType( id: record.id, name: record.name, state: ToleratedEnum.read(record.stateRaw, default: .active),@@ -87,11 +87,11 @@ internal enum ArchiveRecordBuilders { return row } - /// The Work row alone. A 7/8 record names no site (Req 9.1), and since V9 a+ /// The Work row alone. An archive record names no site, and since V9 a /// Work has no site column to name: its site presence is its /// `WorkSiteMembership`, which `makeMembership` below builds from the /// archive's own records.- static func makeWork(_ record: BackupV7Work) -> Work {+ static func makeWork(_ record: BackupV8Work) -> Work { let work = Work( id: record.id, displayTitle: record.displayTitle,@@ -105,7 +105,7 @@ internal enum ArchiveRecordBuilders { /// carries it. `workID` travels whether or not the Work is there (Q37), so an /// orphan re-attaches when its Work arrives (Req 8.3, 9.5). static func makeMembership(- _ record: BackupV7Membership, work: Work?, site: Site?+ _ record: BackupV8Membership, work: Work?, site: Site? ) -> WorkSiteMembership { WorkSiteMembership( id: record.id,@@ -123,13 +123,13 @@ internal enum ArchiveRecordBuilders { /// One dismissed pair. The record's ids are already in the canonical sorted /// order — `BackupImportPayload` normalises them at the door — so nothing /// here re-sorts and then disagrees about which end is which.- static func makeDistinctPair(_ record: BackupV7DistinctPair) -> WorkDistinctPair {+ static func makeDistinctPair(_ record: BackupV8DistinctPair) -> WorkDistinctPair { WorkDistinctPair( id: record.id, lowerWorkID: record.lowerWorkID, higherWorkID: record.higherWorkID, recordedAt: record.recordedAt) } - static func makeEntry(_ record: BackupV7Entry) -> Entry {+ static func makeEntry(_ record: BackupV8Entry) -> Entry { let entry = Entry( id: record.id, captureTitle: record.captureTitle,@@ -144,7 +144,7 @@ internal enum ArchiveRecordBuilders { return entry } - static func makeCharacter(_ record: BackupV7Character) -> CharacterRecord {+ static func makeCharacter(_ record: BackupV8Character) -> CharacterRecord { let character = CharacterRecord( id: record.id, name: record.name, nameKey: record.nameKey, aliases: record.aliases, note: record.note, facts: record.facts,@@ -156,7 +156,7 @@ internal enum ArchiveRecordBuilders { /// The raw columns travel verbatim, so a value written by a later build's /// wider set survives the round trip rather than being coerced to this /// build's default.- static func makeSuppression(_ record: BackupV7Suppression) -> CharacterSuppression {+ static func makeSuppression(_ record: BackupV8Suppression) -> CharacterSuppression { let row = CharacterSuppression( id: record.id, kind: record.kind, nameKey: record.nameKey, source: record.source, evidence: record.evidence, status: record.status,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swiftindex bc433db..31495c8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift@@ -12,10 +12,13 @@ public struct AsterismCapabilities: Codable, Equatable, Sendable { case m3 = "m3" case m4 = "m4" /// V8's gate (`multi-site-works` Q29): a Work holds site memberships,- /// and the archive it writes is format 7 over schema 8. Nothing about a+ /// and the archive it writes is format 8 over schema 9. Nothing about a /// *rule form* changes with it — every `supports…` answer below is m4's- /// — so the case exists to name the archive generation and the store- /// shape it belongs to, which is what `BackupV7Codec` stamps.+ /// — so the case exists to name the store shape and the rule-form set,+ /// which is what `BackupV8Codec` stamps. The literal did not move when+ /// the archive did: 8/9 changes neither of those two things (Q19), and+ /// the generation is named by its format and schema numbers, which are+ /// what the importer gates on. case multiSite = "multi-site" } @@ -28,7 +31,7 @@ public struct AsterismCapabilities: Codable, Equatable, Sendable { public static let multiSite = AsterismCapabilities(gate: .multiSite) /// The current runtime gate is `.multiSite` (`multi-site-works` Q29).- /// `BackupV7Codec` stamps the literal `"multi-site"` rather than reading+ /// `BackupV8Codec` stamps the literal `"multi-site"` rather than reading /// this value, so the archive's gate is independent of the runtime's. /// Earlier gates stay available because the schema and teaching suites still /// exercise them — `CapabilityGatingTests`, `PhraseParsingTests`,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swiftindex eb023b3..c4ec020 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift@@ -4,7 +4,7 @@ import SwiftData private let exportLogger = Logger(subsystem: "AsterismCore", category: "BackupExport") -// The record projection every 7/8 export runs through, and the three refusals it+// The record projection every 8/9 export runs through, and the three refusals it // names. It stood in the 4/4 exporter while three generations shared it; // those generations are gone and this is the live export path, so it stands on // its own.@@ -12,20 +12,17 @@ private let exportLogger = Logger(subsystem: "AsterismCore", category: "BackupEx // MARK: - The format-independent half of a projection /// What `projectCommonArchiveRecords` produced: the records that do not depend on-/// the Work mapper, plus the identity groups and version rewrites it needs.+/// the Work mapper, plus the identity groups it needs. internal struct ArchiveCommonProjection { let groups: BackupGroupProjection.Projection- let entries: [BackupV7Entry]- let sites: [BackupV7Site]- let titlePatterns: [BackupV7TitlePattern]- let urlRules: [BackupV7URLRule]+ let entries: [BackupV8Entry]+ let sites: [BackupV8Site]+ let titlePatterns: [BackupV8TitlePattern]+ let urlRules: [BackupV8URLRule] /// One record per Work and hostname (Req 9.1), enumerated whole rather than /// works→children (Q17): a membership whose Work has not arrived exports /// naming the Work it belongs to instead of vanishing from the backup.- let memberships: [BackupV7Membership]- /// The site union's rule-id → version map, so a citation of a renumbered- /// rule is archived at the version the archive holds (Decision 7).- let rewrites: [UUID: Int]+ let memberships: [BackupV8Membership] } /// Every Entry row's citations, decoded **once** for the whole projection.@@ -65,8 +62,7 @@ internal struct EntryCitationsCache { extension LibraryRepository { /// Everything an archive projection does before the Work records are /// written: the Entry, Site, TitlePattern, URLRule and membership records,- /// the identity groups, the unreadable-rule partition, the site union and- /// its version rewrites.+ /// the identity groups, the unreadable-rule partition and the site union. internal static func projectCommonArchiveRecords( context: ModelContext ) throws -> ArchiveCommonProjection {@@ -129,7 +125,7 @@ extension LibraryRepository { var additionalPatterns: [String: [TitlePattern]] = [:] for pattern in archivablePatterns where pattern.site == nil { guard let hostname = citers[pattern.id] else {- throw BackupV7ExportError.referencesStillArriving(+ throw BackupV8ExportError.referencesStillArriving( detail: "title rule \(pattern.id) has no site and no entry naming one") } additionalPatterns[hostname, default: []].append(pattern)@@ -137,7 +133,7 @@ extension LibraryRepository { var additionalURLRules: [String: [URLRulePattern]] = [:] for rule in archivableURLRules where rule.site == nil { guard let hostname = citers[rule.id] else {- throw BackupV7ExportError.referencesStillArriving(+ throw BackupV8ExportError.referencesStillArriving( detail: "URL rule \(rule.id) has no site and no record naming one") } additionalURLRules[hostname, default: []].append(rule)@@ -155,43 +151,38 @@ extension LibraryRepository { // Rule identity groups are deduped **before** the union runs: a rule // group is one rule, the file keys rules by UUID, and the reference- // validator refuses a payload holding one twice. The union assigns- // versions per rule id, so the surviving row's version is what- // `rewrites` carries.+ // validator refuses a payload holding one twice. The record then carries+ // the version stored on the row the reduction kept — the union assigns+ // none since T-2281 (Req 3.8). let projected = SiteUnionProjection.project( rows: sites, danglingHostnames: danglingHostnames, additionalPatterns: additionalPatterns, additionalURLRules: additionalURLRules, ruleMembership: .oneRowPerIdentityGroup) try requireProjectedTuplesRepresentable(projected) - var wireSites: [BackupV7Site] = []- var wirePatterns: [BackupV7TitlePattern] = []- var wireRules: [BackupV7URLRule] = []- var rewrites: [UUID: Int] = [:]+ var wireSites: [BackupV8Site] = []+ var wirePatterns: [BackupV8TitlePattern] = []+ var wireRules: [BackupV8URLRule] = [] for site in projected {- rewrites.merge(site.versionRewrites) { lhs, _ in lhs }- wireSites.append(mapV7SiteRecord(site))+ wireSites.append(mapV8SiteRecord(site)) for projectedPattern in site.patterns where !omittedTitlePatternIDs.contains(projectedPattern.pattern.id) { wirePatterns.append(- try mapV7TitlePatternRecord(projectedPattern, hostname: site.hostname))+ try mapV8TitlePatternRecord(projectedPattern, hostname: site.hostname)) } for projectedRule in site.urlRules where !omittedURLRuleIDs.contains(projectedRule.rule.id) {- wireRules.append(try mapV7URLRuleRecord(projectedRule, hostname: site.hostname))+ wireRules.append(try mapV8URLRuleRecord(projectedRule, hostname: site.hostname)) } } return ArchiveCommonProjection( groups: groups,- entries: try groups.entries.map {- try mapV7EntryRecord($0, rewrites: rewrites, citations: citations)- },+ entries: try groups.entries.map { try mapV8EntryRecord($0, citations: citations) }, sites: wireSites.sorted { $0.hostname < $1.hostname }, titlePatterns: wirePatterns.sorted { $0.id.uuidString < $1.id.uuidString }, urlRules: wireRules.sorted { $0.id.uuidString < $1.id.uuidString },- memberships: mapMembershipRecords(membershipRows),- rewrites: rewrites)+ memberships: mapMembershipRecords(membershipRows)) } // MARK: - The three named refusals@@ -229,7 +220,7 @@ extension LibraryRepository { // than reached by a mapper that would throw a raw `DecodingError`. do { _ = try citations.value(of: entry) } catch {- throw BackupV7ExportError.unrepresentableValue(+ throw BackupV8ExportError.unrepresentableValue( record: record, field: "citations", value: String(describing: error)) } }@@ -259,7 +250,7 @@ extension LibraryRepository { // its bytes rather than by a `formRaw` that no longer exists. do { _ = try pattern.storedDefinition } catch {- throw BackupV7ExportError.unrepresentableValue(+ throw BackupV8ExportError.unrepresentableValue( record: record, field: "definition", value: String(describing: error)) } }@@ -297,7 +288,7 @@ extension LibraryRepository { var omitted: Set<UUID> = [] for (id, rule) in unreadable.sorted(by: { $0.key.uuidString < $1.key.uuidString }) { guard !cited.contains(id) else {- throw BackupV7ExportError.unrepresentableValue(+ throw BackupV8ExportError.unrepresentableValue( record: "URL rule \(id)", field: "definition", value: "\(rule.definitionData.count) bytes that do not decode") }@@ -348,7 +339,7 @@ extension LibraryRepository { 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(+ throw BackupV8ExportError.unrepresentableValue( record: "Title rule \(id)", field: "definition", value: pattern.definitionData.map { "\($0.count) bytes that do not decode" } ?? "no stored definition")@@ -404,7 +395,7 @@ extension LibraryRepository { _ value: Value?, _ record: String, _ field: String, _ raw: String ) throws { guard value == nil else { return }- throw BackupV7ExportError.unrepresentableValue(record: record, field: field, value: raw)+ throw BackupV8ExportError.unrepresentableValue(record: record, field: field, value: raw) } /// Req 3.7's third face: a hostname whose *projected* tuple the archive@@ -430,7 +421,7 @@ extension LibraryRepository { switch site.mode { case .taught: guard activePatterns != 1 else { continue }- throw BackupV7ExportError.referencesStillArriving(+ throw BackupV8ExportError.referencesStillArriving( detail: "site \(site.hostname) is taught, and the one active title rule " + "that state needs is not in the library") case .untaught:@@ -438,36 +429,36 @@ extension LibraryRepository { $0.rule.origin == .importedV2 && !$0.isCurrent } guard !site.patterns.isEmpty || currentRules > 0 || !historyOnly else { continue }- throw BackupV7ExportError.referencesStillArriving(+ throw BackupV8ExportError.referencesStillArriving( detail: "site \(site.hostname) is untaught while still holding rules, " + "so the teaching that owns them has not arrived") case .articles: guard activePatterns > 0 || currentRules > 0 else { continue }- throw BackupV7ExportError.referencesStillArriving(+ throw BackupV8ExportError.referencesStillArriving( detail: "site \(site.hostname) reads as articles while still holding an " + "active rule, so the change that cleared them has not arrived") } } } - /// Req 3.7's second half: a citation whose rule no row holds. The union- /// renumbering means a rule present in the store always resolves, so what is- /// left here is a citation of a rule that is genuinely absent.+ /// Req 3.7's second half: a citation whose rule no row holds. A citation is+ /// the rule's UUID (T-2281), so a rule present in the store always resolves+ /// and what is left here is a citation of a rule that is genuinely absent. ///- /// Neither arm resolves a **version** (Req 10.4, Q28) — a membership cites- /// none, and the union renumbering makes a held rule's version the current- /// one — but **both** check the site (Q81). An identity or a chapter title- /// derived on one site by another site's rule is a value no writer produces,- /// and the archive validator refuses it on the way back in. The entry arm+ /// Neither arm resolves a **version** — a membership cites none and a rule+ /// citation carries none — but **both** check the site (Q81). An identity or+ /// a chapter title derived on one site by another site's rule is a value no+ /// writer produces, and the archive validator refuses it on the way back in.+ /// The entry arm /// used to check only that the rule was *held*, so a cross-site citation /// exported cleanly and then failed decode-validation on import: a library /// problem surfacing as a broken file, which is exactly what this gate /// exists to say first. internal static func requireCitationsResolve(- entries: [BackupV7Entry],- memberships: [BackupV7Membership],- titlePatterns: [BackupV7TitlePattern],- urlRules: [BackupV7URLRule]+ entries: [BackupV8Entry],+ memberships: [BackupV8Membership],+ titlePatterns: [BackupV8TitlePattern],+ urlRules: [BackupV8URLRule] ) throws { let rulesByID = Dictionary(urlRules.map { ($0.id, $0) }, uniquingKeysWith: { lhs, _ in lhs }) let patternHostnames = Dictionary(@@ -507,14 +498,14 @@ extension LibraryRepository { private static func crossSiteCitation( _ record: String, _ field: String, taughtFor hostname: String- ) -> BackupV7ExportError {+ ) -> BackupV8ExportError { .referencesStillArriving( detail: "\(record) names \(field), which is taught for \(hostname)") } private static func missingCitation( _ record: String, _ field: String- ) -> BackupV7ExportError {+ ) -> BackupV8ExportError { .referencesStillArriving( detail: "\(record) names \(field), which the library does not hold") }@@ -542,7 +533,7 @@ extension LibraryRepository { return map } - // MARK: - V7 Record Mappers+ // MARK: - V8 Record Mappers /// The record an Entry identity group archives as (Req 8.2): the /// representative row's capture evidence, the **group's** authored content,@@ -557,25 +548,23 @@ extension LibraryRepository { /// never held (Decision 10, Q84). Everything else is derived or immutable /// evidence and comes from the representative. ///- /// `rewrites` carries the union's rule-id → version map, so a citation of a- /// rule the projection renumbered is archived at the version the archive- /// actually holds (Decision 7). Without it the verify-decode would reject- /// every duplicated hostname's entries.- internal static func mapV7EntryRecord(- _ group: EntryGroup, rewrites: [UUID: Int] = [:],- citations cache: EntryCitationsCache? = nil- ) throws -> BackupV7Entry {+ /// A `rewrites` parameter carried the union's rule-id → version map here, so+ /// a citation of a rule the projection renumbered was archived at the+ /// version the archive actually held (Decision 7). A citation is a UUID+ /// (T-2281) and nothing renumbers, so the map and the parameter are gone.+ internal static func mapV8EntryRecord(+ _ group: EntryGroup, citations cache: EntryCitationsCache+ ) throws -> BackupV8Entry { let snap = try snapshot(group) let entry = group.representative let carrier = group.carrier- let cache = cache ?? EntryCitationsCache(group.rows) var citations = try cache.value(of: entry) if group.isSplit { let carried = try cache.value(of: carrier) citations.chapterTitle = carried.chapterTitle citations.workAssignment = carried.workAssignment }- return BackupV7Entry(+ return BackupV8Entry( id: snap.id, captureTitle: snap.captureTitle, captureTitleSource: snap.captureTitleSource,@@ -595,7 +584,7 @@ extension LibraryRepository { modifiedAt: snap.modifiedAt, workID: snap.workID, intentionallyUnattached: snap.intentionallyUnattached,- citations: citations.rewritingVersions(rewrites) ?? citations,+ citations: citations, // Req 9.4: the covered revision rides on the record whose text it // describes, read from the carrier — the row the group presents. characterExtractionFingerprint: carrier.characterExtractionFingerprint@@ -618,11 +607,11 @@ extension LibraryRepository { /// pointer to an entry the library does not hold exports verbatim with /// `typeName: nil` (Q24) — refusing there would fail an export at exactly /// the moment sync has not settled.- internal static func mapV7WorkRecord(+ internal static func mapV8WorkRecord( _ group: WorkGroup, canonicalWorkIDs: [UUID: UUID], types: WorkTypeDirectory- ) throws -> BackupV7Work {+ ) throws -> BackupV8Work { let snap = try snapshot(group, canonicalWorkIDs: canonicalWorkIDs, types: types) let assignment = WorkTypeAssignment.assignment(of: group.carrier) let workTypeID: UUID?@@ -633,7 +622,7 @@ extension LibraryRepository { case .configured(let id): (workTypeID, typeName) = (id, types.resolve(id)?.name) }- return BackupV7Work(+ return BackupV8Work( id: snap.id, displayTitle: snap.displayTitle, lastParsedTitle: snap.lastParsedTitle,@@ -664,7 +653,7 @@ extension LibraryRepository { /// there. private static func mapMembershipRecords( _ rows: [WorkSiteMembership]- ) -> [BackupV7Membership] {+ ) -> [BackupV8Membership] { var byKey: [MembershipReconciler.Key: [WorkSiteMembership]] = [:] var unattributed: [WorkSiteMembership] = [] for row in rows {@@ -678,7 +667,7 @@ extension LibraryRepository { let folded = byKey.values.compactMap { MembershipReconciler.survivorFirst($0).first } return (folded + unattributed) .map {- BackupV7Membership(+ BackupV8Membership( id: $0.id, workID: $0.resolvedWorkID, hostname: $0.hostname, createdAt: $0.createdAt, urlIdentity: $0.urlIdentity, urlIdentityState: $0.urlIdentityState,@@ -701,7 +690,7 @@ extension LibraryRepository { /// nothing: a Work is not distinct from itself. internal static func projectDistinctPairs( context: ModelContext- ) throws -> [BackupV7DistinctPair] {+ ) throws -> [BackupV8DistinctPair] { var byKey: [WorkPairKey: [WorkDistinctPair]] = [:] for row in try context.fetch(FetchDescriptor<WorkDistinctPair>()) where row.lowerWorkID != row.higherWorkID {@@ -713,7 +702,7 @@ extension LibraryRepository { guard let survivor = MembershipReconciler.survivorFirstPairs(rows).first else { return nil }- return BackupV7DistinctPair(+ return BackupV8DistinctPair( id: survivor.id, lowerWorkID: key.lower, higherWorkID: key.higher, recordedAt: survivor.recordedAt) }@@ -723,10 +712,10 @@ extension LibraryRepository { /// The wire Site for a hostname: exactly one, whatever the store holds /// (Q38). It names no children (Req 9.3) — the union still decides which /// rules are archived, and each of them names this hostname back.- internal static func mapV7SiteRecord(+ internal static func mapV8SiteRecord( _ projected: SiteUnionProjection.ProjectedSite- ) -> BackupV7Site {- BackupV7Site(+ ) -> BackupV8Site {+ BackupV8Site( hostname: projected.hostname, displayName: projected.displayName, mode: projected.mode,@@ -734,13 +723,15 @@ extension LibraryRepository { ) } - internal static func mapV7TitlePatternRecord(+ internal static func mapV8TitlePatternRecord( _ projected: SiteUnionProjection.ProjectedTitlePattern, hostname: String- ) throws -> BackupV7TitlePattern {- BackupV7TitlePattern(+ ) throws -> BackupV8TitlePattern {+ BackupV8TitlePattern( id: projected.pattern.id, siteHostname: hostname,- version: projected.version,+ // The version stored on the row the per-UUID reduction kept, without+ // renumbering (Req 3.8).+ version: projected.pattern.version, isActive: projected.isActive, createdAt: projected.pattern.createdAt, definition: try projected.pattern.storedDefinition@@ -750,19 +741,19 @@ extension LibraryRepository { /// `throws` because the definition does (Req 4.5): the mapper needs a typed /// `URLRuleDefinition` for every row it writes, so a row that will not /// decode cannot be archived at all.- internal static func mapV7URLRuleRecord(+ internal static func mapV8URLRuleRecord( _ projected: SiteUnionProjection.ProjectedURLRule, hostname: String- ) throws -> BackupV7URLRule {+ ) throws -> BackupV8URLRule { let definition: URLRuleDefinition do { definition = try projected.rule.definition } catch {- throw BackupV7ExportError.unrepresentableValue(+ throw BackupV8ExportError.unrepresentableValue( record: "URL rule \(projected.rule.id)", field: "definition", value: "\(projected.rule.definitionData.count) bytes that do not decode") }- return BackupV7URLRule(+ return BackupV8URLRule( id: projected.rule.id,- version: projected.version,+ version: projected.rule.version, isCurrent: projected.isCurrent, createdAt: projected.rule.createdAt, // `requireRepresentableValues` already refused an unknown origin, so
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swiftindex 6c386ab..1718cdc 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift@@ -24,13 +24,13 @@ internal enum BackupArchiveReferenceChecks { /// refusal — the one message here that has to say which archive format it /// is talking about. static func validate(- entries: [BackupV7Entry],- works: [BackupV7Work],- memberships: [BackupV7Membership],- distinctPairs: [BackupV7DistinctPair],- sites: [BackupV7Site],- titlePatterns: [BackupV7TitlePattern],- urlRules: [BackupV7URLRule],+ entries: [BackupV8Entry],+ works: [BackupV8Work],+ memberships: [BackupV8Membership],+ distinctPairs: [BackupV8DistinctPair],+ sites: [BackupV8Site],+ titlePatterns: [BackupV8TitlePattern],+ urlRules: [BackupV8URLRule], formatLabel: String ) throws { let siteHostnames = Set(sites.map(\.hostname))@@ -138,25 +138,23 @@ internal enum BackupArchiveReferenceChecks { // MARK: Site closed tuple (supersedes M3 8.1) private static func validateSiteTuple(- _ site: BackupV7Site,- patterns: [BackupV7TitlePattern],- rules: [BackupV7URLRule]+ _ site: BackupV8Site,+ patterns: [BackupV8TitlePattern],+ rules: [BackupV8URLRule] ) throws { let id = site.hostname guard !M2Unicode.isBlank(site.hostname) else { throw invalid("Site", id, "hostname is blank") } - var patternVersions: Set<Int> = []- for pattern in patterns {- guard pattern.version > 0, patternVersions.insert(pattern.version).inserted else {- throw invalid("Site", id, "title patterns require positive Site-unique versions")- }- }-- var ruleVersions: Set<Int> = []+ // **Site-unique versions, current-holds-the-greatest and positivity are+ // retired** (T-2281 Req 3.2, Q31), here for the same reason as in the+ // store validator: an archive's citations name UUIDs, so the row+ // versions are advisory and an archive taken from a library that was+ // taught twice must import. Positivity stays a model invariant on the+ // way in — `ArchiveRecordBuilders` constructs the rows through the model+ // inits, which refuse it (Q24) — but refusing the whole archive for it+ // here would strand a library nothing can repair. The+ // at-most-one-current rule (Req 3.3) is unaffected. for rule in rules {- guard rule.version > 0, ruleVersions.insert(rule.version).inserted else {- throw invalid("Site", id, "URL rules require positive Site-unique versions")- } do { try rule.definition.validate(origin: rule.origin, isCurrent: rule.isCurrent) } catch { throw invalid("URLRule", rule.id.uuidString, String(describing: error)) } }@@ -165,9 +163,6 @@ internal enum BackupArchiveReferenceChecks { guard currentRules.count <= 1 else { throw invalid("Site", id, "a Site holds at most one current URL rule") }- if let current = currentRules.first, current.version != rules.map(\.version).max() {- throw invalid("Site", id, "current URL rule must have the greatest retained version")- } let activePatternCount = patterns.count(where: \.isActive) switch site.mode {@@ -210,9 +205,9 @@ internal enum BackupArchiveReferenceChecks { /// (Req 9.5, Q22); its own tuple is still checked, because an orphan is a /// row like any other. private static func validateMembership(- _ membership: BackupV7Membership,+ _ membership: BackupV8Membership, siteHostnames: Set<String>,- rulesByID: [UUID: BackupV7URLRule]+ rulesByID: [UUID: BackupV8URLRule] ) throws { let id = membership.id.uuidString guard !M2Unicode.isBlank(membership.hostname) else {@@ -241,12 +236,12 @@ internal enum BackupArchiveReferenceChecks { // MARK: Entry (Entry-state enumeration, supersedes M3 8.12) private static func validateEntry(- _ entry: BackupV7Entry,+ _ entry: BackupV8Entry, siteHostnames: Set<String>, workIDs: Set<UUID>, hostnamesByWork: [UUID: Set<String>],- patternsByID: [UUID: BackupV7TitlePattern],- rulesByID: [UUID: BackupV7URLRule]+ patternsByID: [UUID: BackupV8TitlePattern],+ rulesByID: [UUID: BackupV8URLRule] ) throws { let id = entry.id.uuidString guard siteHostnames.contains(entry.hostname) else {@@ -308,7 +303,6 @@ internal enum BackupArchiveReferenceChecks { guard entry.urlWorkIdentity == nil, entry.chapterSequence != nil, let nameTitle, let namePattern = patternsByID[nameTitle.id],- namePattern.version == nameTitle.version, namePattern.siteHostname == entry.hostname else { throw invalid( "Entry", id,@@ -320,23 +314,23 @@ internal enum BackupArchiveReferenceChecks { } /// The one rule both Entry rule-reference checks apply: the archive holds- /// the cited rule, at the cited version, taught for the Entry's own site.+ /// the cited rule, taught for the Entry's own site (Req 2.2). One record per+ /// UUID in an archive, so the lookup is the whole of it. /// /// Only the *refusal* differs between the two callers — an identity basis /// that does not resolve is an invalid state tuple, and any other field's /// dangling citation is an unresolved reference — so the predicate is stated /// once and each caller names its own failure. private static func resolvesSameSite(- _ cited: CitedRule, entry: BackupV7Entry, rulesByID: [UUID: BackupV7URLRule]+ _ cited: CitedRule, entry: BackupV8Entry, rulesByID: [UUID: BackupV8URLRule] ) -> Bool {- guard let rule = rulesByID[cited.id] else { return false }- return rule.version == cited.version && rule.siteHostname == entry.hostname+ rulesByID[cited.id]?.siteHostname == entry.hostname } private static func requireSameSiteRule( _ cited: CitedRule,- entry: BackupV7Entry,- rulesByID: [UUID: BackupV7URLRule]+ entry: BackupV8Entry,+ rulesByID: [UUID: BackupV8URLRule] ) throws { guard resolvesSameSite(cited, entry: entry, rulesByID: rulesByID) else { throw invalid(@@ -346,10 +340,10 @@ internal enum BackupArchiveReferenceChecks { } private static func validateEntryRuleReference(- _ entry: BackupV7Entry,+ _ entry: BackupV8Entry, field: String, cited: CitedRule?,- rulesByID: [UUID: BackupV7URLRule]+ rulesByID: [UUID: BackupV8URLRule] ) throws { guard let cited else { return } guard resolvesSameSite(cited, entry: entry, rulesByID: rulesByID) else {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swiftindex 0b8d506..85d1ada 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift@@ -1,7 +1,7 @@ import Foundation /// The file a completed backup export produced, handed to the share sheet and-/// cleaned up afterwards. `BackupV7Exporter` is the only producer.+/// cleaned up afterwards. `BackupV8Exporter` is the only producer. public struct BackupExportResult: Sendable { public let fileURL: URL
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swiftindex faf8802..f3d00d4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift@@ -72,7 +72,7 @@ enum BackupGroupProjection { let characters: [CharacterGroup] } - /// - Throws: `BackupV7ExportError.tornGroups` when the store holds a torn+ /// - Throws: `BackupV8ExportError.tornGroups` when the store holds a torn /// group — the biconditional of Req 8.1, since nothing else here refuses. /// - Parameter distinctPairs: the reader's recorded "not the same work" /// dismissals (Req 5.6). **Not defaulted**: an export that cannot see them@@ -103,7 +103,7 @@ enum BackupGroupProjection { // no site at all and a torn character would export one variant silently. let tornCharacters = characterGroups.values.filter(\.isTorn) guard tornEntries.isEmpty, tornWorks.isEmpty, tornCharacters.isEmpty else {- throw BackupV7ExportError.tornGroups(+ throw BackupV8ExportError.tornGroups( tornGroupsPayload( tornEntries: tornEntries, tornWorks: tornWorks, tornCharacters: tornCharacters,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swiftindex f7216f5..0b1a12d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift@@ -1,7 +1,7 @@ import Foundation import SwiftData -// The character half of a 7/8 import (`character-extraction` Req 6.1).+// The character half of an archive import (`character-extraction` Req 6.1). // // **Additive, UUID-keyed, and never a deletion** — the upsert's posture, applied // to the three arrays this generation adds:@@ -116,7 +116,7 @@ extension LibraryRepository { /// `nameKey` travels rather than being re-derived: it is retained through /// renames (Q19/Q46), and recomputing it from `name` would silently re-key /// every character an archive restored.- internal static func apply(_ record: BackupV7Character, to character: CharacterRecord) {+ internal static func apply(_ record: BackupV8Character, to character: CharacterRecord) { character.name = record.name character.nameKey = record.nameKey character.aliases = record.aliases@@ -126,7 +126,7 @@ extension LibraryRepository { character.modifiedAt = record.modifiedAt } - internal static func apply(_ record: BackupV7Suppression, to row: CharacterSuppression) {+ internal static func apply(_ record: BackupV8Suppression, to row: CharacterSuppression) { row.kindRaw = record.kindRaw row.nameKey = record.nameKey row.sourceKindRaw = record.sourceKindRaw
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swiftindex 24bc543..e74caf8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift@@ -31,8 +31,8 @@ extension LibraryRepository { /// restore of an entry this library already held. That one write is the /// reader acting now, and it has to assert over a removal older than it. internal static func mergeImportedWorkTypes(- workTypes: [BackupV7WorkType],- works: [BackupV7Work],+ workTypes: [BackupV8WorkType],+ works: [BackupV8Work], exportedAt: Date, importedAt: Date, context: ModelContext,@@ -171,7 +171,7 @@ extension LibraryRepository { /// Returned in identifier order, so an interrupted import resumes into the /// same shape on any device. private static func archivedTypeIdentities(- _ records: [BackupV7WorkType]+ _ records: [BackupV8WorkType] ) -> [ArchivedTypeIdentity] { let directory = WorkTypeDirectory( rows: records.map {@@ -266,7 +266,7 @@ extension LibraryRepository { /// A citation with no snapshot is deliberately absent: it stays on the work /// as unresolved rather than being invented a name (Q24). private static func unresolvedTypeCitations(- _ works: [BackupV7Work], in local: WorkTypeDirectory+ _ works: [BackupV8Work], in local: WorkTypeDirectory ) -> [ArchivedTypeCitation] { var seen: Set<UUID> = [] var citations: [ArchivedTypeCitation] = []
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swiftindex 6a82468..7cf012c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift@@ -11,33 +11,33 @@ import OSLog /// longer exist and every accessor answered the same arm three times. What /// remains is the payload's arrays, named. ///-/// This is `BackupV7Payload`'s content rather than the type itself: the wire+/// This is `BackupV8Payload`'s content rather than the type itself: the wire /// struct is a `Codable` frozen shape and the plan is what the commit reads, and /// keeping them separate is what lets a future generation arrive without the /// upsert learning its envelope. public struct BackupImportPayload: Sendable, Equatable {- public let entries: [BackupV7Entry]- public let works: [BackupV7Work]- public let sites: [BackupV7Site]- public let titlePatterns: [BackupV7TitlePattern]- public let urlRules: [BackupV7URLRule]- public let workTypes: [BackupV7WorkType]- public let memberships: [BackupV7Membership]- public let distinctPairs: [BackupV7DistinctPair]- public let characters: [BackupV7Character]- public let suppressions: [BackupV7Suppression]+ public let entries: [BackupV8Entry]+ public let works: [BackupV8Work]+ public let sites: [BackupV8Site]+ public let titlePatterns: [BackupV8TitlePattern]+ public let urlRules: [BackupV8URLRule]+ public let workTypes: [BackupV8WorkType]+ public let memberships: [BackupV8Membership]+ public let distinctPairs: [BackupV8DistinctPair]+ public let characters: [BackupV8Character]+ public let suppressions: [BackupV8Suppression] public init(- entries: [BackupV7Entry],- works: [BackupV7Work],- sites: [BackupV7Site],- titlePatterns: [BackupV7TitlePattern],- urlRules: [BackupV7URLRule],- workTypes: [BackupV7WorkType] = [],- memberships: [BackupV7Membership] = [],- distinctPairs: [BackupV7DistinctPair] = [],- characters: [BackupV7Character] = [],- suppressions: [BackupV7Suppression] = []+ entries: [BackupV8Entry],+ works: [BackupV8Work],+ sites: [BackupV8Site],+ titlePatterns: [BackupV8TitlePattern],+ urlRules: [BackupV8URLRule],+ workTypes: [BackupV8WorkType] = [],+ memberships: [BackupV8Membership] = [],+ distinctPairs: [BackupV8DistinctPair] = [],+ characters: [BackupV8Character] = [],+ suppressions: [BackupV8Suppression] = [] ) { self.entries = entries self.works = works@@ -55,7 +55,7 @@ public struct BackupImportPayload: Sendable, Equatable { self.suppressions = suppressions } - public init(_ payload: BackupV7Payload) {+ public init(_ payload: BackupV8Payload) { self.init( entries: payload.entries, works: payload.works, sites: payload.sites, titlePatterns: payload.titlePatterns, urlRules: payload.urlRules,@@ -65,9 +65,9 @@ public struct BackupImportPayload: Sendable, Equatable { } /// Whether any record carries a character-extraction coverage fingerprint.- /// The 6/7 payload had a coverage array to test for emptiness; 7/8 carries- /// the fingerprints on the records that own them (Req 9.4), so the question- /// is asked of those.+ /// The 6/7 payload had a coverage array to test for emptiness; every+ /// generation since carries the fingerprints on the records that own them,+ /// so the question is asked of those. var carriesCoverage: Bool { entries.contains { $0.characterExtractionFingerprint != nil } || works.contains { $0.genericNotesExtractionFingerprint != nil }@@ -82,11 +82,11 @@ public struct BackupImportPayload: Sendable, Equatable { /// process lease. Represents a complete validated prospective graph ready to be /// materialized atomically. ///-/// One source version is accepted, `7/8`. Every earlier generation's read path-/// has been retired in turn, `6/7` included (`multi-site-works` Decision 2): a-/// Work's site presence is a set of membership rows now, and there is no honest-/// 6/7 record for a two-site Work. Recovering an older archive means checking-/// out a build that still carries its codec.+/// One source version is accepted, `8/9`. Every earlier generation's read path+/// has been retired in turn, `7/8` included (Q14): an Entry citation is the+/// cited rule's UUID now, and a 7/8 record pins it at `(id, version)` — a pair+/// nothing here resolves. Recovering an older archive means a build that still+/// carries its codec. public struct BackupImportPlan: Sendable, Equatable { public let metadata: BackupImportMetadata public let payload: BackupImportPayload@@ -103,7 +103,7 @@ public struct BackupImportPlan: Sendable, Equatable { /// A plan over a wire payload, which is how every archive reaches one. public init(- metadata: BackupImportMetadata, payload: BackupV7Payload,+ metadata: BackupImportMetadata, payload: BackupV8Payload, counts: LibraryRecordCounts ) { self.init(@@ -183,16 +183,16 @@ public enum BackupImportError: Error, Equatable, Sendable, CustomStringConvertib /// repository actor and without a process lease. Never mutates the selected /// file. ///-/// Import supports exact native `7/8` and nothing else. Mixed pairs, older+/// Import supports exact native `8/9` and nothing else. Mixed pairs, older /// generations and future headers reject before repository mutation — which is-/// the same door a *pre-feature* build meets `(7, 8)` at, and why a 7/8 archive-/// cannot half-apply on one (Req 9.6).+/// the same door a *pre-feature* build meets `(8, 9)` at, and why an 8/9 archive+/// cannot half-apply on one (Req 5.2). public enum BackupImporter { private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "BackupImporter") /// The pair this app reads and writes. private static let supportedVersions = (- format: BackupV7Document.formatVersion, schema: BackupV7Document.schemaVersion+ format: BackupV8Document.formatVersion, schema: BackupV8Document.schemaVersion ) // MARK: - Plan Dispatch (Req 5.1, 5.2, Decision 2)@@ -223,9 +223,9 @@ public enum BackupImporter { } private static func planFromArchive(_ data: Data) throws -> BackupImportPlan {- let document: BackupV7Document+ let document: BackupV8Document do {- document = try BackupV7Codec.decode(data)+ document = try BackupV8Codec.decode(data) } catch { throw BackupImportError.decodingFailed(reason: String(describing: error)) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swiftindex b805941..a5a48a5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift@@ -1,7 +1,7 @@ import Foundation // Extracted from the retired `LegacyBackupV2Codec` when the 2/2 and 3/3 import-// paths were removed. Both helpers are used by the live `BackupV7Codec`: the+// paths were removed. Both helpers are used by the live `BackupV8Codec`: the // date formatter fixes the archive's timestamp encoding, and the duplicate-key // validator is what makes a decode strict rather than last-key-wins. //@@ -165,7 +165,7 @@ internal enum BackupArchiveDateFormatter { /// the live decode path: `BackupArchiveShapeValidator` goes through /// `JSONSerialization`, which collapses duplicates without complaint, so this is /// the only thing standing between a two-`payload` archive and importing the-/// wrong one. It also enforces no-trailing-bytes. `BackupV7ArchiveTests` covers+/// wrong one. It also enforces no-trailing-bytes. `BackupV8ArchiveTests` covers /// both properties by editing encoded bytes directly — they cannot be reached /// through any `JSONSerialization` round-trip. internal struct DuplicateJSONKeyValidator {
(diff fragment 'diff-Packages_AsterismCore_Sources_AsterismCore_{BackupV7Codec.swift => BackupV8Codec.swift}.txt' missing)
(diff fragment 'diff-Packages_AsterismCore_Sources_AsterismCore_{BackupV7Exporter.swift => BackupV8Exporter.swift}.txt' missing)
(diff fragment 'diff-Packages_AsterismCore_Sources_AsterismCore_{BackupV7Types.swift => BackupV8Types.swift}.txt' missing)
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift b/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swiftindex 9949a47..30efeaf 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift@@ -220,7 +220,7 @@ public enum CharacterCitationRepointing { /// row's `modifiedAt` backwards**. The reconciler derives it from the /// collapsing Entries rather than a clock (Q56), so it can easily be older /// than the character it rewrites — and `CharacterGroup.modifiedAt` is what- /// `BackupV7Character` carries as its import value guard, so a backwards+ /// `BackupV8Character` carries as its import value guard, so a backwards /// stamp would let an older archive overwrite a newer character. @discardableResult public static func repoint(
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swiftindex 323266c..ed826f6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift@@ -70,10 +70,12 @@ public struct URLRuleDecodeFailure: Error, Equatable, Sendable, CustomStringConv /// /// A plain `ComposedURLRuleBasis?` cannot express the third state, and folding /// it into `nil` is not free: `urlVersionProjection` answers `.available(1)` for-/// an absent rule, which for a Site already holding versions 1–4 would author a-/// library `V4LibraryValidator` refuses — versions are Site-unique and the-/// current rule must hold the greatest retained one. So `id` and `version` are-/// load-bearing here, not diagnostics.+/// an absent rule, and a Site already holding versions 1–4 would then re-teach+/// back to 1. No validator refuses that any more — Site-unique versions and+/// current-holds-the-greatest were retired with the citation version (T-2281,+/// Req 3.1) — but the column is still minted as `max + 1` (Q4), and a state that+/// dropped the retained version would break the minting rather than the+/// invariant. So `id` and `version` stay load-bearing here, not diagnostics. public enum ComposedURLRuleState: Sendable, Equatable { case none case readable(ComposedURLRuleBasis)@@ -442,7 +444,7 @@ public enum ComposedTeachingProjectionPlanner { let evidenceByWorkID: [UUID: WorkIdentityEvidence] if identityBearing, let urlDef = request.urlDefinition { let (projections, derivedIssues, evidence) = try deriveURLIdentity(- basis: basis, urlDefinition: urlDef, version: urlVersion?.committedVersion ?? 1)+ basis: basis, urlDefinition: urlDef) workProjections = projections issues = derivedIssues evidenceByWorkID = evidence@@ -549,9 +551,10 @@ public enum ComposedTeachingProjectionPlanner { return nextVersion(after: current.version) case .unreadable(_, let version, _, _): // Never `.unchanged` — there is no stored definition to compare the- // candidate against — and never `.available(1)`, which would collide- // with the versions the Site already retains. The retained version is- // the whole reason the state carries one.+ // candidate against — and never `.available(1)`, which would re-use+ // a version the Site already retains. Nothing refuses that shape now+ // (Req 3.1), but the column is minted as `max + 1` (Q4) and the+ // retained version is the whole reason the state carries one. return nextVersion(after: version) } }@@ -564,11 +567,14 @@ public enum ComposedTeachingProjectionPlanner { // MARK: - URL identity evidence private static func deriveURLIdentity(- basis: ComposedTeachingBasis, urlDefinition: URLRuleDefinition, version: Int+ basis: ComposedTeachingBasis, urlDefinition: URLRuleDefinition ) throws -> ([ComposedWorkProjection], [URLIdentityIssue], [UUID: WorkIdentityEvidence]) { let ruleID = basis.currentURLRule?.id ?? UUID(uuidString: "00000000-0000-0000-AAAA-000000000000")!+ // The prospective row is not in the store yet, so it is the newest rule+ // this Site has by construction (Q21); the value only feeds the basis+ // sort. let rule = try URLRuleBasisEntry(- id: ruleID, version: version, isCurrent: true,+ id: ruleID, createdAt: .distantFuture, isCurrent: true, origin: .readerTaught, definition: urlDefinition) let evidenceEntries = basis.entries.map { entry in URLEvidenceEntry(
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swiftindex 5dc0f5d..065a37e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift@@ -27,12 +27,9 @@ import SwiftData /// Public because `LibraryProviding` vends the pass and the app's models take /// the protocol, not the actor. public struct DuplicateReconciliationOutcome: Equatable, Sendable {- /// Rule rows whose definition, version or active flag the convergence- /// rewrote (Req 6.1).+ /// Rule rows whose definition or marked flag the convergence rewrote+ /// (Req 6.1). Versions are left as found since T-2281. public var convergedRuleRows = 0- /// Records whose rule citations were re-pointed at a surviving version- /// (Req 6.2).- public var rewrittenCitations = 0 /// Sets whose survivor rows this pass actually wrote. A set at its fixed /// point is not counted, because the value guard wrote nothing (Req 2.4). public var contentWrites = 0@@ -57,7 +54,7 @@ public struct DuplicateReconciliationOutcome: Equatable, Sendable { /// screen is built from changed, and the launch pass's refresh gate can /// skip its re-derivation. public var isEmpty: Bool {- convergedRuleRows == 0 && rewrittenCitations == 0 && contentWrites == 0+ convergedRuleRows == 0 && contentWrites == 0 && movedEntries == 0 && collapsedMembers == 0 && reviewSetKeys.isEmpty && blockedSetKeys.isEmpty }@@ -72,13 +69,12 @@ public struct DuplicateReconciliationOutcome: Equatable, Sendable { /// forever, and reporting it is not writing. Req 2.4's fixed point is about /// the writes. public var wroteNothing: Bool {- convergedRuleRows == 0 && rewrittenCitations == 0 && contentWrites == 0+ convergedRuleRows == 0 && contentWrites == 0 && movedEntries == 0 && collapsedMembers == 0 } mutating func formUnion(_ other: Self) { convergedRuleRows += other.convergedRuleRows- rewrittenCitations += other.rewrittenCitations contentWrites += other.contentWrites movedEntries += other.movedEntries collapsedMembers += other.collapsedMembers@@ -240,7 +236,7 @@ enum DuplicateReconciler { + scan.characterSets.map(\.key))) result.outcome.formUnion(- try convergeRules(scan, batchSize: batchSize, context: context, saveStrategy: saveStrategy))+ try convergeRules(scan, context: context, saveStrategy: saveStrategy)) let workPhase = try resolveWorkSets( scan.workSets, ledger: &ledger, batchSize: batchSize,@@ -326,18 +322,20 @@ enum DuplicateReconciler { /// doubles as Req 6.1's selector: absence sorts last in the canonical /// encoding, so the more-specified definition represents (Q68). Every write /// is value-guarded, so a converged group dirties nothing on the next pass.+ ///+ /// Two things this used to do are gone with T-2281 (Req 3.5): it pulled the+ /// group's rows onto one `version`, and it walked every Entry afterwards to+ /// re-point the citations that alignment had broken. A citation is the+ /// rule's UUID, so the rows may hold whatever versions they were minted at+ /// and nothing that cites them has to move. private static func convergeRules( _ scan: DuplicateScanResult,- batchSize: Int, context: ModelContext, saveStrategy: any RepositorySaveStrategy ) throws -> DuplicateReconciliationOutcome { var outcome = DuplicateReconciliationOutcome() guard !scan.titleRuleSets.isEmpty || !scan.urlRuleSets.isEmpty else { return outcome } - // Rule ids are unique per rule, so one map serves both types and both- // citation shapes (Req 6.2).- var rewrites: [UUID: Int] = [:] var dirty = false if !scan.titleRuleSets.isEmpty {@@ -346,10 +344,9 @@ enum DuplicateReconciler { guard let id = set.key.memberIDs.first, let rows = byID[id], rows.count > 1 else { continue }- let group = try convergePatternGroup(rows)- outcome.convergedRuleRows += group.rewritten- if group.rewritten > 0 { dirty = true }- if let version = group.rewrittenVersion { rewrites[id] = version }+ let rewritten = try convergePatternGroup(rows)+ outcome.convergedRuleRows += rewritten+ if rewritten > 0 { dirty = true } } } @@ -360,16 +357,13 @@ enum DuplicateReconciler { guard let id = set.key.memberIDs.first, let rows = byID[id], rows.count > 1 else { continue }- let group = convergeURLRuleGroup(rows)- outcome.convergedRuleRows += group.rewritten- if group.rewritten > 0 { dirty = true }- if let version = group.rewrittenVersion { rewrites[id] = version }+ let rewritten = convergeURLRuleGroup(rows)+ outcome.convergedRuleRows += rewritten+ if rewritten > 0 { dirty = true } } } if dirty { try saveStrategy.save(context) }- outcome.rewrittenCitations = try rewriteCitations(- rewrites, batchSize: batchSize, context: context, saveStrategy: saveStrategy) return outcome } @@ -377,37 +371,30 @@ enum DuplicateReconciler { /// definition surface including the trims — `setImmutableDefinition` omitted /// those, so a group converged through it could still derive two different /// chapter titles (Q63).- private static func convergePatternGroup(- _ rows: [TitlePattern]- ) throws -> (rewritten: Int, rewrittenVersion: Int?) {+ private static func convergePatternGroup(_ rows: [TitlePattern]) throws -> Int { let ordered = GroupOrdering.sortedPatternRows(rows)- guard let representative = ordered.first else { return (0, nil) }+ guard let representative = ordered.first else { return 0 } // A row whose columns do not form a legal arm cannot be a convergence // target and must not be silently rewritten either: the validator // reports it, and this pass leaves it exactly as it found it. //- // Recorded rather than changed: this returns before the version- // alignment and the active-flag demotion too, so one malformed row —- // which is the *representative* only because absence sorts last, and a- // malformed definition often reads as absent — blocks the whole group's- // convergence, including the two repairs that do not depend on the- // definition at all. Falling back to the next legal row in representative- // order would narrow it, at the cost of a selector that is no longer- // "the least row" and a second rule to state. Left for the design owner.- guard let definition = try? representative.storedDefinition else { return (0, nil) }+ // Recorded rather than changed: this returns before the active-flag+ // demotion too, so one malformed row — which is the *representative*+ // only because absence sorts last, and a malformed definition often+ // reads as absent — blocks the whole group's convergence, including the+ // repair that does not depend on the definition at all. Falling back to+ // the next legal row in representative order would narrow it, at the+ // cost of a selector that is no longer "the least row" and a second rule+ // to state. Left for the design owner.+ guard let definition = try? representative.storedDefinition else { return 0 } var rewritten = 0 for row in ordered where row !== representative { if try row.applyStoredDefinition(definition) { rewritten += 1 } }- let version = alignVersions(- ordered, representative: representative,- version: { $0.version }, setVersion: { $0.version = $1 },- permitsWrite: patternVersionIsFree)- rewritten += version.rewritten rewritten += demoteWithinSites( ordered, isMarked: { $0.isActive }, demote: { $0.isActive = false })- return (rewritten, version.converged)+ return rewritten } /// The surviving definition is the representative row's, compared as a@@ -432,11 +419,9 @@ enum DuplicateReconciler { /// unreadable row makes the group non-converged either way. Re-teaching the /// site repairs it. Two rows that both fail to decode have no semantics to /// compare, so they compare by bytes as before.- private static func convergeURLRuleGroup(- _ rows: [URLRulePattern]- ) -> (rewritten: Int, rewrittenVersion: Int?) {+ private static func convergeURLRuleGroup(_ rows: [URLRulePattern]) -> Int { let ordered = GroupOrdering.sortedURLRuleRows(rows)- guard let representative = ordered.first else { return (0, nil) }+ guard let representative = ordered.first else { return 0 } var rewritten = 0 let representativeDefinition = try? representative.definition@@ -454,82 +439,19 @@ enum DuplicateReconciler { row.definitionData = representative.definitionData rewritten += 1 }- let version = alignVersions(- ordered, representative: representative,- version: { $0.version }, setVersion: { $0.version = $1 },- permitsWrite: urlRuleVersionIsFree)- rewritten += version.rewritten rewritten += demoteWithinSites( ordered, isMarked: { $0.isCurrent }, demote: { $0.isCurrent = false })- return (rewritten, version.converged)- }-- /// Aligns the group's versions on the representative's, wherever the write- /// leaves the owning Site row still validating (Decision 13).- ///- /// `LibraryValidator` requires each Site row's rule versions to be positive- /// and **Site-unique over all of that row's patterns** (`:507-517`) or URL rules- /// (`:528-538`) — not merely over this group's. Every new rule version gets a new- /// UUID (`+ComposedTeaching.swift:120`), so a Site row routinely holds- /// several rules at several versions, and a write that lands the group's row- /// on a version an unrelated rule on the same Site row already holds- /// manufactures a `.siteTuple`. A `.siteTuple` quarantines the hostname and- /// takes teaching off the capture path, which is a worse state than the- /// un-aligned versions it replaces: a pass that "converges" must not move a- /// Site row from valid to invalid.- ///- /// `permitsWrite` is therefore asked, per row, whether the target version is- /// still free on that row's Site — reading the Site's own membership, which- /// is the set the invariant is over. It reads live values, so two rows of the- /// group on one Site row can never both take the target: the second sees the- /// first sitting on it. That is Decision 13's "never within one Site row",- /// derived rather than special-cased.- ///- /// `converged` is non-nil only where every row ended on one version, which is- /// the condition under which a citation naming a losing version has nowhere- /// left to resolve and must be rewritten (Req 6.2).- private static func alignVersions<Rule: AnyObject>(- _ ordered: [Rule],- representative: Rule,- version: (Rule) -> Int,- setVersion: (Rule, Int) -> Void,- permitsWrite: (Rule, Int) -> Bool- ) -> (rewritten: Int, converged: Int?) {- var rewritten = 0- let target = version(representative)- for row in ordered where row !== representative {- guard version(row) != target, permitsWrite(row, target) else { continue }- setVersion(row, target)- rewritten += 1- }- let converged = Set(ordered.map(version)) == [target]- return (rewritten, rewritten > 0 && converged ? target : nil)- }-- /// Whether `target` is free on `row`'s Site row — over **every** pattern that- /// Site row holds, which is the membership `LibraryValidator:507-517` checks.- ///- /// An ownerless row belongs to no Site tuple, so it can threaten no- /// Site-uniqueness invariant and always aligns.- private static func patternVersionIsFree(_ row: TitlePattern, _ target: Int) -> Bool {- guard let site = row.site else { return true }- return !site.patternValues.contains { $0 !== row && $0.version == target }+ return rewritten } - /// The URL-rule counterpart, with the extra clause the validator carries for- /// this type: the current rule must hold the greatest retained version- /// (`LibraryValidator:558-567`). Lowering a current rule under a retained- /// one, or raising a retained one over the current, is the same class of harm- /// as a version collision — a validating hostname turned into a quarantined- /// one by a pass that was only supposed to converge definitions.- private static func urlRuleVersionIsFree(_ row: URLRulePattern, _ target: Int) -> Bool {- guard let site = row.site else { return true }- let others = site.urlRuleValues.filter { $0 !== row }- guard !others.contains(where: { $0.version == target }) else { return false }- if row.isCurrent { return others.allSatisfy { $0.version <= target } }- if let current = others.first(where: \.isCurrent) { return target <= current.version }- return true- }+ // `alignVersions`, `patternVersionIsFree` and `urlRuleVersionIsFree` stood+ // here. They pulled a group's rows onto the representative's version so a+ // citation pinning `(id, version)` still resolved after convergence, and+ // the two predicates existed to refuse the write wherever it would have+ // manufactured a `.siteTuple` out of the Site-unique or greatest-version+ // invariant (Decision 13). T-2281 retires both invariants and the citation+ // no longer carries a version, so the alignment has nothing left to buy and+ // the rows keep the versions they were minted at (Req 3.5). /// Demotes duplicate active/current flags **within each owning Site row**, /// keeping the first in representative order and never activating anything.@@ -569,35 +491,12 @@ enum DuplicateReconciler { return demoted } - /// Re-points every citation naming a rewritten rule at the version the group- /// converged on (Req 6.2), reusing the Site reconciler's citation machinery.- ///- /// One walk for every rule group at once, and only when a version actually- /// moved: rule groups are rare, and a walk per group would charge the- /// library once each.- private static func rewriteCitations(- _ rewrites: [UUID: Int],- batchSize: Int,- context: ModelContext,- saveStrategy: any RepositorySaveStrategy- ) throws -> Int {- guard !rewrites.isEmpty else { return 0 }- var rewritten = 0- for chunk in LibraryRepository.chunks(- of: try context.fetch(FetchDescriptor<Entry>()), size: batchSize) {- var dirty = false- for entry in chunk where SiteReconciler.rewriteCitations(of: entry, rewrites) {- rewritten += 1- dirty = true- }- if dirty { try saveStrategy.save(context) }- }- // There is no Work half (task 21): a `WorkSiteMembership` cites its- // identity rule by UUID alone (Req 10.4, Q28), and neither the validator- // nor the 7/8 archive reads `Work.urlIdentityRuleVersion` any more, so a- // renumbering has nothing to move there.- return rewritten- }+ // `rewriteCitations` stood here: it re-pointed every citation naming a+ // rewritten rule at the version the group converged on, reusing the Site+ // reconciler's walk. Nothing cites a version since T-2281, and the Work half+ // never did (a `WorkSiteMembership` cites by UUID alone, Req 10.4/Q28), so+ // the whole pass is deleted rather than kept as a full-table fetch that+ // returns zero. // MARK: - Req 5.1: Work sets
diff --git a/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift b/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swiftindex 7d64d9e..30d7a06 100644--- a/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift@@ -1,24 +1,24 @@ import Foundation -/// One `(rule id, rule version)` pair a stored record cites.+/// The rule a stored record cites, as its UUID. ///-/// The version travels with the id because rule rows are versioned and a-/// citation names the version it replayed against; `WorkSiteMembership` is the-/// deliberate exception (Req 10.4, Q28), which is why it carries a bare UUID.+/// It used to be an `(id, version)` pair, and keeping the pair resolvable is+/// what forced every Site to hold unique, ordered rule versions. The version+/// carried no information the UUID does not — re-teaching mints both together,+/// recalculation reuses both — so T-2281 dropped it along with the invariant and+/// the renumbering machinery that repaired it. A blob a previous build wrote+/// still carries the key; the synthesized decoder ignores it (Q3). public struct CitedRule: Codable, Hashable, Sendable { public var id: UUID- public var version: Int - public init(id: UUID, version: Int) {+ public init(id: UUID) { self.id = id- self.version = version } - /// The pair, or nil where either half is absent — the shape the seventeen- /// legacy columns store a citation in.- public init?(id: UUID?, version: Int?) {- guard let id, let version else { return nil }- self.init(id: id, version: version)+ /// The citation, or nil where there is none.+ public init?(id: UUID?) {+ guard let id else { return nil }+ self.init(id: id) } } @@ -30,8 +30,10 @@ public struct CitedRule: Codable, Hashable, Sendable { /// * The identity *basis version* becomes a **case** rather than an integer /// (Q26). `LibraryValidator` and `BackupArchiveReferenceChecks` switched on /// `identityKeyVersion` to choose an arm; a typed case is that switch.-/// * Every citation is a `CitedRule?`, so "cited at a version" is one shape-/// instead of two columns that can disagree.+/// * Every citation is a `CitedRule?`, so "cites a rule" is one shape instead+/// of the two columns per citation that could disagree. V8 folded an `id` and+/// a `version` column into the pair; T-2281 dropped the version, leaving the+/// UUID. /// /// It is a Codable value in an opaque column, so the wire-format note applies: a /// case added to either enum below in a later version makes builds without it@@ -170,7 +172,7 @@ public struct EntryCitations: Codable, Equatable, Sendable { Entry.RuleCitation( cited: chapterSequence, target: .urlRule, label: "its sequence rule"), Entry.RuleCitation(- cited: CitedRule(id: chapterTitle.patternID, version: chapterTitle.patternVersion),+ cited: CitedRule(id: chapterTitle.patternID), target: .titlePattern, label: "its chapter rule"), Entry.RuleCitation( cited: workAssignment.patternRule, target: .titlePattern, label: "its work rule"),@@ -179,61 +181,17 @@ public struct EntryCitations: Codable, Equatable, Sendable { ] } - /// The same value with every cited version replaced by the one `rewrites`- /// names for that rule id, or nil when nothing moves.- ///- /// Rule UUIDs are unique, so the new version is a lookup: this is how a rule- /// group that converged on one version keeps every citation replaying- /// (Req 6.2 of `relational-references`, Q63).- public func rewritingVersions(_ rewrites: [UUID: Int]) -> EntryCitations? {- guard !rewrites.isEmpty else { return nil }- var changed = false- func rewrite(_ cited: CitedRule?) -> CitedRule? {- guard let cited, let replacement = rewrites[cited.id],- replacement != cited.version else { return cited }- changed = true- return CitedRule(id: cited.id, version: replacement)- }-- var copy = self- switch identity {- case .rawURL:- break- case .rule(let url, let name):- if let rewritten = rewrite(url) {- copy.identity = .rule(url: rewritten, nameTitle: rewrite(name))- }- case .composed(let url, let name):- if let rewritten = rewrite(url) {- copy.identity = .composed(url: rewritten, nameTitle: rewrite(name))- }- }- copy.urlWork = rewrite(urlWork)- copy.chapterSequence = rewrite(chapterSequence)- if let rewritten = rewrite(- CitedRule(id: chapterTitle.patternID, version: chapterTitle.patternVersion)),- rewritten.version != chapterTitle.patternVersion {- copy.chapterTitle = FieldProvenance.tolerant(- kind: chapterTitle.kind, patternID: rewritten.id, patternVersion: rewritten.version)- }- switch workAssignment {- case .none, .manual:- break- case .pattern(let rule):- if let rewritten = rewrite(rule) { copy.workAssignment = .pattern(rewritten) }- case .urlRule(let rule):- if let rewritten = rewrite(rule) { copy.workAssignment = .urlRule(rewritten) }- }- copy.workURL = rewrite(workURL)- return changed ? copy : nil- }+ // `rewritingVersions` stood here: the walk that replaced every cited version+ // with the one a converged rule group had settled on. A citation is a UUID+ // now (T-2281), so a rule group converging moves nothing a citation reads and+ // there is nothing to rewrite. } extension Entry { /// One citation row, as every pass that walks them sees it. /// /// It used to be a table of key paths into the model and into- /// `BackupV7Entry`, hand-enumerated nowhere else. With the citations folded+ /// `BackupV8Entry`, hand-enumerated nowhere else. With the citations folded /// into one blob the key paths have nothing to point at, so the row is a /// *value* now — but the seven rows, their order and their labels are /// unchanged, because `citerHostnames` still keeps the first hostname it@@ -252,7 +210,6 @@ extension Entry { public let label: String public var id: UUID? { cited?.id }- public var version: Int? { cited?.version } public init(cited: CitedRule?, target: Target, label: String) { self.cited = cited@@ -278,27 +235,24 @@ extension FieldProvenance { /// `FieldProvenance`'s initializer throws. public static let manual = try! FieldProvenance(kind: .manual) - /// A provenance built from a stored `(kind, patternID, patternVersion)`- /// triple that may not be legal.+ /// A provenance built from a stored `(kind, patternID)` pair that may not be+ /// legal. /// /// The struct's initializer enforces the pairing and throws otherwise, which- /// is right for a *write*. A read of columns a lagging writer or a partial+ /// is right for a *write*. A read of a value a lagging writer or a partial /// sync produced has no such option: the pairing is what it is, and the- /// blob has to carry it rather than refuse it. So an illegal triple keeps+ /// blob has to carry it rather than refuse it. So an illegal pair keeps /// its citation where there is one, and degrades to the bare kind where /// there is not. public static func tolerant(- kind: FieldProvenanceKind, patternID: UUID?, patternVersion: Int?+ kind: FieldProvenanceKind, patternID: UUID? ) -> FieldProvenance {- if let exact = try? FieldProvenance(- kind: kind, patternID: patternID, patternVersion: patternVersion) {+ if let exact = try? FieldProvenance(kind: kind, patternID: patternID) { return exact }- if let patternID, let patternVersion, patternVersion > 0,- let cited = try? FieldProvenance(- kind: .pattern, patternID: patternID, patternVersion: patternVersion) {- return cited- }+ // `.pattern` with a citation is the one pairing the initializer always+ // accepts, so this arm cannot throw.+ if let patternID { return try! FieldProvenance(kind: .pattern, patternID: patternID) } return (try? FieldProvenance(kind: kind)) ?? .absent } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftindex f3ec6d2..877946f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -255,7 +255,7 @@ public protocol LibraryProviding: Sendable { func projectInitialTeaching(hostname: String, patternDefinition: PatternDefinition) async throws -> TeachingContract /// Project a replacement teaching operation. Returns a contract for approval.- func projectReplacement(hostname: String, patternDefinition: PatternDefinition, boundaryEntryID: UUID, historicalPatternID: UUID?, historicalPatternVersion: Int?) async throws -> TeachingContract+ func projectReplacement(hostname: String, patternDefinition: PatternDefinition, boundaryEntryID: UUID, historicalPatternID: UUID?) async throws -> TeachingContract /// Commit an approved teaching contract. Returns `.committed` on success or /// `.refreshed(freshContract)` if any basis/outcome value changed.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swiftindex 1912d90..00d9958 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift@@ -206,7 +206,7 @@ extension LibraryRepository { var candidates: [String] = [rawURL] if let site = try Self.fetchSites(hostname: hostname, context: context).first,- let currentRule = site.urlRuleValues.first(where: { $0.isCurrent }),+ let currentRule = site.currentURLRule, // `try?`: a rule whose stored bytes will not decode is not // applied, and the lookup falls back to the conservative alias // alone. Capture must never fail because of a rule (Req 4.4) —@@ -226,7 +226,7 @@ extension LibraryRepository { // v3 candidate: only when a capture title is supplied, so the // sequence-only key can embed the title-derived Work name (Q21). if let captureTitle,- let activePattern = site.patternValues.first(where: \.isActive),+ let activePattern = site.activePattern, let stored = try? activePattern.storedDefinition { let titleRule = ComposedTitleRule(stored) let derivation = ComposedDeriver.derive(
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swiftindex d0ecc19..4941a08 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift@@ -8,7 +8,7 @@ import SwiftData public enum ComposedTeachingCommitOutcome: Equatable, Sendable { /// The commit succeeded with the resolved title-rule identity and, when the /// Site holds a URL rule, its identity.- case committed(titleRuleID: UUID, titleRuleVersion: Int, urlRuleID: UUID?, urlRuleVersion: Int?)+ case committed(titleRuleID: UUID, urlRuleID: UUID?) /// The basis or outcome changed since approval; a fresh contract is returned /// for re-approval with zero writes. case refreshed(ComposedTeachingContract)@@ -127,7 +127,7 @@ extension LibraryRepository { let titleIsNew: Bool switch currentOutcome.titleVersion { case .unchanged(let v):- guard let active = site.patternValues.first(where: \.isActive) else {+ guard let active = site.activePattern else { return .invalidated(reason: "no active title rule to preserve") } titleRuleID = active.id@@ -145,7 +145,7 @@ extension LibraryRepository { if let urlDef = contract.request.urlDefinition, let urlProjection = currentOutcome.urlVersion { switch urlProjection { case .unchanged(let v):- guard let current = site.urlRuleValues.first(where: \.isCurrent) else {+ guard let current = site.currentURLRule else { return .invalidated(reason: "no current URL rule to preserve") } resolvedURL = (current.id, v, urlDef, false)@@ -187,11 +187,11 @@ extension LibraryRepository { // 6. Apply the composed outcome to Entries and Works. try Self.applyComposedOutcome( context: context, hostname: hostname, site: site, outcome: currentOutcome,- titleRuleID: titleRuleID, titleVersion: titleVersion,+ titleRuleID: titleRuleID, titleDefinition: contract.request.titleDefinition, titleTrimPrefix: Self.nonEmpty(contract.request.trimPrefix), titleTrimSuffix: Self.nonEmpty(contract.request.trimSuffix),- url: resolvedURL.map { ($0.id, $0.version, $0.definition) }, timestamp: timestamp)+ url: resolvedURL.map { ($0.id, $0.definition) }, timestamp: timestamp) // 6b. The articles conversion's own fix-up (Decision 3). //@@ -265,8 +265,7 @@ extension LibraryRepository { composedLogger.debug("Committed composed teaching for \(hostname, privacy: .public)") return .committed(- titleRuleID: titleRuleID, titleRuleVersion: titleVersion,- urlRuleID: resolvedURL?.id, urlRuleVersion: resolvedURL?.version)+ titleRuleID: titleRuleID, urlRuleID: resolvedURL?.id) } } @@ -316,19 +315,19 @@ extension LibraryRepository { let sites = try Self.fetchSites(hostname: hostname, context: context) guard let site = sites.first else { return .invalidated(reason: "no Site for hostname '\(hostname)'") }- guard let activePattern = site.patternValues.first(where: \.isActive) else {+ guard let activePattern = site.activePattern else { return .invalidated(reason: "recalculation requires an active title rule") }- let currentURL = site.urlRuleValues.first(where: \.isCurrent)+ let currentURL = site.currentURLRule // `try?`, matching the basis: a recalculation over a Site whose // current rule will not decode reapplies the title rule alone rather // than refusing. The request the contract carries already has a nil // `urlDefinition` for the same reason, so the outcome holds no Work // projections and no Work identity is mutated (Req 4.4).- let resolvedURL: (id: UUID, version: Int, definition: URLRuleDefinition)?+ let resolvedURL: (id: UUID, definition: URLRuleDefinition)? if let currentURL, let definition = try? currentURL.definition {- resolvedURL = (currentURL.id, currentURL.version, definition)+ resolvedURL = (currentURL.id, definition) } else { resolvedURL = nil }@@ -338,7 +337,7 @@ extension LibraryRepository { // Detect whether reapplying the current rules changes any derived value. guard try Self.composedOutcomeChangesState( context: context, hostname: hostname, outcome: currentOutcome,- titleRuleID: activePattern.id, titleVersion: activePattern.version,+ titleRuleID: activePattern.id, titleDefinition: contract.request.titleDefinition, titleTrimPrefix: Self.nonEmpty(contract.request.trimPrefix), titleTrimSuffix: Self.nonEmpty(contract.request.trimSuffix),@@ -348,7 +347,7 @@ extension LibraryRepository { try Self.applyComposedOutcome( context: context, hostname: hostname, site: site, outcome: currentOutcome,- titleRuleID: activePattern.id, titleVersion: activePattern.version,+ titleRuleID: activePattern.id, titleDefinition: contract.request.titleDefinition, titleTrimPrefix: Self.nonEmpty(contract.request.trimPrefix), titleTrimSuffix: Self.nonEmpty(contract.request.trimSuffix),@@ -395,9 +394,9 @@ extension LibraryRepository { /// a logical record differs. private static func composedOutcomeChangesState( context: ModelContext, hostname: String, outcome: ComposedTeachingOutcome,- titleRuleID: UUID, titleVersion: Int, titleDefinition: PatternDefinition,+ titleRuleID: UUID, titleDefinition: PatternDefinition, titleTrimPrefix: String?, titleTrimSuffix: String?,- url: (id: UUID, version: Int, definition: URLRuleDefinition)?+ url: (id: UUID, definition: URLRuleDefinition)? ) throws -> Bool { if !outcome.prospectiveWorks.isEmpty { return true } let titleRule = ComposedTitleRule(@@ -503,7 +502,7 @@ extension LibraryRepository { let siteMode = try Self.requireKnownSiteMode(of: site, hostname: hostname) let currentTitleRule: ComposedTitleRuleBasis?- if let active = site.patternValues.first(where: \.isActive) {+ if let active = site.activePattern { let stored = try active.storedDefinition currentTitleRule = ComposedTitleRuleBasis( id: active.id, version: active.version, definition: stored.definition,@@ -519,7 +518,7 @@ extension LibraryRepository { // because the version projection has to offer the *next* version rather // than version 1. let urlRuleState: ComposedURLRuleState- if let current = site.urlRuleValues.first(where: \.isCurrent), let origin = current.origin {+ if let current = site.currentURLRule, let origin = current.origin { do { urlRuleState = .readable(ComposedURLRuleBasis( id: current.id, version: current.version, origin: origin,@@ -610,9 +609,9 @@ extension LibraryRepository { static func applyComposedOutcome( context: ModelContext, hostname: String, site: Site, outcome: ComposedTeachingOutcome,- titleRuleID: UUID, titleVersion: Int, titleDefinition: PatternDefinition,+ titleRuleID: UUID, titleDefinition: PatternDefinition, titleTrimPrefix: String?, titleTrimSuffix: String?,- url: (id: UUID, version: Int, definition: URLRuleDefinition)?, timestamp: Date+ url: (id: UUID, definition: URLRuleDefinition)?, timestamp: Date ) throws { let composedTitleRule = ComposedTitleRule( definition: titleDefinition, trimPrefix: titleTrimPrefix, trimSuffix: titleTrimSuffix)@@ -639,7 +638,7 @@ extension LibraryRepository { var minted: WorkURLIdentityMint? if case .urlIdentity(let identity) = intent.key, let url { minted = WorkURLIdentityMint(- value: identity.value, ruleID: url.id, ruleVersion: url.version)+ value: identity.value, ruleID: url.id) } let work = Work.createCarrying( in: context, title: intent.displayTitle.value, hostname: hostname, site: site,@@ -660,7 +659,7 @@ extension LibraryRepository { work.applyURLIdentity( url.map { WorkURLIdentityMint(- value: identity.value, ruleID: $0.id, ruleVersion: $0.version)+ value: identity.value, ruleID: $0.id) }, on: hostname) case .clear:@@ -685,12 +684,12 @@ extension LibraryRepository { entry.editCitations { citations in applyEntryIdentityAndChapter( to: entry, citations: &citations, derivation: derivation,- titleRuleID: titleRuleID, titleVersion: titleVersion, url: url)+ titleRuleID: titleRuleID, url: url) applyAssignment( to: entry, citations: &citations, assignment: projection.assignment, derivation: derivation, hostname: hostname,- titleRuleID: titleRuleID, titleVersion: titleVersion, url: url,+ titleRuleID: titleRuleID, url: url, createdByKey: createdByKey, workGroups: workGroups, timestamp: timestamp) } entry.modifiedAt = timestamp@@ -708,13 +707,13 @@ extension LibraryRepository { /// reads has to be consistent across every one of them. static func applyEntryIdentityAndChapter( to entry: Entry, citations: inout EntryCitations, derivation: ComposedDerivation,- titleRuleID: UUID, titleVersion: Int, url: (id: UUID, version: Int, definition: URLRuleDefinition)?+ titleRuleID: UUID, url: (id: UUID, definition: URLRuleDefinition)? ) { // The conservative-key alias is always the immutable raw URL (Q21). entry.conservativeIdentityKey = entry.rawURLString - let urlCited = url.flatMap { CitedRule(id: $0.id, version: $0.version) }- let titleCited = CitedRule(id: titleRuleID, version: titleVersion)+ let urlCited = url.map { CitedRule(id: $0.id) }+ let titleCited = CitedRule(id: titleRuleID) // One switch, not two: the stored key, the basis column and the cited // identity are three answers to the same question.@@ -755,15 +754,14 @@ extension LibraryRepository { citations.chapterTitle = derivation.chapterTitle == nil ? .absent : FieldProvenance.tolerant(- kind: .pattern, patternID: titleCited.id,- patternVersion: titleCited.version)+ kind: .pattern, patternID: titleCited.id) } private static func applyAssignment( to entry: Entry, citations: inout EntryCitations, assignment: ComposedAssignmentProjection, derivation: ComposedDerivation, hostname: String,- titleRuleID: UUID, titleVersion: Int, url: (id: UUID, version: Int, definition: URLRuleDefinition)?,+ titleRuleID: UUID, url: (id: UUID, definition: URLRuleDefinition)?, createdByKey: [ProspectiveWorkKey: Work], workGroups: [UUID: WorkGroup], timestamp: Date ) { switch assignment {@@ -783,7 +781,7 @@ extension LibraryRepository { // On this hostname's membership alone (Req 3.5). work.applyURLIdentity( WorkURLIdentityMint(- value: workIdentity.value, ruleID: url.id, ruleVersion: url.version),+ value: workIdentity.value, ruleID: url.id), on: hostname) } // Req 3.21: *every* reuse or claim refreshes the Work's parsed@@ -794,12 +792,12 @@ extension LibraryRepository { work.modifiedAt = timestamp } applyAssignmentProvenance(to: &citations, derivation: derivation,- titleRuleID: titleRuleID, titleVersion: titleVersion, url: url)+ titleRuleID: titleRuleID, url: url) case .create(let key): guard let work = createdByKey[key] else { break } entry.work = work applyAssignmentProvenance(to: &citations, derivation: derivation,- titleRuleID: titleRuleID, titleVersion: titleVersion, url: url)+ titleRuleID: titleRuleID, url: url) } } @@ -807,18 +805,18 @@ extension LibraryRepository { /// decoded so a row pays one decode and one encode for its whole write. static func applyAssignmentProvenance( to citations: inout EntryCitations, derivation: ComposedDerivation,- titleRuleID: UUID, titleVersion: Int, url: (id: UUID, version: Int, definition: URLRuleDefinition)?+ titleRuleID: UUID, url: (id: UUID, definition: URLRuleDefinition)? ) { if derivation.workIdentity != nil, let url { // Identity-based assignment (URL rule supplied the Work identity).- let cited = CitedRule(id: url.id, version: url.version)+ let cited = CitedRule(id: url.id) citations.workAssignment = .urlRule(cited) citations.workURL = cited citations.workURLAssignmentKind = .identity } else { // Title-based assignment (the composed name grouped the Work). citations.workAssignment = .pattern(- CitedRule(id: titleRuleID, version: titleVersion))+ CitedRule(id: titleRuleID)) citations.workURL = nil citations.workURLAssignmentKind = nil }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swiftindex cd6fd40..ba2ec88 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift@@ -182,7 +182,7 @@ extension LibraryRepository { // (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] = []+ var matchedRecords: [BackupV8Site] = [] for record in payload.sites { if sitesByHostname[record.hostname] != nil { matchedRecords.append(record)@@ -353,12 +353,12 @@ extension LibraryRepository { try saveStrategy.save(context) } - // (5) The rule merge. Archive rules joining existing ones collide on- // version and can leave two active title rules on one row, so the union- // renumbers deterministically and rewrites every citing record's- // `(id, version)` pair — the imported records and the ones already there- // alike (Decision 7). Reusing the reconciler is what keeps the imported- // shape and the reconciled shape the same shape.+ // (5) The rule merge. Archive rules joining existing ones can leave two+ // marked title rules on one row, and the union demotes all but the+ // Decision 1 one (Req 3.7). Nothing is renumbered and no citation is+ // rewritten: the rows keep the versions the archive recorded, and a+ // citation names a UUID. Reusing the reconciler is what keeps the+ // imported shape and the reconciled shape the same shape. // // Every hostname the archive touched, named once: the reconciler unions // its two lists, so handing it the same array twice said nothing the one@@ -422,7 +422,7 @@ extension LibraryRepository { /// 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,+ _ record: BackupV8Site, to site: Site, patterns: [TitlePattern], urlRules: [URLRulePattern]@@ -459,7 +459,7 @@ extension LibraryRepository { /// The Work half of the upsert. ///- /// A 7/8 Work record carries no site of its own (Req 9.1), so nothing here+ /// An archive Work record carries no site of its own, so nothing here /// touches the superseded columns or mints a membership: the membership /// records are their own step, and they are what the columns mirror /// afterwards.@@ -471,7 +471,7 @@ extension LibraryRepository { /// Work's site presence either, and a group skipped as torn must not be /// half-updated through its memberships. private static func commitWorks(- _ records: [BackupV7Work],+ _ records: [BackupV8Work], into workRows: inout [UUID: [Work]], types: WorkTypeDirectory, context: ModelContext,@@ -544,7 +544,7 @@ extension LibraryRepository { /// Inserting stays unconditional: a row the library does not hold cannot be /// regressed, and the upsert's posture is to add (Req 4.1). private static func commitMemberships(- _ records: [BackupV7Membership],+ _ records: [BackupV8Membership], workRows: [UUID: [Work]], workTargets: [UUID: Work], appliedWorkIDs: Set<UUID>,@@ -591,7 +591,7 @@ extension LibraryRepository { /// reconciler's latest-wins rule reads — so an older archive cannot undo a /// newer dismissal, and re-importing the same archive writes nothing. private static func commitDistinctPairs(- _ records: [BackupV7DistinctPair],+ _ records: [BackupV8DistinctPair], context: ModelContext, batchSize: Int, saveStrategy: any RepositorySaveStrategy@@ -622,7 +622,7 @@ extension LibraryRepository { /// The mutable half of an archive Work record, shared by the upsert and by /// the materializers so an inserted record and an updated one cannot drift /// apart. Identity, hostname, and `createdAt` are set at construction.- internal static func apply(_ record: BackupV7Work, to work: Work) {+ internal static func apply(_ record: BackupV8Work, to work: Work) { work.displayTitle = record.displayTitle work.lastParsedTitle = record.lastParsedTitle work.genericNotes = record.genericNotes@@ -636,7 +636,7 @@ extension LibraryRepository { /// The mutable half of an archive membership record. Identity, hostname and /// `createdAt` are set at construction, so what an update moves is the /// site-specific content: the URL identity triple and the confirmed Work URL.- internal static func apply(_ record: BackupV7Membership, to membership: WorkSiteMembership) {+ internal static func apply(_ record: BackupV8Membership, to membership: WorkSiteMembership) { membership.hostname = record.hostname membership.createdAt = record.createdAt membership.urlIdentity = record.urlIdentity@@ -646,7 +646,7 @@ extension LibraryRepository { membership.workID = record.workID ?? membership.workID } - internal static func apply(_ record: BackupV7Entry, to entry: Entry) {+ internal static func apply(_ record: BackupV8Entry, to entry: Entry) { entry.captureTitle = record.captureTitle entry.captureTitleSourceRaw = record.captureTitleSource.rawValue entry.rawURLString = record.rawURL@@ -664,7 +664,7 @@ extension LibraryRepository { entry.lastSharedAt = record.lastSharedAt entry.modifiedAt = record.modifiedAt entry.intentionallyUnattached = record.intentionallyUnattached- // A 7/8 record carries the citations as one blob (Q25) and the store has+ // An archive record carries the citations as one blob (Q25) and the store has // exactly one home for them, so the record's value is written straight // in. This is a **whole-value** write, not an edit: the record replaces // everything the row cited, bytes that would not decode included.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swiftindex be404b6..a441c50 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift@@ -29,7 +29,8 @@ extension LibraryRepository { /// Build a TeachingBasis from a fresh context for the given hostname. /// Validates closed Site/pattern state: no duplicate Sites (handled by fetchSites),- /// valid mode, mode-consistent patterns, positive site-unique versions, valid definitions.+ /// valid mode, mode-consistent patterns, valid definitions. The row `version`+ /// is validated nowhere: it is an advisory integer since T-2281 (Q4, Q37). func buildTeachingBasis(hostname: String, context: ModelContext) throws -> TeachingBasis { // Q39: a duplicated hostname resolves to the row `SiteResolutionOrder` // selects, exactly as capture already does. This used to refuse@@ -71,12 +72,6 @@ extension LibraryRepository { return nil } try capabilities.validate(patternDefinition: def)- guard p.version > 0 else {- throw LibraryRepositoryError.corruptLibrary(- operation: "building teaching basis",- reason: "pattern \(p.id) has non-positive version \(p.version)"- )- } return PatternBasisEntry( id: p.id, version: p.version,@@ -84,10 +79,7 @@ extension LibraryRepository { definition: def, createdAt: p.createdAt )- }.sorted { a, b in- if a.version != b.version { return a.version < b.version }- return a.id.uuidString < b.id.uuidString- }+ }.sorted(by: RuleSelection.precedes) // Validate mode-consistent pattern state let activePatterns = patterns.filter(\.isActive)@@ -113,14 +105,13 @@ extension LibraryRepository { } } - // Validate site-unique pattern versions- let versionSet = Set(patterns.map(\.version))- guard versionSet.count == patterns.count else {- throw LibraryRepositoryError.corruptLibrary(- operation: "building teaching basis",- reason: "Site '\(hostname)' has duplicate pattern versions"- )- }+ // Two version refusals stood here and both are **retired**: the+ // site-unique one (T-2281 Req 3.1), because it made a hostname+ // untaught-able for the duration of a version collision — exactly the+ // state two teaches of one site produce — and the positivity one (Q37),+ // because Q31 dropped the matching read-side check and nothing repairs a+ // non-positive `version` any more, so refusing here would block the+ // hostname forever. Minting `max + 1` over a 0 still yields >= 1. let entryDescriptor = FetchDescriptor<Entry>( predicate: #Predicate { $0.hostname == hostname }@@ -208,8 +199,7 @@ extension LibraryRepository { hostname: String, patternDefinition: PatternDefinition, boundaryEntryID: UUID,- historicalPatternID: UUID? = nil,- historicalPatternVersion: Int? = nil+ historicalPatternID: UUID? = nil ) async throws -> TeachingContract { guard capabilities.supportsSegmentTeaching else { throw LibraryRepositoryError.invalidInput(@@ -231,8 +221,7 @@ extension LibraryRepository { let request = TeachingRequest( operation: .replacement( boundaryEntryID: boundaryEntryID,- historicalPatternID: historicalPatternID,- historicalPatternVersion: historicalPatternVersion+ historicalPatternID: historicalPatternID ), patternDefinition: patternDefinition )@@ -369,7 +358,7 @@ extension LibraryRepository { for row in group.rows { Self.applyProjection( projection, to: row,- patternID: patternID, patternVersion: patternVersion,+ patternID: patternID, createdWorks: createdWorks, existingWorks: workTargets, timestamp: timestamp@@ -407,7 +396,7 @@ extension LibraryRepository { ) } - return .committed(patternID: patternID, patternVersion: patternVersion)+ return .committed(patternID: patternID) } } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swiftindex d3e61e2..b5583c5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift@@ -75,22 +75,37 @@ extension LibraryRepository { // pointer, so the replay cannot change as unrelated teaching flips // the winner. let allPatterns = legalSite?.patternValues ?? []- let activePatterns = allPatterns.filter(\.isActive)- let isWorkOnly = legalSite?.isWorkOnlyTitleRule ?? false + // The Decision 1 pick, resolved **once** and then passed everywhere+ // this screen needs it: the history split below, the active summary,+ // the presentation title. Every `Site.activePattern` access is a+ // fresh scan of the relationship and a fresh decode, and two of them+ // disagreeing is exactly the split-brain the pick exists to prevent.+ let pickedPattern = legalSite?.activePattern+ let pickedDefinition = try pickedPattern.map { try $0.storedDefinition }+ let isWorkOnly = pickedDefinition?.definition == .wholeTitle++ // Everything except the row the pick took, oldest first. Keyed on+ // the pick rather than on `!isActive` so the two halves cannot+ // disagree: on a+ // double-marked Site the Decision 1 tiebreak drops one marked row,+ // and a flag filter would leave it out of the history as well.+ // No screen shows that today — a Site with two marked patterns+ // fails `validatedRecentSiteMode` (`activeCount != 1`), so+ // `legalSite` is nil and every Site-derived value is withheld (Q39,+ // Q52) — but the split belongs to the pick, not to the column.+ //+ // Excluded by **object identity**, not by id: rows of one identity+ // group share a UUID (T-2281), so `$0.id != picked.id` would drop+ // every row of the picked row's group and hide its history. let historicalPatterns = allPatterns- .filter { !$0.isActive }- .sorted {- if $0.version != $1.version { return $0.version < $1.version }- return $0.id.uuidString < $1.id.uuidString- }+ .filter { $0 !== pickedPattern }+ .sorted(by: RuleSelection.precedes) let activePatternSummary: PatternRuleSummary?- if let active = activePatterns.first {- let def = try active.definition+ if let active = pickedPattern, let def = pickedDefinition?.definition { activePatternSummary = PatternRuleSummary( id: active.id,- version: active.version, form: def.form, isActive: true, plainLanguageRule: PatternSummaryFormatter.summary(for: def)@@ -103,9 +118,11 @@ extension LibraryRepository { // T-2289: a retired pattern that will not decode has no summary // to show and must not take the whole detail down with it. guard let def = try? pattern.definition else { return nil }+ // `isActive: false` reports the pick, not the column: a row that+ // is still flag-marked but lost the Decision 1 tiebreak is not+ // the rule this hostname applies. return PatternRuleSummary( id: pattern.id,- version: pattern.version, form: def.form, isActive: false, plainLanguageRule: PatternSummaryFormatter.summary(for: def)@@ -166,7 +183,7 @@ extension LibraryRepository { // unresolvable citation is therefore disclosed rather than thrown // (Q13, Req 3.4), in the vocabulary this screen already uses for a // degraded field: the assignment settlement says the citation does- // not resolve, and keeps the cited `(id, version)` visible as+ // not resolve, and keeps the cited rule's UUID visible as // evidence (Req 4.2). Recent renders the same replay as a marked // row, from this same helper — which is also where the rule that an // Entry with no Site relationship has nothing to replay against, and@@ -183,11 +200,9 @@ extension LibraryRepository { : .notApplicable let assignmentSettlement: FieldSettlement if replay == .unresolvable,- let patternID = entrySnap.workAssignmentProvenance.patternID,- let patternVersion = entrySnap.workAssignmentProvenance.patternVersion {+ let patternID = entrySnap.workAssignmentProvenance.patternID { assignmentSettlement = .patternUnsettled( patternID: patternID,- version: patternVersion, reason: "Cited title pattern does not resolve") } else { // Includes the citation that carries no identity at all:@@ -247,7 +262,8 @@ extension LibraryRepository { let displayTitle: String if let legalSite, let mode = siteMode { displayTitle = Self.presentationTitle(- for: entrySnap.captureTitle, siteMode: mode, site: legalSite)+ for: entrySnap.captureTitle, siteMode: mode, site: legalSite,+ activePattern: pickedPattern) } else { displayTitle = entrySnap.captureTitle }@@ -263,7 +279,7 @@ extension LibraryRepository { unresolvedCandidateTitle: replay.candidateTitle, displayTitle: displayTitle, workDisplayTitle: workDisplayTitle,- hasCurrentURLRule: legalSite?.urlRuleValues.contains(where: \.isCurrent) ?? false,+ hasCurrentURLRule: legalSite?.currentURLRule != nil, groupState: group.state, citingCharacters: citingCharacters )@@ -275,19 +291,25 @@ extension LibraryRepository { /// The presentation title for an entry: articles clean junk suffixes, /// Work-only Sites trim the retained affixes. The immutable capture title /// stays visible as evidence in provenance disclosure.+ /// `activePattern` is the caller's already-resolved Decision 1 pick for+ /// `site`, passed in rather than re-picked here so a screen cannot trim by+ /// one rule while it discloses another. internal static func presentationTitle( for captureTitle: String, siteMode: SiteMode,- site: Site+ site: Site,+ activePattern: TitlePattern? ) -> String { switch siteMode { case .articles: return ArticleTitleCleaner.clean(captureTitle, using: site.junkSuffixRule)- case .taught where site.isWorkOnlyTitleRule:+ case .taught: // The whole-title rule's Work name is the trimmed capture title.- let stored = site.activePattern.flatMap { try? $0.storedDefinition }+ guard let stored = activePattern.flatMap({ try? $0.storedDefinition }),+ stored.definition == .wholeTitle+ else { return captureTitle } return TitleTrimApplicator.apply(- prefix: stored?.trimPrefix, suffix: stored?.trimSuffix, to: captureTitle)+ prefix: stored.trimPrefix, suffix: stored.trimSuffix, to: captureTitle) default: return captureTitle }@@ -301,20 +323,18 @@ extension LibraryRepository { ) -> FieldSettlement { switch provenance.kind { case .pattern:- guard let patternID = provenance.patternID,- let patternVersion = provenance.patternVersion else {- // FieldProvenance invariant guarantees these are non-nil for .pattern kind,- // but we handle gracefully rather than force unwrapping.+ guard let patternID = provenance.patternID else {+ // The FieldProvenance invariant guarantees this is non-nil for the+ // .pattern kind, but we handle it gracefully rather than force-unwrap. return .unsettled(reason: "Pattern \(fieldName) provenance missing identity (corrupt)") } guard hasValue else { return .patternUnsettled( patternID: patternID,- version: patternVersion, reason: "Pattern produced no \(fieldName) value" ) }- return .patternSettled(patternID: patternID, version: patternVersion)+ return .patternSettled(patternID: patternID) case .urlRule: return hasValue ? .unsettled(reason: "URL-rule-derived \(fieldName)")
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swiftindex 966ed0d..1316197 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift@@ -167,7 +167,8 @@ extension LibraryRepository { guard let site = try sites.site(for: hostname, context: context), let mode = SiteMode(rawValue: site.modeRaw) else { return captureTitle }- return presentationTitle(for: captureTitle, siteMode: mode, site: site)+ return presentationTitle(+ for: captureTitle, siteMode: mode, site: site, activePattern: site.activePattern) } private static func siteDisplayName(
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swiftindex 1ee6178..b213171 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swift@@ -22,7 +22,7 @@ extension LibraryRepository { patternVersion: computedVersion ) - case .replacement(let boundaryEntryID, let historicalPatternID, let historicalPatternVersion):+ case .replacement(let boundaryEntryID, let historicalPatternID): // Compute max version from basis patterns let maxVersion = basis.patterns.map(\.version).max() ?? 0 let (newVersion, overflow) = maxVersion.addingReportingOverflow(1)@@ -39,8 +39,7 @@ extension LibraryRepository { existingWorks: candidates, patternVersion: computedVersion, boundaryEntryID: boundaryEntryID,- historicalPatternID: historicalPatternID,- historicalPatternVersion: historicalPatternVersion+ historicalPatternID: historicalPatternID ) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swiftindex 5d1a2ae..8aa4fe7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift@@ -157,7 +157,8 @@ extension LibraryRepository { if let site, let mode { // Whole-title (Work-only) Sites are re-taught through the // composed surface, not the Recent inline re-teach pill.- let isWorkOnly = site.isWorkOnlyTitleRule+ let picked = site.activePattern+ let isWorkOnly = (try? picked?.definition) == .wholeTitle actionType = switch (mode, actionable) { case (.untaught, true): .teach case (.taught, true): isWorkOnly ? .none : .reteach@@ -166,7 +167,8 @@ extension LibraryRepository { displayCaptureTitle = Self.presentationTitle( for: entry.captureTitle, siteMode: mode,- site: site+ site: site,+ activePattern: picked ) replay = Self.replayCitedPattern(for: entry, citingSite: { group.carrier.site }) } else {@@ -302,9 +304,12 @@ extension LibraryRepository { guard let mode = SiteMode(rawValue: site.modeRaw) else { return nil } let patterns = site.patternValues- var versions: Set<Int> = []+ // Site-unique versions and positivity were checked here and are both+ // **retired** with the validator's copies (T-2281 Req 3.1, Q31): hiding+ // a hostname because two of its rules share an advisory integer — or+ // because one holds an integer no live path writes and no repair pass+ // rewrites — is the quarantine this spec removes. for pattern in patterns {- guard pattern.version > 0, versions.insert(pattern.version).inserted else { return nil } guard (try? pattern.definition.validated()) != nil else { return nil } } @@ -344,7 +349,9 @@ extension LibraryRepository { /// candidate title Recent and Entry detail both disclose. The search space /// is what the Entry's own Site retains — a fixed pointer, so the replay /// cannot come and go as unrelated teaching flips the hostname winner- /// (Req 3.2, 3.5). Id *and* version are tested together (Req 4.2).+ /// (Req 3.2, 3.5). The cited **id** is the whole of the match since T-2281+ /// (Req 2.1, 2.3); where several rows carry it, the group representative+ /// answers (Req 2.6). /// /// **Whether a citation is replayable at all is decided here**, not at the /// two call sites. Written twice it drifted: Recent's copy omitted the@@ -375,10 +382,9 @@ extension LibraryRepository { return .notApplicable } guard let patternID = entry.workAssignmentProvenance.patternID,- let patternVersion = entry.workAssignmentProvenance.patternVersion,- let pattern = site.patternValues.first(where: {- $0.id == patternID && $0.version == patternVersion- }),+ let pattern = RuleSelection.citedRow(+ patternID, in: site.patternValues,+ representative: GroupOrdering.representativePattern), let definition = try? pattern.definition else { return .unresolvable }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swiftindex 06c139a..b5cdbdb 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift@@ -151,7 +151,6 @@ extension LibraryRepository { // 4. Apply — allocate IDs/time AFTER equality let timestamp = self.clock.now() let patternID = activePattern.id- let patternVersion = activePattern.version let plan = contract.outcome.plan guard let projection = plan.entryProjections.first else {@@ -187,7 +186,7 @@ extension LibraryRepository { for row in group.rows { Self.applyProjection( projection, to: row,- patternID: patternID, patternVersion: patternVersion,+ patternID: patternID, createdWorks: createdWorks, existingWorks: workTargets, timestamp: timestamp@@ -352,17 +351,17 @@ extension LibraryRepository { if site.mode == .articles { entry.intentionallyUnattached = true } else if site.mode == .taught, !quarantined,- let activePattern = site.patternValues.first(where: \.isActive) {+ let activePattern = site.activePattern { // A current rule whose stored bytes will not decode is skipped, // not propagated: the Entry then derives from the title rule // alone and keeps the conservative identity key, which is // exactly the path an untaught URL side already takes. Capture // never fails over a rule (Req 4.4).- let urlRecord = site.urlRuleValues.first(where: \.isCurrent)- let url: (id: UUID, version: Int, definition: URLRuleDefinition)? =+ let urlRecord = site.currentURLRule+ let url: (id: UUID, definition: URLRuleDefinition)? = urlRecord.flatMap { record in guard let definition = try? record.definition else { return nil }- return (record.id, record.version, definition)+ return (record.id, definition) } let storedTitleRule = try activePattern.storedDefinition let titleRule = ComposedTitleRule(storedTitleRule)@@ -380,12 +379,12 @@ extension LibraryRepository { entry.editCitations { citations in Self.applyEntryIdentityAndChapter( to: entry, citations: &citations, derivation: derivation,- titleRuleID: activePattern.id, titleVersion: activePattern.version,+ titleRuleID: activePattern.id, url: url) Self.applyCaptureAssignment( to: entry, citations: &citations, assignment: contract.outcome.composedAssignment, derivation: derivation,- titleRuleID: activePattern.id, titleVersion: activePattern.version,+ titleRuleID: activePattern.id, url: url, allWorks: allWorks, types: types, hostname: validated.hostname, site: site, context: context, timestamp: timestamp)@@ -431,7 +430,7 @@ extension LibraryRepository { private static func applyCaptureAssignment( to entry: Entry, citations: inout EntryCitations, assignment: ComposedAssignmentProjection?, derivation: ComposedDerivation,- titleRuleID: UUID, titleVersion: Int, url: (id: UUID, version: Int, definition: URLRuleDefinition)?,+ titleRuleID: UUID, url: (id: UUID, definition: URLRuleDefinition)?, allWorks: [Work], types: WorkTypeDirectory, hostname: String, site: Site?, context: ModelContext, timestamp: Date ) {@@ -442,7 +441,7 @@ extension LibraryRepository { // The rule processed this Entry but could not resolve a single Work; // record the processing provenance with no Work, matching M3 (Req 9.3). applyAssignmentProvenance(to: &citations, derivation: derivation,- titleRuleID: titleRuleID, titleVersion: titleVersion, url: url)+ titleRuleID: titleRuleID, url: url) case .reuse(let workID), .claim(let workID): // **Every row of the Work group, not the one row this Entry points // at** (Req 2.7, Q80). These are derived writes, and two of them are@@ -461,7 +460,7 @@ extension LibraryRepository { // On the capture's own membership (Req 3.5). work.applyURLIdentity( WorkURLIdentityMint(- value: workIdentity.value, ruleID: url.id, ruleVersion: url.version),+ value: workIdentity.value, ruleID: url.id), on: hostname) } // Req 3.21, the same rule the composed apply path follows — the@@ -471,7 +470,7 @@ extension LibraryRepository { work.modifiedAt = timestamp } applyAssignmentProvenance(to: &citations, derivation: derivation,- titleRuleID: titleRuleID, titleVersion: titleVersion, url: url)+ titleRuleID: titleRuleID, url: url) case .create(let key): guard let name = derivation.workName, !M2Unicode.isBlank(name) else { break } // Through the factory, so the minted membership carries the identity@@ -479,7 +478,7 @@ extension LibraryRepository { var minted: WorkURLIdentityMint? if case .urlIdentity(let identity) = key, let url { minted = WorkURLIdentityMint(- value: identity.value, ruleID: url.id, ruleVersion: url.version)+ value: identity.value, ruleID: url.id) } let work = Work.createCarrying( in: context, title: name, hostname: hostname, site: site, identity: minted,@@ -488,7 +487,7 @@ extension LibraryRepository { work.titleProvenanceRaw = TitleProvenance.parsed.rawValue entry.work = work applyAssignmentProvenance(to: &citations, derivation: derivation,- titleRuleID: titleRuleID, titleVersion: titleVersion, url: url)+ titleRuleID: titleRuleID, url: url) } } @@ -565,12 +564,11 @@ extension LibraryRepository { let activePattern = activePatterns[0] let def = try activePattern.definition try capabilities.validate(patternDefinition: def)- guard activePattern.version > 0 else {- throw LibraryRepositoryError.corruptLibrary(- operation: "building capture basis",- reason: "active pattern \(activePattern.id) has non-positive version"- )- }+ // The `version > 0` refusal that stood here is retired (Q37). Q31+ // dropped the matching read-side check and nothing repairs a+ // non-positive `version` any more, so a refusal here would leave the+ // hostname permanently un-re-parseable; minting `max + 1` over a 0+ // still yields >= 1. activePatternBasis = PatternBasisEntry( id: activePattern.id, version: activePattern.version, isActive: true, definition: def,@@ -616,7 +614,7 @@ extension LibraryRepository { // derives identity, sequence, and names exactly as the teaching preview // would (Req 6.3, 6.4). let currentTitleRule: ComposedTitleRuleBasis?- if let site, siteMode == .taught, let active = site.patternValues.first(where: \.isActive) {+ if let site, siteMode == .taught, let active = site.activePattern { let stored = try active.storedDefinition currentTitleRule = ComposedTitleRuleBasis( id: active.id, version: active.version, definition: stored.definition,@@ -629,7 +627,7 @@ extension LibraryRepository { // the URL side of the basis empty and the capture takes the conservative // path (Req 4.4). let currentURLRule: ComposedURLRuleBasis?- if let site, let current = site.urlRuleValues.first(where: \.isCurrent),+ if let site, let current = site.currentURLRule, let origin = current.origin, let definition = try? current.definition { currentURLRule = ComposedURLRuleBasis( id: current.id, version: current.version, origin: origin,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swiftindex 00d50a5..4e37a86 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swift@@ -75,8 +75,8 @@ extension LibraryRepository { return RuleSuggestionCandidate( hostname: hostname, siteMode: mode,- titleRuleVersion: site.activePattern?.version,- urlRuleVersion: site.urlRuleValues.first(where: \.isCurrent)?.version,+ titleRuleID: site.activePattern?.id,+ urlRuleID: site.currentURLRule?.id, entryCount: entryCount, latestCaptureAt: try context.fetch(newest).first?.firstCapturedAt) }@@ -90,10 +90,10 @@ extension LibraryRepository { public struct RuleSuggestionCandidate: Equatable, Sendable { public let hostname: String public let siteMode: SiteMode- /// Version of the Site's active title pattern; nil where it has none.- public let titleRuleVersion: Int?- /// Version of the Site's current URL rule; nil where it has none.- public let urlRuleVersion: Int?+ /// UUID of the Site's active title pattern; nil where it has none.+ public let titleRuleID: UUID?+ /// UUID of the Site's current URL rule; nil where it has none.+ public let urlRuleID: UUID? public let entryCount: Int /// The newest capture's `firstCapturedAt`; nil where the hostname has no /// captures.@@ -102,15 +102,15 @@ public struct RuleSuggestionCandidate: Equatable, Sendable { public init( hostname: String, siteMode: SiteMode,- titleRuleVersion: Int?,- urlRuleVersion: Int?,+ titleRuleID: UUID?,+ urlRuleID: UUID?, entryCount: Int, latestCaptureAt: Date? ) { self.hostname = hostname self.siteMode = siteMode- self.titleRuleVersion = titleRuleVersion- self.urlRuleVersion = urlRuleVersion+ self.titleRuleID = titleRuleID+ self.urlRuleID = urlRuleID self.entryCount = entryCount self.latestCaptureAt = latestCaptureAt }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swiftindex 8161665..209f1df 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift@@ -43,12 +43,8 @@ extension LibraryRepository { hostname: site.hostname, displayName: displayName, mode: mode,- patternIDs: site.patternValues- .sorted {- if $0.version != $1.version { return $0.version < $1.version }- return $0.id.uuidString < $1.id.uuidString- }- .map(\.id),+ // Oldest rule first.+ patternIDs: site.patternValues.sorted(by: RuleSelection.precedes).map(\.id), junkSuffixRule: site.junkSuffixRule, workCount: workCount) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+URLIdentity.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+URLIdentity.swiftindex ecf1e0d..7fd9f9d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+URLIdentity.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+URLIdentity.swift@@ -22,7 +22,7 @@ extension LibraryRepository { operation: "reviewing URL identity" ) { context in let evidence = try self.buildURLSiteEvidence(hostname: hostname, context: context)- guard let currentRule = evidence.rules.first(where: \.isCurrent) else {+ guard let currentRule = RuleSelection.current(evidence.rules) else { throw LibraryRepositoryError.invalidInput( operation: "reviewing URL identity", reason: "No current URL rule for hostname '\(hostname)'"@@ -62,7 +62,7 @@ extension LibraryRepository { let definition = try? rulePattern.definition else { return nil } return try URLRuleBasisEntry( id: rulePattern.id,- version: rulePattern.version,+ createdAt: rulePattern.createdAt, isCurrent: rulePattern.isCurrent, origin: origin, definition: definition
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swiftindex 4b9e13f..ad0b45f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift@@ -434,7 +434,7 @@ extension LibraryRepository { case .set(let identity, let rule): row.applyURLIdentity( WorkURLIdentityMint(- value: identity.value, ruleID: rule.id, ruleVersion: rule.version),+ value: identity.value, ruleID: rule.id), on: site.hostname) case .clear: row.applyURLIdentity(nil, on: site.hostname)@@ -592,13 +592,16 @@ extension LibraryRepository { private static func currentRuleBasisEntry( from rules: [URLRulePattern], operation: String ) throws -> (entry: URLRuleBasisEntry?, unreadable: Bool) {- let currentRules = rules.filter(\.isCurrent)- guard currentRules.count <= 1 else {+ let currentCount = rules.count(where: \.isCurrent)+ guard currentCount <= 1 else { throw LibraryRepositoryError.corruptLibrary( operation: operation,- reason: "Site has \(currentRules.count) current URL rules")+ reason: "Site has \(currentCount) current URL rules") }- guard let rule = currentRules.first else { return (nil, false) }+ // One marked row at most by the refusal above, so the Decision 1 pick+ // and `first` cannot differ — but the pick is the one selector every+ // reader goes through (T-2281 Q11), and this is a reader.+ guard let rule = RuleSelection.current(rules) else { return (nil, false) } guard let origin = rule.origin else { throw LibraryRepositoryError.corruptLibrary( operation: operation, reason: "current URL rule has an unknown origin")@@ -607,7 +610,7 @@ extension LibraryRepository { do { return ( try URLRuleBasisEntry(- id: rule.id, version: rule.version, isCurrent: rule.isCurrent,+ id: rule.id, createdAt: rule.createdAt, isCurrent: rule.isCurrent, origin: origin, definition: definition), false) } catch {@@ -677,7 +680,7 @@ extension LibraryRepository { ) -> WorkIdentitySnapshot { guard let value = membership.urlIdentity, !value.isEmpty else { return .none } let reference = membership.urlIdentityRuleID.flatMap { id in- rules.first { $0.id == id }.flatMap { try? URLRuleReference(id: id, version: $0.version) }+ rules.first { $0.id == id }.map { _ in URLRuleReference(id: id) } } return WorkIdentitySnapshot( value: ExactScalarString(value),
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex a54654e..fd55cd8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -646,14 +646,16 @@ public actor LibraryRepository { /// - `duplicates`: hostnames holding more than one Site row, the class /// `.duplicateSiteRows` reports. Derived from `Site.hostname` alone, which /// is the only column the answer depends on.- /// - `colliding`: hostnames whose rules carry the damage Decision 7 renumbers- /// away — a version used twice, a non-positive version, two rules marked- /// where one may be, or a current URL rule that does not hold the greatest- /// version. The same conditions `SiteUnionProjection.assignVersions`- /// renumbers for, so a hostname this names either converges on the pass- /// that follows or is one the projection declines to touch (untaught twins,- /// rules sharing a UUID) — in which case the pass costs one projection and- /// writes nothing, which is already true of every duplicated hostname.+ /// - `colliding`: hostnames holding **two rules marked where one may be** —+ /// the state two concurrent teaches leave, which the union repairs by+ /// demoting all but the Decision 1 row (Req 3.6, 3.7). Version conditions+ /// named this list until T-2281 (a version used twice, a non-positive one,+ /// a current URL rule below a retained one); nothing renumbers now, and a+ /// shared version is not damage. A hostname this names either converges on+ /// the pass that follows or is one the projection declines to touch+ /// (untaught twins, rules sharing a UUID) — in which case the pass costs+ /// one projection and writes nothing, which is already true of every+ /// duplicated hostname. /// /// Rules are grouped by their owning row's hostname, which faults /// `TitlePattern.site` once per rule. A library holds one rule per teaching@@ -685,23 +687,20 @@ public actor LibraryRepository { titleRuleRows.append( DuplicateScan.RuleRow(id: pattern.id, createdAt: pattern.createdAt)) guard let hostname = pattern.site?.hostname else { return }- patterns[hostname, default: RuleTally()]- .record(version: pattern.version, marked: pattern.isActive)+ patterns[hostname, default: RuleTally()].record(marked: pattern.isActive) } try context.enumerate(FetchDescriptor<URLRulePattern>(), batchSize: batchSize) { rule in urlRuleRows.append(DuplicateScan.RuleRow(id: rule.id, createdAt: rule.createdAt)) guard let hostname = rule.site?.hostname else { return }- rules[hostname, default: RuleTally()]- .record(version: rule.version, marked: rule.isCurrent)+ rules[hostname, default: RuleTally()].record(marked: rule.isCurrent) } var colliding: Set<String> = [] for (hostname, tally) in patterns where tally.collides { colliding.insert(hostname) }- // The greatest-version invariant is the URL rules' alone: a title rule's- // active revision is not required to be the newest.- for (hostname, tally) in rules where tally.collides || tally.markedIsNotGreatest {- colliding.insert(hostname)- }+ // Both kinds ask the one question now. The URL rules used to ask a+ // second — whether the current row held the greatest version — and that+ // invariant is retired (Req 3.1).+ for (hostname, tally) in rules where tally.collides { colliding.insert(hostname) } // `Dictionary` iteration is per-process seeded; the reconciler sorts the // union it is handed, but the list it reports having worked from should // not depend on the seed either.@@ -711,28 +710,18 @@ public actor LibraryRepository { } /// One hostname's rules of one type, counted without holding the rows.+ ///+ /// It tallied versions too — the set, the greatest, and the marked row's —+ /// for the invariants T-2281 retired. One marked row of each kind is the+ /// whole question left (Req 3.6). private struct RuleTally {- private var count = 0- private var versions: Set<Int> = [] private var markedCount = 0- private var greatest = Int.min- private var markedVersion: Int?-- mutating func record(version: Int, marked: Bool) {- count += 1- versions.insert(version)- greatest = max(greatest, version)- if marked {- markedCount += 1- markedVersion = version- }- } - var collides: Bool {- versions.count != count || versions.contains { $0 <= 0 } || markedCount > 1+ mutating func record(marked: Bool) {+ if marked { markedCount += 1 } } - var markedIsNotGreatest: Bool { markedCount == 1 && markedVersion != greatest }+ var collides: Bool { markedCount > 1 } } /// The quarantine reason for a Site, or nil when it validates.@@ -1375,12 +1364,11 @@ public actor LibraryRepository { _ projection: EntryProjection, to entry: Entry, patternID: UUID,- patternVersion: Int, createdWorks: [String: Work], existingWorks: [UUID: Work], timestamp: Date ) {- let cited = CitedRule(id: patternID, version: patternVersion)+ let cited = CitedRule(id: patternID) // One decode and one encode of the blob for both arms: the chapter and // the assignment are two citations in the same value.@@ -1395,7 +1383,7 @@ public actor LibraryRepository { case .pendingPattern: entry.chapterTitle = projection.projectedChapter citations.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: cited.id, patternVersion: cited.version)+ kind: .pattern, patternID: cited.id) } // Apply assignment@@ -1494,7 +1482,7 @@ public actor LibraryRepository { // The four `map*Record` mappers stood here — `Entry`/`Work`/`Site`/ // `TitlePattern` to the V2 `*Record` structs, for the snapshot // `validateStore` validated. They went with it; the live export path has its- // own `map*Record` family in `BackupArchiveProjection`, over `BackupV7Entry`+ // own `map*Record` family in `BackupArchiveProjection`, over `BackupV8Entry` // and friends, and never used these. internal func withLockedContext<Value: Sendable>(@@ -1805,8 +1793,7 @@ public actor LibraryRepository { let chapter = citations.chapterTitle let assignment = FieldProvenance.tolerant( kind: citations.workAssignment.provenanceKind,- patternID: citations.workAssignment.patternRule?.id,- patternVersion: citations.workAssignment.patternRule?.version)+ patternID: citations.workAssignment.patternRule?.id) return EntrySnapshot( id: entry.id, captureTitle: entry.captureTitle,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swiftindex 439e29f..7b8be8c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift@@ -535,10 +535,12 @@ public enum LibraryValidator { /// Site owns. A nil relationship is a state Req 2.1 explicitly permits, and /// after task 14 it makes every citation unresolvable by construction, so /// only the **resolution clause** is demoted for it, never the whole guard:- /// a nonblank identity, a complete `(id, version)` reference and every arm- /// of the closed tuple table keep failing exactly as they did. A record with- /// no Site has nothing to replay its citation against; a record with one is- /// diagnosed as before.+ /// a nonblank identity, a citation being *present* where the record must+ /// carry one, and every arm of the closed tuple table keep failing exactly+ /// as they did. A citation is a bare UUID since T-2281, so "present" is the+ /// whole of its well-formedness — there is no second component to be+ /// missing. A record with no Site has nothing to replay its citation+ /// against; a record with one is diagnosed as before. private struct CitationContext { /// The citing record's own Site relationship: the cited search space, /// and — when nil — the reason a failure is tolerated.@@ -554,40 +556,40 @@ public enum LibraryValidator { var toleratesUnresolved: Bool { tolerant && citingSite == nil } } - /// Whether a cited URL rule resolves — id and version together (Req 4.2),- /// among the rules the citing record's own Site owns (Req 3.2) — or is- /// tolerated because the citing record has no Site.+ /// Whether a cited URL rule resolves — by UUID (Req 2.1), among the rules+ /// the citing record's own Site owns (Req 3.2) — or is tolerated because+ /// the citing record has no Site. private static func resolves( citedRule reference: URLRuleReference, _ citation: CitationContext ) -> Bool { citedRule(reference, citation) != nil || citation.toleratesUnresolved } - /// The cited URL rule among the citing record's own Site's rules — id and- /// version together (Req 4.2) — or nil when it does not resolve, including- /// when there is no Site to search. The single source for every cited-rule- /// read, `validateExtractionReplay` included: a replay that resolved its- /// rule anywhere else would enforce a rule the citing record's Site does not+ /// The cited URL rule among the citing record's own Site's rules — by UUID+ /// (Req 2.1) — or nil when it does not resolve, including when there is no+ /// Site to search. The single source for every cited-rule read,+ /// `validateExtractionReplay` included: a replay that resolved its rule+ /// anywhere else would enforce a rule the citing record's Site does not /// own, and would re-throw one call after `requiredReference` tolerated the /// same reference (Req 3.4, Q40). private static func citedRule( _ reference: URLRuleReference, _ citation: CitationContext ) -> URLRulePattern? {- citation.citingSite?.urlRuleValues.first {- $0.id == reference.id && $0.version == reference.version- }+ guard let rows = citation.citingSite?.urlRuleValues else { return nil }+ return RuleSelection.citedRow(+ reference.id, in: rows, representative: GroupOrdering.representativeURLRule) } - /// Whether a cited title pattern resolves — id and version together- /// (Req 4.2), among the patterns the citing record's own Site owns (Req 3.2)- /// — or is tolerated because the citing record has no Site. The sibling of+ /// Whether a cited title pattern resolves — by UUID (Req 2.1), among the+ /// patterns the citing record's own Site owns (Req 3.2) — or is tolerated+ /// because the citing record has no Site. The sibling of /// `resolves(citedRule:)`, for the call sites that need the answer and not /// the pattern; `validateV3` keeps the raw `citedPattern` call because it /// replays the pattern it resolves. private static func resolves(- citedPattern id: UUID, version: Int, _ citation: CitationContext+ citedPattern id: UUID, _ citation: CitationContext ) -> Bool {- citedPattern(id: id, version: version, citation) != nil || citation.toleratesUnresolved+ citedPattern(id: id, citation) != nil || citation.toleratesUnresolved } /// The cited title pattern among the citing record's own Site's rules, or@@ -595,9 +597,11 @@ public enum LibraryValidator { /// Callers pair a nil with `citation.toleratesUnresolved` to decide between /// a diagnosis and a tolerated state. private static func citedPattern(- id: UUID, version: Int, _ citation: CitationContext+ id: UUID, _ citation: CitationContext ) -> TitlePattern? {- citation.citingSite?.patternValues.first { $0.id == id && $0.version == version }+ guard let rows = citation.citingSite?.patternValues else { return nil }+ return RuleSelection.citedRow(+ id, in: rows, representative: GroupOrdering.representativePattern) } // MARK: - Site (closed tuple table, supersedes M3 8.1)@@ -649,19 +653,18 @@ public enum LibraryValidator { throw invalid("Site", id, "URL-rule membership is incomplete or duplicated") } - // Versions are unique **across identity groups**, not across rows. Two- // rows of one rule at one version are the M4b Q18 origin case — an- // archive imported twice — which Decision 13 deliberately never- // separates; two rows of one rule at *different* versions are what- // Decision 13's guard leaves behind where aligning them would have- // broken this very clause. Neither is a collision. An unrelated rule- // landing on a version this group holds still is.- var patternVersionOwners: [Int: UUID] = [:]+ // **Site-unique versions are retired** (T-2281 Req 3.1). The uniqueness+ // existed to make `(id, version)` a key; a citation is the UUID alone+ // now, so two rows sharing a version carry no ambiguity — and requiring+ // uniqueness is what two teaches of one site collided on. **Positivity+ // went with it on the read side** (Q31): `version > 0` remains a model+ // invariant (Q24), but nothing repairs a non-positive row any more —+ // the union and the tally stopped renumbering — so a diagnosis here+ // would quarantine the hostname for good. What the row still has to be+ // is **owned** by the Site that lists it. for pattern in patterns {- guard pattern.site === site, pattern.version > 0,- Self.claimVersion(pattern.version, by: pattern.id, in: &patternVersionOwners)- else {- throw invalid("Site", id, "title patterns require positive Site-unique versions and ownership")+ guard pattern.site === site else {+ throw invalid("Site", id, "title patterns require ownership") } // **Only the active pattern must read** (T-2289). A retired row is // history: nothing applies it, and a re-teach — the one action a@@ -671,7 +674,7 @@ public enum LibraryValidator { // could clear. An Entry still citing it is handled in the replay. // The trim checks below are skipped for the same reason: a retired // row's structure is as far beyond a re-teach as its bytes are.- // Version and ownership were checked above for every row.+ // Ownership was checked above for every row. guard pattern.isActive else { continue } let stored: StoredPatternDefinition do { stored = try pattern.storedDefinition }@@ -686,12 +689,11 @@ public enum LibraryValidator { } } - var ruleVersionOwners: [Int: UUID] = [:]+ // As for the title patterns above: ownership only — neither uniqueness+ // nor positivity (T-2281 Req 3.1, Q31). for rule in rules {- guard rule.site === site, rule.version > 0,- Self.claimVersion(rule.version, by: rule.id, in: &ruleVersionOwners)- else {- throw invalid("Site", id, "URL rules require positive Site-unique versions and ownership")+ guard rule.site === site else {+ throw invalid("Site", id, "URL rules require ownership") } guard let origin = rule.origin else { throw invalid("URLRulePattern", rule.id.uuidString, "unknown origin")@@ -722,16 +724,12 @@ public enum LibraryValidator { guard currentRules.count <= 1 else { throw invalid("Site", id, "a Site holds at most one current URL rule") }- // **The greatest retained version is compared between rules, and a- // rule's version is its group's greatest** (Decision 16). The flag sits- // on the first row in representative order, which sorts by creation date- // then version *ascending*, so a converged group routinely ends with its- // current row below a retained twin — the same rule, materialised twice,- // not an older teaching left current.- if let current = currentRules.first,- Self.groupVersion(of: current.id, in: rules) != rules.map(\.version).max() {- throw invalid("Site", id, "current URL rule must have the greatest retained version")- }+ // Decision 16's "current URL rule must have the greatest retained+ // version" clause stood here and is **retired** (T-2281 Req 3.1). It+ // was the read-time half of the version invariant — the clause that+ // made a re-teach have to renumber before it could mark a new row — and+ // nothing reads the integer any more. Which row is current is the marked+ // flag, tiebroken by `RuleSelection.current` (Decision 1). guard Self.siteTupleIsLegal(site.mode, patterns: patterns, urlRules: rules) else { throw invalid("Site", id, Self.illegalSiteTupleReason(site.mode))@@ -820,9 +818,9 @@ public enum LibraryValidator { /// /// The identity tuple is the Work arm's, moved: `rule` carries a value and a /// rule, `legacyUnverified` a value and no rule, `none` neither. What it no- /// longer does is *resolve* the cited rule — a membership cites by UUID- /// alone and holds no version (Req 10.4, Q28), so there is no `(id, version)`- /// pair to look up and the version for display resolves at read time.+ /// longer does is *resolve* the cited rule — a membership cites the rule+ /// row by UUID alone and holds no version (Req 10.4, Q28), so there is no+ /// `(id, version)` pair to look up. /// /// `site == nil` is tolerated outright (Req 1.5): the Site row has not /// arrived, exactly as `Entry.site` tolerates. So is a membership whose Work@@ -1010,13 +1008,13 @@ public enum LibraryValidator { "v3 identity requires a sequence rule and a resolving name contributor on its own site, with no Work identity" guard entry.urlWorkIdentity == nil, workReference == nil, let sequence = entry.chapterSequence, sequenceReference == identityReference,- let nameRef = reference(from: nameTitle)+ let nameRef = nameTitle.map({ URLRuleReference(id: $0.id) }) else { throw invalid("Entry", id, v3IdentityReason) } // The pattern itself, not `resolves(citedPattern:)`: the replay below // needs the pattern this resolves.- let namePattern = citedPattern(id: nameRef.id, version: nameRef.version, citation)+ let namePattern = citedPattern(id: nameRef.id, citation) guard namePattern != nil || citation.toleratesUnresolved else { throw invalid("Entry", id, v3IdentityReason) }@@ -1137,13 +1135,12 @@ public enum LibraryValidator { ) throws { switch provenance.kind { case .none:- guard entry.chapterTitle == nil, provenance.patternID == nil,- provenance.patternVersion == nil else {+ guard entry.chapterTitle == nil, provenance.patternID == nil else { throw invalid("Entry", id, "absent chapter must have none provenance") } case .manual: guard let title = entry.chapterTitle, !M2Unicode.isBlank(title),- provenance.patternID == nil, provenance.patternVersion == nil else {+ provenance.patternID == nil else { throw invalid("Entry", id, "manual chapter requires a nonblank value and no pattern") } case .pattern:@@ -1151,8 +1148,8 @@ public enum LibraryValidator { // (Req 3.2); an Entry with no Site relationship tolerates a pattern // that does not resolve (Req 3.4, Q27). guard site.mode != .articles, let title = entry.chapterTitle, !M2Unicode.isBlank(title),- let patternID = provenance.patternID, let patternVersion = provenance.patternVersion,- resolves(citedPattern: patternID, version: patternVersion, citation) else {+ let patternID = provenance.patternID,+ resolves(citedPattern: patternID, citation) else { throw invalid("Entry", id, "pattern chapter provenance does not resolve") } case .urlRule:@@ -1248,20 +1245,13 @@ public enum LibraryValidator { cited: CitedRule?, _ citation: CitationContext ) throws -> URLRuleReference {- guard let reference = reference(from: cited),+ guard let reference = cited.map({ URLRuleReference(id: $0.id) }), resolves(citedRule: reference, citation) else { throw unresolved(owner, id, "\(field) URL rule") } return reference } - /// A cited `(id, version)` as the reference type the resolution helpers- /// take, or nil where the citation is absent or its version is not positive.- private static func reference(from cited: CitedRule?) -> URLRuleReference? {- guard let cited, cited.version > 0 else { return nil }- return try? URLRuleReference(id: cited.id, version: cited.version)- }- // MARK: - Identity grouping /// Buckets one entity type by its identity key, preserving first-seen order@@ -1314,21 +1304,6 @@ public enum LibraryValidator { return groups.values.allSatisfy(isConverged) } - /// Claims `version` for `owner`, refusing only where a *different* rule- /// already holds it.- private static func claimVersion(- _ version: Int, by owner: UUID, in owners: inout [Int: UUID]- ) -> Bool {- if let existing = owners[version] { return existing == owner }- owners[version] = owner- return true- }-- /// A rule's version, which for a split group is the greatest its rows hold.- private static func groupVersion(of ruleID: UUID, in rules: [URLRulePattern]) -> Int? {- rules.filter { $0.id == ruleID }.map(\.version).max()- }- private static func invalid(_ type: String, _ id: String, _ reason: String) -> LibraryValidationError { .invalidStateTuple(type: type, id: id, reason: reason) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swiftindex f244691..ea2a77b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift@@ -317,7 +317,7 @@ extension LibraryRepository { entry.work = work entry.editCitations { $0.workAssignment = .pattern(- CitedRule(id: activePattern.id, version: activePattern.version))+ CitedRule(id: activePattern.id)) } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex abd9f74..92ed406 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -36,7 +36,7 @@ public typealias CharacterSuppression = AsterismSchemaV9.CharacterSuppression /// /// Not the only coercion in the codebase, and not meant to be: the archive /// paths spell their own `?? .default` — `WorkTypeDirectory`,-/// `ArchiveRecordBuilders`, `BackupV7Types`, `BackupArchiveProjection` — each+/// `ArchiveRecordBuilders`, `BackupV8Types`, `BackupArchiveProjection` — each /// answering what *that* wire may say rather than what a stored column may /// hold. Those are deliberately separate policies, not omissions from this one. ///@@ -267,8 +267,10 @@ public final class Entry { /// /// **A nil blob is not undecodable** (Q23). It reads as the default, so a /// real edit over one is written and a no-op edit over one leaves it nil —- /// which is what keeps `entriesWithoutCitationBlobCount` meaning something- /// after `SiteReconciler.rewriteCitations` has walked every Entry.+ /// which is what kept `entriesWithoutCitationBlobCount` meaning something+ /// while the reconcilers walked every Entry to rewrite citations. Those+ /// walks are gone with the version rewriting (T-2281); the guard stays,+ /// because any bulk writer that arrives next would trip the same wire. /// /// Value-guarded, so a writer that changed nothing dirties no row. @discardableResult@@ -468,7 +470,16 @@ public final class Site { /// The Site's active title pattern (Decision 5: a taught Site always holds /// exactly one). Nil for untaught/articles Sites.- public var activePattern: TitlePattern? { patternValues.first(where: \.isActive) }+ ///+ /// One of the two accessors every reader of a current rule goes through+ /// (T-2281 Req 4.2, Q11): `RuleSelection.current` carries the Decision 1+ /// tiebreak for the double-marked Site the version invariant's retirement+ /// leaves readable until the next reconcile repairs it.+ public var activePattern: TitlePattern? { RuleSelection.current(patternValues) }++ /// The Site's current URL rule, by the same rule as `activePattern`. Nil+ /// where the Site holds no marked URL rule.+ public var currentURLRule: URLRulePattern? { RuleSelection.current(urlRuleValues) } /// Whether the Site's active title rule is a whole-title rule — the V4 /// successor to the M3 `.wholeCaptureTitle` interpretation (the Work name is@@ -927,9 +938,7 @@ public final class WorkSiteMembership { public var urlIdentity: String? public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue /// The rule row that derived `urlIdentity`, **by UUID only** (Req 10.4,- /// Q28): no version column, so rule-version renumbering leaves memberships- /// untouched and the version for display resolves by row lookup at read- /// time.+ /// Q28): the citation names the row and carries no version. public var urlIdentityRuleID: UUID? public var workURLString: String? /// The Work this row belongs to, kept while `work` is nil (Q37).@@ -1052,7 +1061,6 @@ public final class WorkDistinctPair { struct WorkURLIdentityMint: Equatable, Sendable { var value: String var ruleID: UUID- var ruleVersion: Int } extension Work {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swiftindex aee4ca3..36c2750 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift@@ -24,7 +24,7 @@ public struct ProjectionContract<Basis: Sendable & Equatable, /// Result of committing a teaching operation (initial or replacement). public enum TeachingCommitOutcome: Equatable, Sendable { /// The commit succeeded with the given pattern identity.- case committed(patternID: UUID, patternVersion: Int)+ case committed(patternID: UUID) /// The basis or outcome changed; a fresh contract is returned for re-approval. case refreshed(TeachingContract) /// The operation can no longer be projected (e.g. boundary entry deleted).@@ -72,7 +72,7 @@ public struct TeachingBasis: Sendable, Equatable { public let siteMode: SiteMode /// The hostname. public let hostname: String- /// All active/retained patterns for this Site, sorted by (version, id).+ /// All active/retained patterns for this Site, sorted by (createdAt, id). public let patterns: [PatternBasisEntry] /// All same-Site Entries sorted by (firstCapturedAt, id). public let entries: [EntryBasisEntry]@@ -182,7 +182,7 @@ public enum TeachingOperation: Sendable, Equatable { /// Initial teaching (untaught → taught). case initial /// Replacement teaching with boundary and optional historical correction.- case replacement(boundaryEntryID: UUID, historicalPatternID: UUID?, historicalPatternVersion: Int?)+ case replacement(boundaryEntryID: UUID, historicalPatternID: UUID?) } /// The complete outcome of a teaching projection (the plan).
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swiftindex fc668a8..717b0e4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift@@ -187,7 +187,7 @@ internal enum CitationReplay: Equatable { /// The citation resolved. The replayed candidate Work title, which the /// pattern may legitimately leave nil. case replayed(String?)- /// The cited `(id, version)` pair matches no pattern in the search space, or+ /// The cited id matches no pattern in the search space, or /// the pattern it matches cannot reproduce the candidate. Rendered as needing /// attention; never thrown. case unresolvable@@ -303,15 +303,13 @@ public struct EntryCitingCharacter: Equatable, Sendable, Identifiable { /// Summary of a pattern rule for display, with full provenance diagnostics. public struct PatternRuleSummary: Equatable, Sendable { public let id: UUID- public let version: Int public let form: PatternForm public let isActive: Bool /// Plain-language description of the segment edge/offset/length/ignored rule. public let plainLanguageRule: String - public init(id: UUID, version: Int, form: PatternForm, isActive: Bool, plainLanguageRule: String) {+ public init(id: UUID, form: PatternForm, isActive: Bool, plainLanguageRule: String) { self.id = id- self.version = version self.form = form self.isActive = isActive self.plainLanguageRule = plainLanguageRule@@ -321,9 +319,9 @@ public struct PatternRuleSummary: Equatable, Sendable { /// Settlement status for a single field. Carries exact pattern info or reason. public enum FieldSettlement: Equatable, Sendable { /// Field is settled by pattern with exact identity.- case patternSettled(patternID: UUID, version: Int)+ case patternSettled(patternID: UUID) /// A pattern produced diagnostics for the field but no settled value.- case patternUnsettled(patternID: UUID, version: Int, reason: String)+ case patternUnsettled(patternID: UUID, reason: String) /// Field is settled manually (protected). case manualProtected(reason: String) /// Field is unsettled with reason.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RuleSelection.swift b/Packages/AsterismCore/Sources/AsterismCore/RuleSelection.swiftnew file mode 100644index 0000000..b49786a--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/RuleSelection.swift@@ -0,0 +1,102 @@+import Foundation++/// A rule row that can be marked as the one its Site currently applies.+///+/// The two stored kinds spell the flag differently (`TitlePattern.isActive`,+/// `URLRulePattern.isCurrent`) and the URL-identity basis carries a value-type+/// echo of the row, so the protocol names the concept once and the conformances+/// translate. No `AnyObject` constraint: `URLRuleBasisEntry` is a struct.+protocol MarkableRule {+ var id: UUID { get }+ var createdAt: Date { get }+ var isMarked: Bool { get }+}++extension TitlePattern: MarkableRule {+ var isMarked: Bool { isActive }+}++extension URLRulePattern: MarkableRule {+ var isMarked: Bool { isCurrent }+}++extension URLRuleBasisEntry: MarkableRule {+ var isMarked: Bool { isCurrent }+}++extension PatternBasisEntry: MarkableRule {+ var isMarked: Bool { isActive }+}++/// Which of a Site's rule rows is the current one (T-2281 Decision 1).+///+/// "Current" used to be "the marked row", resting on an invariant that a Site+/// held at most one marked row of each kind and that it carried the greatest+/// `version`. The version invariant is gone, and two marked rows remain a state+/// two concurrent teaches or an import into a non-empty library can produce —+/// diagnosed and repaired (Req 3.3, 3.7), but readable in the meantime. So the+/// pick needs a tiebreak, and one shared by every reader *and* by the repair,+/// so that demoting the loser is invisible to whoever was already reading.+///+/// The tiebreak is recency, because Req 4's story is "the rule I most recently+/// taught" and `createdAt` is the only column that says so. `version` takes no+/// part: it is an advisory integer since T-2281 (Q4).+enum RuleSelection {+ /// The one order rule rows are listed in: oldest `createdAt` first, ties+ /// broken by the lower `id.uuidString`. `(createdAt, id)` since T-2281+ /// (Req 4.4) — the row version no longer orders anything.+ ///+ /// `current` is the same order read from the other end, so a list and the+ /// row it highlights cannot disagree.+ static func precedes<Rule: MarkableRule>(_ lhs: Rule, _ rhs: Rule) -> Bool {+ if lhs.createdAt != rhs.createdAt { return lhs.createdAt < rhs.createdAt }+ return lhs.id.uuidString < rhs.id.uuidString+ }++ /// The marked row with the newest `createdAt`, ties broken by the lowest+ /// `id.uuidString`. Nil when no row is marked.+ ///+ /// Deterministic across repeated reads whatever order the store hands the+ /// to-many relationship over, which is the property Req 4.2 asks for — with+ /// one limit: on a full tie (the same `createdAt` *and* the same+ /// `uuidString`, which only an unconverged same-UUID group produces) the+ /// returned id is still stable but which of those rows comes back follows+ /// array order.+ static func current<Rule: MarkableRule>(_ rows: [Rule]) -> Rule? {+ rows.lazy.filter(\.isMarked).min { lhs, rhs in+ // `precedes` read from the recent end: the newest row wins, and+ // among rows of one age the same id tiebreak settles it — so a+ // listing and the row it highlights cannot disagree.+ lhs.createdAt == rhs.createdAt ? precedes(lhs, rhs) : lhs.createdAt > rhs.createdAt+ }+ }++ /// The row a citation of `id` resolves to, among the citing record's own+ /// Site's rows (Req 2.1) — and where several rows carry the id, the one+ /// `representative` ranks first (Req 2.6, Q6).+ ///+ /// A cited UUID naming more than one row is the same-UUID group T-2282+ /// owns; this spec only owes determinism, so the pick is delegated to the+ /// existing rule-row group ordering rather than decided here. `first(where:)`+ /// would have been arbitrary: a to-many relationship hands its rows over in+ /// no particular order, so two reads of one unchanged Site could resolve+ /// different rows.+ ///+ /// The single-match path — every citation in a healthy library — allocates+ /// nothing and never calls `representative`.+ static func citedRow<Rule: MarkableRule>(+ _ id: UUID, in rows: [Rule], representative: ([Rule]) -> Rule?+ ) -> Rule? {+ var first: Rule?+ var group: [Rule] = []+ for row in rows where row.id == id {+ if first == nil {+ first = row+ } else {+ if group.isEmpty, let first { group.append(first) }+ group.append(row)+ }+ }+ return group.isEmpty ? first : representative(group)+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/SiteReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/SiteReconciler.swiftindex ae4886f..1c2b258 100644--- a/Packages/AsterismCore/Sources/AsterismCore/SiteReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/SiteReconciler.swift@@ -18,8 +18,11 @@ public struct SiteReconciliationOutcome: Equatable, Sendable { public var consolidatedHostnames: [String] = [] /// Records whose `site` relationship was pointed at the survivor. public var repinnedRecords = 0- /// Rule rows whose owner or version the union rewrote.- public var renumberedRules = 0+ /// Rule rows the union re-parented onto the survivor, or demoted.+ ///+ /// Named `renumberedRules` while the union also renumbered; T-2281 deleted+ /// the renumbering, and owner and mark are all that move now.+ public var reparentedRules = 0 /// Records whose nil relationship was healed against a surviving row /// (Req 1.8) outside a consolidation. public var healedRecords = 0@@ -28,7 +31,7 @@ public struct SiteReconciliationOutcome: Equatable, Sendable { public var isEmpty: Bool { consolidatedHostnames.isEmpty && repinnedRecords == 0- && renumberedRules == 0 && healedRecords == 0+ && reparentedRules == 0 && healedRecords == 0 } } @@ -59,10 +62,10 @@ enum SiteReconciler { /// The two hostname lists are **unioned**, so a caller holding one list of /// hostnames-to-reconcile passes it once and leaves the other empty. /// `duplicateHostnames` are the ones holding more than one Site row;- /// `collidingHostnames` are the ones whose rules may carry the same-row- /// version collision two concurrent teaches produce (Decision 7), which no- /// row count can see — `LibraryRepository.reconcileAfterSync` composes that- /// list from its own rule tallies and the `.siteTuple` set.+ /// `collidingHostnames` are the ones whose rules may carry the second marked+ /// row two concurrent teaches produce (Req 3.6, 3.7), which no row count can+ /// see — `LibraryRepository.reconcileAfterSync` composes that list from its+ /// own rule tallies and the `.siteTuple` set. /// /// `rowsByHostname` is every Site row a hostname holds, when the caller /// already has them. Import does: its upsert fetched and grouped the whole@@ -98,18 +101,17 @@ enum SiteReconciler { guard projection.consolidates, let survivor = projection.survivor else { continue } // The rule union and every demotion land in one save: a receiving- // device must never see a rule re-parented without the version that- // travels with it. Nothing is saved where nothing was dirtied —- // that is what makes `run ∘ run = run`, and a converged hostname- // still reaches here on every pass, because additive-only leaves the- // stripped rows in place and `.duplicateSiteRows` standing.+ // device must never see half of a re-parent. Nothing is saved where+ // nothing was dirtied — that is what makes `run ∘ run = run`, and a+ // converged hostname still reaches here on every pass, because+ // additive-only leaves the stripped rows in place and+ // `.duplicateSiteRows` standing. let union = applyUnion(projection, survivor: survivor)- outcome.renumberedRules += union.rules+ outcome.reparentedRules += union.rules if union.changed { try saveStrategy.save(context) } let repin = try repin(- hostname: hostname, survivor: survivor,- rewrites: projection.versionRewrites, batchSize: batchSize,+ hostname: hostname, survivor: survivor, batchSize: batchSize, context: context, saveStrategy: saveStrategy) outcome.repinnedRecords += repin.repinned if union.changed || repin.changed { outcome.consolidatedHostnames.append(hostname) }@@ -120,10 +122,13 @@ enum SiteReconciler { return outcome } - // MARK: - Rule custody (Req 1.1, 1.3, Decision 7)+ // MARK: - Rule custody: re-parent and demote, never renumber (Req 1.1, 1.3, 3.4) - /// Re-parents the union's rules to the survivor, applies the renumbering and- /// the demotions, and leaves every stripped row legal.+ /// Re-parents the union's rules to the survivor, applies the demotions, and+ /// leaves every stripped row legal.+ ///+ /// Versions are **not** written: a row keeps the version it was minted at+ /// (Req 3.4), so a merge moves owner and mark and nothing else. /// /// Each write is guarded by a comparison, so a second run over a reconciled /// graph dirties nothing — which is also the convergence mechanism when a@@ -141,10 +146,6 @@ enum SiteReconciler { pattern.site = survivor touched = true }- if pattern.version != projected.version {- pattern.version = projected.version- touched = true- } if pattern.isActive != projected.isActive { pattern.isActive = projected.isActive touched = true@@ -162,10 +163,6 @@ enum SiteReconciler { rule.site = survivor touched = true }- if rule.version != projected.version {- rule.version = projected.version- touched = true- } if rule.isCurrent != projected.isCurrent { rule.isCurrent = projected.isCurrent touched = true@@ -197,10 +194,13 @@ enum SiteReconciler { return (rewritten, changed) } - // MARK: - Re-pin and citation rewrite (Req 1.4, 1.8)+ // MARK: - Re-pin (Req 1.4, 1.8) - /// Points a hostname's records at the survivor and rewrites their rule- /// citations, in chunks.+ /// Points a hostname's records at the survivor, in chunks.+ ///+ /// It used to rewrite their rule citations too, through the union's rule-id+ /// → version map. A citation is the rule's UUID since T-2281 and a merge+ /// changes no id, so Req 1.4 holds without a walk (Req 3.4). /// /// Chunking is not a nicety: re-pinning a 5,000-record hostname in one save /// is the 17 s shape Q27 measured, because the cost is inverse-array@@ -210,7 +210,6 @@ enum SiteReconciler { private static func repin( hostname: String, survivor: Site,- rewrites: [UUID: Int], batchSize: Int, context: ModelContext, saveStrategy: any RepositorySaveStrategy@@ -228,7 +227,6 @@ enum SiteReconciler { repinned += 1 dirty = true }- if rewriteCitations(of: entry, rewrites) { dirty = true } } if dirty { changed = true@@ -259,8 +257,9 @@ enum SiteReconciler { // re-pinned — nothing reads it — and the walk that survived existed for // one column, `urlIdentityRuleVersion`, which the 6/7 archive exported // for import to re-check. A `WorkSiteMembership` cites its rule by UUID- // alone (Req 10.4, Q28) and the 7/8 record carries no version, so a rule- // renumbering has nothing left to move on the Work side.+ // alone (Req 10.4, Q28) and a membership record carries no version, so+ // the rule renumbering this walk once served had nothing left to move+ // on the Work side. return (repinned, changed) }@@ -317,44 +316,6 @@ enum SiteReconciler { return healed } - /// Rewrites an Entry's seven `(rule id, version)` citations through the- /// union's map. Rule UUIDs are unique, so the new version is a lookup and- /// provenance replay keeps resolving across the merge (Req 1.4).- ///- /// **One place** since V9: the blob is where a citation lives, so- /// `EntryCitations.rewritingVersions` is the whole rewrite and the column- /// half is gone.- ///- /// Internal rather than private because `DuplicateReconciler` rewrites the- /// same citations after a rule identity group converges on one version- /// (Q63): two spellings of "re-point every citation" would be two lists to- /// keep in step.- ///- /// There is no Work counterpart any more: a `WorkSiteMembership` cites by- /// UUID and carries no version (Req 10.4, Q28), and since task 21 neither- /// the validator nor the archive reads `Work.urlIdentityRuleVersion`.- ///- /// **One unreadable row must not abandon the consolidation.** An earlier- /// spelling decoded `entry.citations` and let the throw escape, so a single- /// Entry whose bytes will not decode left every later Entry citing a version- /// its rule no longer holds — on the pass whose whole job is to stop exactly- /// that. `editCitations` repairs such a row from the default instead (Q24).- ///- /// A **nil**-blob Entry is left nil (Q23): `rewritingVersions` has nothing- /// to rewrite in the default value, so the edit is a no-op and the value- /// guard writes nothing. This pass walks every Entry on a consolidation, so- /// a version of it that converted nil blobs would zero- /// `entriesWithoutCitationBlobCount` on the first reconcile after the- /// upgrade — the one counter that tells the reader which rows lost their- /// provenance.- @discardableResult- static func rewriteCitations(of entry: Entry, _ rewrites: [UUID: Int]) -> Bool {- guard !rewrites.isEmpty else { return false }- return entry.editCitations { citations in- if let rewritten = citations.rewritingVersions(rewrites) { citations = rewritten }- }- }- private static func chunks<Element>(of values: [Element], size: Int) -> [ArraySlice<Element>] { LibraryRepository.chunks(of: values, size: size) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swiftindex ed84411..76309e8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swift@@ -24,19 +24,21 @@ import SwiftData enum SiteUnionProjection { /// A title rule as the union places it: its owning row becomes the survivor,- /// its version is the one the deterministic renumbering assigns, and at most- /// one of a hostname's patterns stays active (Req 1.3).+ /// and at most one of a hostname's patterns stays active (Req 1.3).+ ///+ /// **No version.** The union renumbered until T-2281, because the validator+ /// required Site-unique versions and a citation pinned `(id, version)`.+ /// Both are gone: the row keeps whatever version it was minted at, and the+ /// only thing the union decides about a rule is which row owns it and+ /// whether it stays marked (Req 3.4, 3.5). struct ProjectedTitlePattern { let pattern: TitlePattern- let version: Int let isActive: Bool } - /// The URL-rule counterpart. The kept `isCurrent` rule always holds the- /// greatest version, which `LibraryValidator` requires (`:421-423`).+ /// The URL-rule counterpart. struct ProjectedURLRule { let rule: URLRulePattern- let version: Int let isCurrent: Bool } @@ -60,11 +62,6 @@ enum SiteUnionProjection { let patterns: [ProjectedTitlePattern] let urlRules: [ProjectedURLRule] - /// Rule id → the version the union assigns it, for rewriting every citing- /// record's `(id, version)` pair (Decision 7). Rule ids are unique, so- /// `citedVersion = versionRewrites[citedID]` is mechanical.- let versionRewrites: [UUID: Int]- /// False where there is nothing for the reconciler to write: a row set /// whose winner the deterministic order picks by the device-local /// tiebreak rather than by synced content (Decision 5 — untaught twins,@@ -117,12 +114,13 @@ enum SiteUnionProjection { /// Deduping to the plain representative loses the flag whenever the /// marked row is not the least one, which the ordering makes ordinary /// rather than exotic: it compares version before the flag, so a group- /// spanning versions — the state Decision 13 deliberately leaves behind- /// — decides on version alone. A dropped active title rule leaves a- /// `.taught` hostname with none, which `requireProjectedTuplesRepresentable`- /// refuses permanently and untruthfully as references-still-arriving; a- /// dropped current URL rule refuses nothing and archives the hostname's- /// identity teaching as history.+ /// spanning versions decides on version alone — and since T-2281 nothing+ /// aligns those versions, so a group routinely does span them. A dropped+ /// active title rule leaves a `.taught` hostname with none, which+ /// `requireProjectedTuplesRepresentable` refuses permanently and+ /// untruthfully as references-still-arriving; a dropped current URL rule+ /// refuses nothing and archives the hostname's identity teaching as+ /// history. /// /// Choosing among the marked rows keeps the choice deterministic and /// changes nothing for a converged group, whose rows hold one definition@@ -209,12 +207,6 @@ enum SiteUnionProjection { ordered.flatMap(\.urlRuleValues) + additionalURLRules, id: \.id, marked: \.isCurrent, representative: GroupOrdering.representativeURLRule)- // Counted after the dedup: a rule whose only surviving row came from- // the Site relationships is not a merge, and `assignVersions` renumbers- // the whole hostname whenever it thinks one happened.- let survivingAdditionalPatterns = countSurviving(additionalPatterns, in: unionPatterns)- let survivingAdditionalRules = countSurviving(additionalURLRules, in: unionRules)- // Decision 5: custody moves only where the deterministic order // distinguishes the rows by *synced* content — steps 1–4. Where those // tie, the winner comes from the device-local `PersistentIdentifier`,@@ -230,46 +222,33 @@ enum SiteUnionProjection { let distinguishable = ordered.count <= 1 || SiteResolutionOrder.distinguishedBySyncedContent(ordered[0], ordered[1]) - let keptActive = keptRule(- among: unionPatterns, ordered: ordered, marked: \.isActive, id: \.id)- let keptCurrent = keptRule(- among: unionRules, ordered: ordered, marked: \.isCurrent, id: \.id)-- let patternVersions = assignVersions(- unionPatterns, rowCount: ordered.count, version: \.version, id: \.id,- kept: keptActive, additional: survivingAdditionalPatterns,- keptMustHoldGreatest: false, currentlyMarked: { $0.isActive })- let ruleVersions = assignVersions(- unionRules, rowCount: ordered.count, version: \.version, id: \.id,- kept: keptCurrent, additional: survivingAdditionalRules,- keptMustHoldGreatest: true, currentlyMarked: { $0.isCurrent })+ // The one rule of each type that stays marked; every other marked rule+ // demotes to history (Req 1.3, 3.7). The pick is `RuleSelection.current`+ // — the same predicate every reader of the current rule uses (Decision+ // 1, Q18) — so the demotion this drives is invisible to a reader who was+ // already looking at the row it keeps. It used to rank by *owning Site+ // row* first, which existed so the survivor's row won a merge; under the+ // new rule the newest teach wins whichever row it sits on, which is what+ // the reader means by "the rule I last taught".+ let keptActive = RuleSelection.current(unionPatterns)+ let keptCurrent = RuleSelection.current(unionRules) let projectedPatterns = unionPatterns.map {- ProjectedTitlePattern(- pattern: $0, version: patternVersions[$0.id] ?? $0.version,- isActive: $0 === keptActive)+ ProjectedTitlePattern(pattern: $0, isActive: $0 === keptActive) } let projectedRules = unionRules.map {- ProjectedURLRule(- rule: $0, version: ruleVersions[$0.id] ?? $0.version,- isCurrent: $0 === keptCurrent)+ ProjectedURLRule(rule: $0, isCurrent: $0 === keptCurrent) } - var rewrites = patternVersions- rewrites.merge(ruleVersions) { lhs, _ in lhs }- // A distinguishable hostname with more than one row always has work: // records can be pinned to either row, so the re-pin is owed even where // the losers own nothing. A single row only has work when the union- // repairs something — a same-row version collision, or an extra active- // rule two concurrent teaches both minted.+ // repairs something, and since T-2281 that is **flag drift alone** — an+ // extra marked rule two concurrent teaches both minted (Req 3.7). The+ // version arm went with the renumbering: a shared version is not damage. let repairsInPlace =- projectedPatterns.contains {- $0.version != $0.pattern.version || $0.isActive != $0.pattern.isActive- }- || projectedRules.contains {- $0.version != $0.rule.version || $0.isCurrent != $0.rule.isCurrent- }+ projectedPatterns.contains { $0.isActive != $0.pattern.isActive }+ || projectedRules.contains { $0.isCurrent != $0.rule.isCurrent } let hasWork = distinguishable && (rows.count > 1 || repairsInPlace) return ProjectedSite(@@ -283,20 +262,9 @@ enum SiteUnionProjection { strippedRows: stripped, patterns: projectedPatterns, urlRules: projectedRules,- versionRewrites: rewrites, consolidates: hasWork) } - // MARK: - Rule membership-- private static func countSurviving<Rule: AnyObject>(- _ candidates: [Rule], in surviving: [Rule]- ) -> Int {- guard !candidates.isEmpty else { return 0 }- let kept = Set(surviving.map(ObjectIdentifier.init))- return candidates.count { kept.contains(ObjectIdentifier($0)) }- }- // MARK: - Survivor teaching state private static func displayName(survivor: Site?, hostname: String) -> String {@@ -339,96 +307,4 @@ enum SiteUnionProjection { } return survivor.mode }-- /// The one rule of its type that stays active/current: the marked rule owned- /// by the highest-ranked row, and among several on one row the lowest id.- /// Every other marked rule demotes to history (Req 1.3).- private static func keptRule<Rule: AnyObject>(- among rules: [Rule],- ordered: [Site],- marked: (Rule) -> Bool,- id: (Rule) -> UUID- ) -> Rule? {- let rank = Dictionary(- uniqueKeysWithValues: ordered.enumerated().map { (ObjectIdentifier($0.element), $0.offset) })- let marked = rules.filter(marked)- guard !marked.isEmpty else { return nil }- return marked.min { lhs, rhs in- let lhsRank = owningRank(lhs, rank: rank)- let rhsRank = owningRank(rhs, rank: rank)- if lhsRank != rhsRank { return lhsRank < rhsRank }- return id(lhs).uuidString < id(rhs).uuidString- }- }-- private static func owningRank<Rule: AnyObject>(- _ rule: Rule, rank: [ObjectIdentifier: Int]- ) -> Int {- let site: Site? =- switch rule {- case let pattern as TitlePattern: pattern.site- case let urlRule as URLRulePattern: urlRule.site- default: nil- }- guard let site else { return .max }- return rank[ObjectIdentifier(site)] ?? .max- }-- // MARK: - Version reconciliation (Decision 7)-- /// Deterministic renumbering, applied **only when the union needs it**.- ///- /// Rules of each type are ordered by (original version, rule UUID) and- /// renumbered 1..n, with the kept active/current rule moved last so the- /// greatest-version invariant holds. Both devices see the same synced- /// content, so both compute the same numbering; a partial view that computes- /// a different one re-converges on a later pass, because the numbering is a- /// pure function of what has arrived.- ///- /// The guard matters as much as the renumbering. Versions are legal without- /// being contiguous — the validator asks only for positive Site-unique ones,- /// and for the current URL rule to hold the greatest — so renumbering- /// unconditionally would rewrite the citations of every ordinary single-row- /// hostname the first time this ran, for nothing.- private static func assignVersions<Rule: AnyObject>(- _ rules: [Rule],- rowCount: Int,- version: (Rule) -> Int,- id: (Rule) -> UUID,- kept: Rule?,- additional: Int,- keptMustHoldGreatest: Bool,- currentlyMarked: (Rule) -> Bool- ) -> [UUID: Int] {- guard !rules.isEmpty else { return [:] }-- let versions = rules.map(version)- let collides = Set(versions).count != versions.count- let nonPositive = versions.contains { $0 <= 0 }- let merging = rowCount > 1 || additional > 0- let demoting = rules.count(where: currentlyMarked) > 1- let keptIsNotGreatest =- keptMustHoldGreatest && kept.map { version($0) != versions.max() } == true-- guard collides || nonPositive || merging || demoting || keptIsNotGreatest else {- // `merge` rather than `uniqueKeysWithValues`: two rows sharing a rule- // UUID is a tolerated state (`.duplicateIdentity`), and trapping on it- // here would turn a diagnosis into a crash.- return Dictionary(rules.map { (id($0), version($0)) }, uniquingKeysWith: { lhs, _ in lhs })- }-- var ordered = rules.sorted {- let lhs = version($0)- let rhs = version($1)- if lhs != rhs { return lhs < rhs }- return id($0).uuidString < id($1).uuidString- }- if let kept {- ordered.removeAll { $0 === kept }- ordered.append(kept)- }- return Dictionary(- ordered.enumerated().map { (id($0.element), $0.offset + 1) },- uniquingKeysWith: { _, rhs in rhs })- } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swift b/Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swiftindex b0afda9..a98579b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swift@@ -204,7 +204,6 @@ public enum TitleProjectionPlanner { patternVersion: Int, boundaryEntryID: UUID, historicalPatternID: UUID? = nil,- historicalPatternVersion: Int? = nil, trimPrefix: String? = nil, trimSuffix: String? = nil ) throws -> ProjectionPlan {@@ -227,8 +226,7 @@ public enum TitleProjectionPlanner { let isAfterBoundary = index >= boundaryIndex let historicalTargeting = computeHistoricalTargeting( entry: entry,- historicalPatternID: historicalPatternID,- historicalPatternVersion: historicalPatternVersion+ historicalPatternID: historicalPatternID ) let isEarlier = index < boundaryIndex let earlierChapterUnsettled = isEarlier@@ -365,18 +363,15 @@ public enum TitleProjectionPlanner { private static func computeHistoricalTargeting( entry: EntrySnapshot,- historicalPatternID: UUID?,- historicalPatternVersion: Int?+ historicalPatternID: UUID? ) -> FieldTargeting {- guard let hID = historicalPatternID, let hVersion = historicalPatternVersion else {+ guard let hID = historicalPatternID else { return FieldTargeting(chapterTargeted: false, assignmentTargeted: false) } let chapterCites = entry.chapterTitleProvenance.kind == .pattern && entry.chapterTitleProvenance.patternID == hID- && entry.chapterTitleProvenance.patternVersion == hVersion let assignmentCites = entry.workAssignmentProvenance.kind == .pattern && entry.workAssignmentProvenance.patternID == hID- && entry.workAssignmentProvenance.patternVersion == hVersion return FieldTargeting(chapterTargeted: chapterCites, assignmentTargeted: assignmentCites) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swiftindex 6c2be9b..a6c9498 100644--- a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift@@ -19,42 +19,43 @@ public enum URLIdentityPlanningError: Error, Equatable, Sendable, CustomStringCo case .invalidPreviousIdentity(let workID): "Work \(workID.uuidString) has an invalid previous identity tuple" case .ruleNotInBasis(let reference):- "URL rule \(reference.id.uuidString) v\(reference.version) is not in the evidence basis"+ "URL rule \(reference.id.uuidString) is not in the evidence basis" } } } public struct URLRuleBasisEntry: Equatable, Sendable { public let id: UUID- public let version: Int+ /// The row's own `createdAt`, which is what orders the basis and what breaks+ /// the Decision 1 tie between two rules both marked current (T-2281 Req 4.1,+ /// 4.4). The prospective entry a teaching projection builds for a rule not yet+ /// inserted carries `.distantFuture` — it is the newest by construction (Q21).+ public let createdAt: Date public let isCurrent: Bool public let origin: URLRuleOrigin public let definition: URLRuleDefinition- public let reference: URLRuleReference++ /// A citation of this row. Derived, not stored: since T-2281 a reference is+ /// the row's UUID and nothing else.+ public var reference: URLRuleReference { URLRuleReference(id: id) } public init( id: UUID,- version: Int,+ createdAt: Date, isCurrent: Bool, origin: URLRuleOrigin = .readerTaught, definition: URLRuleDefinition ) throws {- guard version > 0 else {- throw URLIdentityPlanningError.invalidRule(reason: "version must be positive")- }- let reference: URLRuleReference do { try definition.validate(origin: origin, isCurrent: isCurrent)- reference = try URLRuleReference(id: id, version: version) } catch { throw URLIdentityPlanningError.invalidRule(reason: String(describing: error)) } self.id = id- self.version = version+ self.createdAt = createdAt self.isCurrent = isCurrent self.origin = origin self.definition = definition- self.reference = reference } } @@ -143,9 +144,8 @@ public struct URLSiteEvidenceBasis: Equatable, Sendable { ) throws { guard !hostname.isBlank else { throw URLIdentityPlanningError.blankHostname } try Self.requireUnique(rules.map(\.id), kind: "URL rule")- guard Set(rules.map(\.version)).count == rules.count else {- throw URLIdentityPlanningError.invalidRule(reason: "versions must be Site-unique")- }+ // T-2281 Req 3.1: rule versions are advisory, so a Site holding two rules+ // at one version is no longer a refusal. "At most one current" stays (Q12). guard rules.count(where: \.isCurrent) <= 1 else { throw URLIdentityPlanningError.invalidRule(reason: "at most one rule may be current") }@@ -163,9 +163,7 @@ public struct URLSiteEvidenceBasis: Equatable, Sendable { } self.hostname = hostname- self.rules = rules.sorted {- ($0.version, $0.id.uuidString) < ($1.version, $1.id.uuidString)- }+ self.rules = rules.sorted(by: RuleSelection.precedes) self.entries = entries.sorted(by: Self.entryOrder) self.works = works.sorted { $0.id.uuidString < $1.id.uuidString } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swiftindex 5ec1810..a5ae8f5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swift@@ -277,16 +277,17 @@ public enum URLRuleDefinition: Codable, Equatable, Hashable, Sendable { } } +/// The planner's and validator's name for a cited URL rule.+///+/// Same shape as `CitedRule` since T-2281 dropped the version from both, and+/// kept distinct because it is the name this concept threads through+/// `ProjectionContract` under (Q16). Non-throwing: there is nothing left to+/// reject. public struct URLRuleReference: Codable, Equatable, Hashable, Sendable { public let id: UUID- public let version: Int - public init(id: UUID, version: Int) throws {- guard version > 0 else {- throw URLIdentityError.invalidRule(reason: "rule reference version must be positive")- }+ public init(id: UUID) { self.id = id- self.version = version } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift b/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swiftindex dfce8bb..e957710 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift@@ -101,21 +101,19 @@ public struct JunkSuffixRule: Codable, Equatable, Sendable { public struct FieldProvenance: Codable, Equatable, Sendable { public let kind: FieldProvenanceKind public let patternID: UUID?- public let patternVersion: Int? - public init(kind: FieldProvenanceKind, patternID: UUID? = nil, patternVersion: Int? = nil) throws {+ public init(kind: FieldProvenanceKind, patternID: UUID? = nil) throws { switch kind { case .pattern:- guard patternID != nil, let patternVersion, patternVersion > 0 else {+ guard patternID != nil else { throw ModelInvariantError.invalidCombination(field: "field provenance") } case .none, .urlRule, .manual:- guard patternID == nil, patternVersion == nil else {+ guard patternID == nil else { throw ModelInvariantError.invalidCombination(field: "field provenance") } } self.kind = kind self.patternID = patternID- self.patternVersion = patternVersion } }
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swiftindex 0818a0b..6cc8245 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift@@ -156,15 +156,20 @@ public struct CorpusFingerprint: Sendable, Equatable { public var siteMode: SiteMode public var entryCount: Int public var latestCaptureAt: Date?- public var titleRuleVersion: Int?- public var urlRuleVersion: Int?+ /// The Site's current title pattern, identified by its UUID; nil where it+ /// has none. Re-teaching mints a new row with a new UUID, so the token+ /// flips exactly when the stored rule is replaced; a recalculation in place+ /// keeps the UUID and leaves the hostname valid (Req 6.2, Q5).+ public var titleRuleID: UUID?+ /// The Site's current URL rule, on the same terms as `titleRuleID`.+ public var urlRuleID: UUID? public init(siteMode: SiteMode, entryCount: Int, latestCaptureAt: Date?,- titleRuleVersion: Int? = nil, urlRuleVersion: Int? = nil) {+ titleRuleID: UUID? = nil, urlRuleID: UUID? = nil) { self.siteMode = siteMode self.entryCount = entryCount self.latestCaptureAt = latestCaptureAt- self.titleRuleVersion = titleRuleVersion- self.urlRuleVersion = urlRuleVersion+ self.titleRuleID = titleRuleID+ self.urlRuleID = urlRuleID } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesModeTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesModeTests.swiftindex dae2f00..2e9fbf9 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesModeTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesModeTests.swift@@ -52,8 +52,7 @@ struct ArticlesModePlannerTests { chapterProvenance: try FieldProvenance(kind: .manual), assignmentProvenance: try FieldProvenance( kind: .pattern,- patternID: patternID,- patternVersion: 1+ patternID: patternID ) ), makeEntry(@@ -61,8 +60,7 @@ struct ArticlesModePlannerTests { chapter: "Derived chapter", chapterProvenance: try FieldProvenance( kind: .pattern,- patternID: patternID,- patternVersion: 1+ patternID: patternID ), workID: manualWorkID, assignmentProvenance: try FieldProvenance(kind: .manual)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swiftindex 09532f8..32663f3 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift@@ -34,7 +34,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() #expect(Set(payload.sites.map(\.hostname)) == ["present.example", "orphan.example"]) let synthesised = try #require(payload.sites.first { $0.hostname == "orphan.example" })@@ -75,11 +75,11 @@ struct BackupExportDegradedRefusalTests { #expect(await repository.diagnostics.quarantineMap()["quarantined.example"] != nil) let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging) let result = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date())) - let decoded = try BackupV7Codec.decode(try Data(contentsOf: result.fileURL))+ let decoded = try BackupV8Codec.decode(try Data(contentsOf: result.fileURL)) #expect(decoded.payload.entries.count == 1) #expect(decoded.payload.titlePatterns.count == 2) // The union demoted one of the two, which is what makes the archive legal@@ -102,17 +102,18 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() #expect(payload.sites.count == 1) let site = try #require(payload.sites.first) #expect(site.mode == .taught)- // Both rows' teaching is in the archive, with the versions renumbered and- // one rule left active (Decision 7, Req 1.3).+ // Both rows' teaching is in the archive with one rule left active+ // (Req 1.3), and each record carries the version stored on its row —+ // both devices minted v1, and T-2281 renumbers neither (Req 3.8). #expect(payload.titlePatterns.count(where: { $0.siteHostname == site.hostname }) == 2) #expect(payload.titlePatterns.count == 2) #expect(payload.titlePatterns.count(where: \.isActive) == 1)- #expect(Set(payload.titlePatterns.map(\.version)).count == 2)+ #expect(payload.titlePatterns.map(\.version) == [1, 1]) // Export never writes: both rows are still in the store afterwards. let rows = try fixture.freshContext().fetch(FetchDescriptor<Site>()) #expect(rows.count == 2)@@ -134,12 +135,12 @@ struct BackupExportDegradedRefusalTests { entry.chapterTitle = "Chapter 1" entry.editCitations { $0.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: patternID, patternVersion: 1)+ kind: .pattern, patternID: patternID) } } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() let pattern = try #require(payload.titlePatterns.first) #expect(pattern.id == patternID)@@ -168,7 +169,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() #expect(payload.entries.count == 1) #expect(payload.entries.first?.id == shared)@@ -193,7 +194,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV7Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() } guard case .tornGroups(let payload) = error else { Issue.record("expected .tornGroups, got \(error)")@@ -220,7 +221,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV7Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() } guard case .unrepresentableValue(let record, _, let value) = error else { Issue.record("expected .unrepresentableValue, got \(error)")@@ -254,7 +255,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() let record = try #require(payload.works.first { $0.id == workID }) // The 7/8 record has nowhere to put a legacy type at all (Req 10.3, Q16).@@ -276,7 +277,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV7Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() } guard case .unrepresentableValue(_, _, let value) = error else { Issue.record("expected .unrepresentableValue, got \(error)")@@ -316,7 +317,7 @@ struct BackupExportDegradedRefusalTests { memberships: try context.fetch(FetchDescriptor<WorkSiteMembership>())) #expect(omitted == [staleID]) - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() #expect(payload.urlRules.map(\.id) == [currentID]) #expect(payload.urlRules.first?.siteHostname == payload.sites.first?.hostname)@@ -343,15 +344,15 @@ struct BackupExportDegradedRefusalTests { rule.definitionData = Data("{}".utf8) let entry = store.insertEntry(hostname: "tth.example", title: "Chapter 1", offset: 0) entry.urlWorkIdentity = "a-story"- entry.editCitations { $0.urlWork = CitedRule(id: ruleID, version: 1) }+ entry.editCitations { $0.urlWork = CitedRule(id: ruleID) } } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date())) } guard case .unrepresentableValue(let record, let field, _) = error else {@@ -395,7 +396,7 @@ struct BackupExportDegradedRefusalTests { entries: try context.fetch(FetchDescriptor<Entry>())) #expect(omitted == [retiredID]) - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() #expect(payload.titlePatterns.map(\.id) == [activeID]) #expect(payload.titlePatterns.first?.siteHostname == payload.sites.first?.hostname)@@ -418,16 +419,16 @@ struct BackupExportDegradedRefusalTests { 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))+ $0.workAssignment = .pattern(CitedRule(id: patternID)) } } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date())) } guard case .unrepresentableValue(let record, let field, _) = error else {@@ -459,11 +460,11 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date())) } guard case .unrepresentableValue(let record, let field, _) = error else {@@ -507,7 +508,7 @@ struct BackupExportDegradedRefusalTests { for order in [activeFirst, retiredFirst] { #expect(order.map(\.id) == [sharedID, sharedID]) let error = try #require(- throws: BackupV7ExportError.self,+ throws: BackupV8ExportError.self, "the partition must refuse an all-unreadable group holding the active row" ) { try LibraryRepository.partitionUnreadableTitlePatterns(order, entries: entries)@@ -521,10 +522,10 @@ struct BackupExportDegradedRefusalTests { } let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date())) } guard case .unrepresentableValue(let record, _, _) = error else { Issue.record("expected .unrepresentableValue, got \(error)")@@ -548,12 +549,12 @@ struct BackupExportDegradedRefusalTests { entry.chapterTitle = "Chapter 1" entry.editCitations { $0.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: absent, patternVersion: 3)+ kind: .pattern, patternID: absent) } } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV7Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() } guard case .referencesStillArriving = error else { Issue.record("expected .referencesStillArriving, got \(error)")@@ -594,7 +595,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV7Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() } guard case .referencesStillArriving(let detail) = error else { Issue.record("expected .referencesStillArriving, got \(error)")@@ -622,7 +623,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV7Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() } guard case .referencesStillArriving(let detail) = error else { Issue.record("expected .referencesStillArriving, got \(error)")@@ -648,11 +649,11 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date())) } guard case .referencesStillArriving = error else {@@ -671,7 +672,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV7Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() } guard case .referencesStillArriving = error else { Issue.record("expected .referencesStillArriving, got \(error)")@@ -696,11 +697,11 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date())) } guard case .tornGroups = error else {@@ -727,7 +728,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() let target = try Self.importIntoEmptyStore(payload) // What reconciliation would settle on: one row per hostname holding the@@ -752,7 +753,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() let target = try Self.importIntoEmptyStore(payload) let rows = try target.fetch(FetchDescriptor<Site>())@@ -778,13 +779,13 @@ struct BackupExportDegradedRefusalTests { #expect(await repository.diagnostics.isEmpty) let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging) let result = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date())) - let decoded = try BackupV7Codec.decode(try Data(contentsOf: result.fileURL))- #expect(decoded.backupFormatVersion == 7)- #expect(decoded.databaseSchemaVersion == 8)+ let decoded = try BackupV8Codec.decode(try Data(contentsOf: result.fileURL))+ #expect(decoded.backupFormatVersion == 8)+ #expect(decoded.databaseSchemaVersion == 9) #expect(decoded.payload.entries.count == 1) #expect(decoded.payload.sites.count == 1) exporter.cleanup(result)@@ -794,12 +795,12 @@ struct BackupExportDegradedRefusalTests { private func expectRefusal( _ body: () async throws -> Void- ) async throws -> BackupV7ExportError {+ ) async throws -> BackupV8ExportError { do { try await body() Issue.record("expected a named refusal, but the export proceeded") return .snapshotFailed(reason: "no refusal")- } catch let error as BackupV7ExportError {+ } catch let error as BackupV8ExportError { return error } }@@ -807,11 +808,11 @@ struct BackupExportDegradedRefusalTests { /// The archive's own import path, into a fresh empty store. Both round-trip /// tests go through the strict reference validator on the way in, which is /// what makes "the archive is legal" an assertion rather than a hope.- private static func importIntoEmptyStore(_ payload: BackupV7Payload) throws -> ModelContext {- let encoded = try BackupV7Codec.encode(+ private static func importIntoEmptyStore(_ payload: BackupV8Payload) throws -> ModelContext {+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "1", exportedAt: DegradedExportFixture.epoch))- let decoded = try BackupV7Codec.decode(encoded)+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: DegradedExportFixture.epoch))+ let decoded = try BackupV8Codec.decode(encoded) let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration(
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swiftindex 9ee158a..6b1dd6d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift@@ -4,7 +4,7 @@ import Testing @testable import AsterismCore -/// The byte-for-byte pin on the 7/8 export (`multi-site-works`, task 21).+/// The byte-for-byte pin on the 8/9 export (T-2281, Req 5.5). /// /// Nothing in the other suites would notice a change to a key's spelling, a sort /// order, or a number's formatting: they assert on decoded *values*, and a@@ -20,7 +20,13 @@ import Testing /// Determinism comes from the fixture rather than from luck: every UUID and date /// is a literal, `M5Fixture` runs on a `FixedRepositoryClock`, and the canonical /// encoder sorts keys while the projection sorts every array by identifier.-@Suite("Backup 7/8 golden export", .serialized)+///+/// **Recorded at 8/9 for T-2281.** An Entry citation is the rule's UUID now, so+/// the citation blobs the archive carries lost their `version` keys, and the+/// envelope moved with them. Re-recording is a deliberate act with a repeatable+/// procedure: run this suite's byte test with `ASTERISM_RECORD_GOLDEN=1` set and+/// it writes the fixture and fails, then run it again without the flag (Q22).+@Suite("Backup 8/9 golden export", .serialized) struct BackupGoldenExportTests { /// The recorded archive. Regenerating it is a deliberate act — see the@@ -28,10 +34,10 @@ struct BackupGoldenExportTests { private static var goldenURL: URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent()- .appending(path: "Fixtures/backup-7-8-golden.json")+ .appending(path: "Fixtures/backup-8-9-golden.json") } - /// Every array the 7/8 payload declares is non-empty, so the golden below is+ /// Every array the 8/9 payload declares is non-empty, so the golden below is /// evidence about the whole projection rather than about the half a smaller /// fixture would reach. @Test("The golden library populates every payload array")@@ -66,7 +72,7 @@ struct BackupGoldenExportTests { #expect(payload.distinctPairs.count == 1) #expect(payload.memberships.contains { $0.workID == BackupGoldenLibrary.absentWorkID }) // A membership carrying an identity, its rule and a confirmed Work URL —- // the only shape the export's version rewrite runs over.+ // every optional column of the record populated at once. #expect( payload.memberships.contains { $0.urlIdentity != nil && $0.urlIdentityRuleID != nil@@ -88,20 +94,36 @@ struct BackupGoldenExportTests { == 1) } - @Test("The 7/8 export of the golden library is byte-identical to the recorded archive")+ @Test("The 8/9 export of the golden library is byte-identical to the recorded archive") func exportIsByteIdenticalToTheRecordedArchive() async throws { let payload = try await Self.exportedPayload()- let encoded = try BackupV7Codec.encode(+ let encoded = try BackupV8Codec.encode( payload: payload, metadata: BackupGoldenLibrary.metadata) + // Q22: every generation bump used to re-record the golden by hand from+ // this test's failure message. The flag makes that procedure repeatable,+ // and keeps the recorded bytes coming from the same code path the+ // comparison reads. It fails deliberately — a run that records has not+ // checked anything.+ if ProcessInfo.processInfo.environment["ASTERISM_RECORD_GOLDEN"] == "1" {+ try encoded.write(to: Self.goldenURL)+ Issue.record(+ """+ recorded \(Self.goldenURL.lastPathComponent); re-run without \+ ASTERISM_RECORD_GOLDEN=1 to compare against it.+ """)+ return+ }+ let golden = try Data(contentsOf: Self.goldenURL) #expect( encoded == golden, """- the 7/8 export of the golden library no longer produces the recorded \+ the 8/9 export of the golden library no longer produces the recorded \ bytes. An archive's bytes are its identity — the checksum is taken \ over them — so this is a wire-format change unless it is a bug. \- Establish which before re-recording \(Self.goldenURL.lastPathComponent).+ Establish which before re-recording \(Self.goldenURL.lastPathComponent) \+ with ASTERISM_RECORD_GOLDEN=1. """) } @@ -112,55 +134,43 @@ struct BackupGoldenExportTests { let golden = try Data(contentsOf: Self.goldenURL) let plan = try BackupImporter.plan(from: golden) - #expect(plan.metadata.formatVersion == 7)- #expect(plan.metadata.schemaVersion == 8)+ #expect(plan.metadata.formatVersion == 8)+ #expect(plan.metadata.schemaVersion == 9) #expect(plan.counts.entries == plan.metadata.entryCount) #expect(plan.counts.works == plan.metadata.workCount) } - /// `drop-superseded-columns`' archive requirement: a 7/8 archive **exported- /// by a V8 build** restores into a V9 library. The bytes on disk are that- /// archive — `multi-site-works` recorded them and V9 did not re-record- /// them, because V9 changed where the citations are read from and not what- /// the wire says.- ///- /// Distinct from `recordedArchiveDecodesAndPlans` above, which stops at the- /// import gate's in-memory materialization: this one commits to a real- /// store and validates what is left there, which is what the reader does on- /// the day the archive is the rollback.- @Test("A 7/8 archive recorded before V9 restores into a V9 library and validates clean")- func recordedArchiveRestoresIntoAV9Library() async throws {- let golden = try Data(contentsOf: Self.goldenURL)-- let target = try await M5Fixture()- try await target.repository.confirmImport(plan: try BackupImporter.plan(from: golden))-- #expect(try await target.repository.strictDiagnoses().isEmpty)- // Against an empty library that would validate trivially: what is in the- // store re-exports as the same archive, so every record arrived.- let reExported = try BackupV7Codec.encode(- payload: try await target.repository.backupV7Snapshot(),- metadata: BackupGoldenLibrary.metadata)- #expect(reExported == golden)- }+ // `recordedArchiveRestoresIntoAV9Library` stood here: a 7/8 archive+ // **exported by a V8 build** restores into a V9 library+ // (`drop-superseded-columns` Q36). Its evidence was bytes only a V8 build+ // could produce, and Req 5.2 makes those unreadable — an 8/9 re-recording+ // would prove a different claim, and one `exportImportExportIsByteIdentical`+ // below already makes (Q23). /// Req 9.2, end to end: export, restore into an empty library, export again. /// Byte-identical is the strongest form of "reproduces every record" there /// is — a field the import dropped, re-derived or reordered shows up as a /// different file rather than as a passing assertion about the half a suite /// remembered to check.+ ///+ /// It is still only a statement about *record fidelity*: two libraries can+ /// re-export the same bytes and both be shapes the writer would refuse. So+ /// the restored library is also put through the strict validator, which is+ /// the only place in this suite a restored library is held to that bar since+ /// `recordedArchiveRestoresIntoAV9Library` went away (Q23). @Test("An export imported into an empty library re-exports the same bytes") func exportImportExportIsByteIdentical() async throws {- let first = try BackupV7Codec.encode(+ let first = try BackupV8Codec.encode( payload: try await Self.exportedPayload(), metadata: BackupGoldenLibrary.metadata) let target = try await M5Fixture() try await target.repository.confirmImport(plan: try BackupImporter.plan(from: first))- let second = try BackupV7Codec.encode(- payload: try await target.repository.backupV7Snapshot(),+ let second = try BackupV8Codec.encode(+ payload: try await target.repository.backupV8Snapshot(), metadata: BackupGoldenLibrary.metadata) #expect(second == first)+ #expect(try await target.repository.strictDiagnoses().isEmpty) } /// Req 9.5's tolerated half, asserted on the restored library rather than on@@ -168,7 +178,7 @@ struct BackupGoldenExportTests { /// detached, and the dismissed pair imports verbatim. @Test("An orphan membership and a dismissed pair survive the restore") func orphansSurviveTheRestore() async throws {- let archive = try BackupV7Codec.encode(+ let archive = try BackupV8Codec.encode( payload: try await Self.exportedPayload(), metadata: BackupGoldenLibrary.metadata) let target = try await M5Fixture()@@ -185,17 +195,17 @@ struct BackupGoldenExportTests { /// Imports the golden archive into a fresh library, seeds the duplicate rows /// no write path produces, and exports what results.- private static func exportedPayload() async throws -> BackupV7Payload {+ private static func exportedPayload() async throws -> BackupV8Payload { let fixture = try await M5Fixture() let plan = try BackupImporter.plan(- from: try BackupV7Codec.encode(+ from: try BackupV8Codec.encode( payload: BackupGoldenLibrary.payload, metadata: BackupGoldenLibrary.metadata)) try await fixture.repository.confirmImport(plan: plan) try await fixture.repository.seedM5Rows( sites: BackupGoldenLibrary.duplicateSites, works: BackupGoldenLibrary.duplicateWorks, entries: BackupGoldenLibrary.duplicateEntries)- return try await fixture.repository.backupV7Snapshot()+ return try await fixture.repository.backupV8Snapshot() } } @@ -230,7 +240,7 @@ extension LibraryRepository { } /// The archive the golden library is built from: one record of every kind the-/// 7/8 payload can hold, with literal identifiers and one literal date.+/// 8/9 payload can hold, with literal identifiers and one literal date. enum BackupGoldenLibrary { static let created = Date(timeIntervalSince1970: 1_000_000) @@ -280,21 +290,20 @@ enum BackupGoldenLibrary { static let workName = "Actual Title" static let titlePrefix = "TtH • Story • " /// The taught site's membership carries a rule-derived URL identity and a- /// Work URL — the only shape the export's version rewrite and the membership- /// identity arm both run over.+ /// Work URL — the shape the membership identity arm runs over. static let workIdentity = "golden.example/story/actual-title" static let workURL = "https://golden.example/story/actual-title" static let secondSiteWorkURL = "https://plain.example/works/actual-title" static let articleTitleSuffix = " - Articles Example" - static var metadata: BackupV7Metadata {- BackupV7Metadata(appBuild: "golden", exportedAt: created)+ static var metadata: BackupV8Metadata {+ BackupV8Metadata(appBuild: "golden", exportedAt: created) } // MARK: The archive - static var payload: BackupV7Payload {- BackupV7Payload(+ static var payload: BackupV8Payload {+ BackupV8Payload( entries: [notedEntry, plainEntry, articleEntry], works: [typedWork, foldedWork, legacyWork], sites: [taughtSite, plainSite, articlesSite],@@ -315,8 +324,8 @@ enum BackupGoldenLibrary { } /// The whole-title rule names the Work by trimming the boilerplate prefix.- private static var pattern: BackupV7TitlePattern {- BackupV7TitlePattern(+ private static var pattern: BackupV8TitlePattern {+ BackupV8TitlePattern( id: patternID, siteHostname: taughtHost, version: 1, isActive: true, createdAt: created, definition: StoredPatternDefinition(@@ -324,8 +333,8 @@ enum BackupGoldenLibrary { } /// The articles site's retained history, and the fixture's only `trimSuffix`.- private static var articlePattern: BackupV7TitlePattern {- BackupV7TitlePattern(+ private static var articlePattern: BackupV8TitlePattern {+ BackupV8TitlePattern( id: articlePatternID, siteHostname: articlesHost, version: 1, isActive: false, createdAt: created, definition: StoredPatternDefinition(@@ -333,8 +342,8 @@ enum BackupGoldenLibrary { } /// A sequence-only query rule extracts "94" from the raw URL.- private static var rule: BackupV7URLRule {- BackupV7URLRule(+ private static var rule: BackupV8URLRule {+ BackupV8URLRule( id: ruleID, version: 1, isCurrent: true, createdAt: created, origin: .readerTaught, definition: .sequence(locator: .query(name: ExactScalarString("chapter"))),@@ -343,31 +352,31 @@ enum BackupGoldenLibrary { /// Carries the `junkSuffixRule` column, which no other site in the fixture /// sets and which is therefore absent from the file altogether without it.- private static var taughtSite: BackupV7Site {- BackupV7Site(+ private static var taughtSite: BackupV8Site {+ BackupV8Site( hostname: taughtHost, displayName: "Golden", mode: .taught, junkSuffixRule: try! JunkSuffixRule( version: 1, anchors: [try! SegmentPositionSpec(origin: .end, offset: 0)])) } - private static var plainSite: BackupV7Site {- BackupV7Site(+ private static var plainSite: BackupV8Site {+ BackupV8Site( hostname: plainHost, displayName: "Plain", mode: .untaught, junkSuffixRule: nil) } /// The third site mode. `.articles` may hold neither an active title rule /// nor a current URL rule, so its retained pattern is inactive — which is /// also where the fixture's `trimSuffix` lives.- private static var articlesSite: BackupV7Site {- BackupV7Site(+ private static var articlesSite: BackupV8Site {+ BackupV8Site( hostname: articlesHost, displayName: "Articles", mode: .articles, junkSuffixRule: nil) } private static func workType( id: UUID, name: String, state: WorkTypeState = .active, canonicalID: UUID? = nil- ) -> BackupV7WorkType {- BackupV7WorkType(+ ) -> BackupV8WorkType {+ BackupV8WorkType( id: id, name: name, stateRaw: state.rawValue, canonicalID: canonicalID, createdAt: created, modifiedAt: created) }@@ -376,8 +385,8 @@ enum BackupGoldenLibrary { /// The multi-site Work's first site: a rule-derived identity, its cited rule /// and a confirmed Work URL.- private static var taughtMembership: BackupV7Membership {- BackupV7Membership(+ private static var taughtMembership: BackupV8Membership {+ BackupV8Membership( id: taughtMembershipID, workID: typedWorkID, hostname: taughtHost, createdAt: created, urlIdentity: workIdentity, urlIdentityState: .rule, urlIdentityRuleID: ruleID, workURLString: workURL)@@ -386,22 +395,22 @@ enum BackupGoldenLibrary { /// Its second site (Req 9.1): a different Work URL, no identity, and no /// Entries at all — a membership that outlives its entries (Req 7.1) is the /// ordinary shape after a cross-site merge.- private static var secondSiteMembership: BackupV7Membership {- BackupV7Membership(+ private static var secondSiteMembership: BackupV8Membership {+ BackupV8Membership( id: secondSiteMembershipID, workID: typedWorkID, hostname: plainHost, createdAt: created.addingTimeInterval(1), urlIdentity: nil, urlIdentityState: .none, urlIdentityRuleID: nil, workURLString: secondSiteWorkURL) } - private static var plainMembership: BackupV7Membership {- BackupV7Membership(+ private static var plainMembership: BackupV8Membership {+ BackupV8Membership( id: plainMembershipID, workID: foldedWorkID, hostname: plainHost, createdAt: created, urlIdentity: nil, urlIdentityState: .none, urlIdentityRuleID: nil, workURLString: nil) } - private static var articleMembership: BackupV7Membership {- BackupV7Membership(+ private static var articleMembership: BackupV8Membership {+ BackupV8Membership( id: articleMembershipID, workID: legacyWorkID, hostname: articlesHost, createdAt: created, urlIdentity: nil, urlIdentityState: .none, urlIdentityRuleID: nil, workURLString: nil)@@ -409,17 +418,17 @@ enum BackupGoldenLibrary { /// Req 8.3, Q22: a membership whose Work has not arrived. It imports /// unattached, keeps the Work it names, and is deleted only with that Work.- private static var orphanMembership: BackupV7Membership {- BackupV7Membership(+ private static var orphanMembership: BackupV8Membership {+ BackupV8Membership( id: orphanMembershipID, workID: absentWorkID, hostname: plainHost, createdAt: created, urlIdentity: "plain.example/absent", urlIdentityState: .legacyUnverified, urlIdentityRuleID: nil, workURLString: nil) } /// Req 5.5: the reader said these two are not the same work.- private static var distinctPair: BackupV7DistinctPair {+ private static var distinctPair: BackupV8DistinctPair { let ids = WorkDistinctPair.sortedIDs(typedWorkID, foldedWorkID)- return BackupV7DistinctPair(+ return BackupV8DistinctPair( id: distinctPairID, lowerWorkID: ids.lower, higherWorkID: ids.higher, recordedAt: created) }@@ -428,8 +437,8 @@ enum BackupGoldenLibrary { /// The configured-type work, the one whose generic notes a coverage /// fingerprint describes, and the fixture's two-site Work.- private static var typedWork: BackupV7Work {- BackupV7Work(+ private static var typedWork: BackupV8Work {+ BackupV8Work( id: typedWorkID, displayTitle: workName, lastParsedTitle: workName, genericNotes: genericNotes, genreTags: ["fantasy"], titleProvenance: .parsed, workTypeID: novelTypeID, typeName: "novel",@@ -439,18 +448,19 @@ enum BackupGoldenLibrary { /// The work citing the **folded** type row, so the import's canonical chase /// and the export's directory both have something to resolve.- private static var foldedWork: BackupV7Work {- BackupV7Work(+ private static var foldedWork: BackupV8Work {+ BackupV8Work( id: foldedWorkID, displayTitle: "Plain Work", lastParsedTitle: nil, genericNotes: "", genreTags: [], titleProvenance: .manual, workTypeID: foldedTypeID, typeName: "novella", createdAt: created, modifiedAt: created) } - /// The untyped work. A pre-feature `typeRaw` is not carried at all from 7/8- /// on (Req 10.3, Q16), so this is what such a Work archives as.- private static var legacyWork: BackupV7Work {- BackupV7Work(+ /// The untyped work. A pre-feature `typeRaw` has not been carried since 7/8+ /// (`multi-site-works` Req 10.3, Q16), so this is what such a Work archives+ /// as.+ private static var legacyWork: BackupV8Work {+ BackupV8Work( id: legacyWorkID, displayTitle: "An Article", lastParsedTitle: nil, genericNotes: "", genreTags: [], titleProvenance: .manual, workTypeID: nil, typeName: nil,@@ -460,14 +470,14 @@ enum BackupGoldenLibrary { // MARK: The entries /// The v3 key embeds host + resolved Work name + sequence.- private static var notedEntry: BackupV7Entry {+ private static var notedEntry: BackupV8Entry { let rawURL = "https://\(taughtHost)/read?chapter=94&x=1" let key = EntryIdentityKeyV3Codec.encode( try! URLSequenceNameIdentity( hostname: ExactScalarString(taughtHost), workName: ExactScalarString(workName), chapterSequence: ExactScalarString("94")))- return BackupV7Entry(+ return BackupV8Entry( id: notedEntryID, captureTitle: titlePrefix + workName, captureTitleSource: .host, rawURL: rawURL, canonicalURL: nil, hostname: taughtHost, entryIdentityKey: key, conservativeIdentityKey: rawURL,@@ -477,18 +487,18 @@ enum BackupGoldenLibrary { modifiedAt: created, workID: typedWorkID, intentionallyUnattached: false, citations: EntryCitations( identity: .composed(- url: CitedRule(id: ruleID, version: 1),- nameTitle: CitedRule(id: patternID, version: 1)),- chapterSequence: CitedRule(id: ruleID, version: 1),- workAssignment: .pattern(CitedRule(id: patternID, version: 1))),+ url: CitedRule(id: ruleID),+ nameTitle: CitedRule(id: patternID)),+ chapterSequence: CitedRule(id: ruleID),+ workAssignment: .pattern(CitedRule(id: patternID))), characterExtractionFingerprint: CharacterCoverageFingerprint.of(entryNote)) } /// The untaught site's Entry: a conservative key, which is what capture /// writes where no rule has been taught.- private static var plainEntry: BackupV7Entry {+ private static var plainEntry: BackupV8Entry { let rawURL = "https://\(plainHost)/read/7"- return BackupV7Entry(+ return BackupV8Entry( id: plainEntryID, captureTitle: "Plain Work", captureTitleSource: .manual, rawURL: rawURL, canonicalURL: nil, hostname: plainHost, entryIdentityKey: rawURL, conservativeIdentityKey: rawURL,@@ -503,9 +513,9 @@ enum BackupGoldenLibrary { /// The articles site's Entry, and the fixture's only `canonicalURL`: a /// capture whose raw URL carried a tracking parameter the canonical form /// drops.- private static var articleEntry: BackupV7Entry {+ private static var articleEntry: BackupV8Entry { let rawURL = "https://\(articlesHost)/posts/hello?utm_source=share"- return BackupV7Entry(+ return BackupV8Entry( id: articleEntryID, captureTitle: "An Article" + articleTitleSuffix, captureTitleSource: .host, rawURL: rawURL, canonicalURL: "https://\(articlesHost)/posts/hello",@@ -520,8 +530,8 @@ enum BackupGoldenLibrary { // MARK: The characters - private static var guide: BackupV7Character {- BackupV7Character(+ private static var guide: BackupV8Character {+ BackupV8Character( id: guideID, workID: typedWorkID, name: "Grover", nameKey: "grover", aliases: ["Klar"], note: "The guide.", facts: [@@ -534,14 +544,14 @@ enum BackupGoldenLibrary { } /// The sync orphan: a character whose work has not arrived.- private static var orphan: BackupV7Character {- BackupV7Character(+ private static var orphan: BackupV8Character {+ BackupV8Character( id: orphanID, workID: nil, name: "The Stranger", nameKey: "the stranger", aliases: [], note: "", facts: [], createdAt: created, modifiedAt: created) } - private static var candidateSuppression: BackupV7Suppression {- BackupV7Suppression(+ private static var candidateSuppression: BackupV8Suppression {+ BackupV8Suppression( id: candidateSuppressionID, workID: typedWorkID, kindRaw: CharacterSuppressionKind.candidate.rawValue, nameKey: "the crowned one", sourceKindRaw: nil, sourceEntryID: nil, evidence: nil,@@ -549,8 +559,8 @@ enum BackupGoldenLibrary { } /// A fact suppression, which is the shape that carries a source and evidence.- private static var factSuppression: BackupV7Suppression {- BackupV7Suppression(+ private static var factSuppression: BackupV8Suppression {+ BackupV8Suppression( id: factSuppressionID, workID: typedWorkID, kindRaw: CharacterSuppressionKind.fact.rawValue, nameKey: "grover", sourceKindRaw: SourceRef.entry(notedEntryID).kindRaw, sourceEntryID: notedEntryID,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swiftindex db94e18..6aef10a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift@@ -36,7 +36,7 @@ struct BackupGroupProjectionTests { store.addEntry(id: shared, key: "chapter-1", capturedAt: 40, sharedAt: 90, title: "Chapter 1") try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.entries.count == 1) let entry = try #require(payload.entries.first)@@ -58,15 +58,15 @@ struct BackupGroupProjectionTests { store.addEntry(id: shared, key: "chapter-1", capturedAt: 40) try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }- let encoded = try BackupV7Codec.encode(+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch)) // The decode gate is the reference validator, which refuses a payload // holding one UUID twice — the shape the projection exists to prevent // reaching it.- let decoded = try BackupV7Codec.decode(encoded)+ let decoded = try BackupV8Codec.decode(encoded) #expect(decoded.payload.entries.count == 1) } @@ -86,7 +86,7 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-2", capturedAt: 20, work: second, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.works.count == 1) let work = try #require(payload.works.first)@@ -114,7 +114,7 @@ struct BackupGroupProjectionTests { store.addEntry(id: entryID, key: "chapter-1", capturedAt: 30, work: second, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.entries.count == 1) let work = try #require(payload.works.first)@@ -147,7 +147,7 @@ struct BackupGroupProjectionTests { store.addEntry(id: shared, key: "chapter-1", capturedAt: 40, work: work, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } let entry = try #require(payload.entries.first) let archivedWork = try #require(payload.works.first)@@ -157,10 +157,10 @@ struct BackupGroupProjectionTests { // names none (Req 9.3), so an Entry that points nowhere is unattached and // nothing contradicts it. #expect(archivedWork.id == work.id)- let encoded = try BackupV7Codec.encode(+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))- _ = try BackupV7Codec.decode(encoded)+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ _ = try BackupV8Codec.decode(encoded) } /// The Definitions' assignment normalisation, in the export (Q106): rows@@ -195,7 +195,7 @@ struct BackupGroupProjectionTests { rowB.editCitations { $0.workAssignment = .manual } try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.entries.count == 1) let entry = try #require(payload.entries.first)@@ -205,10 +205,10 @@ struct BackupGroupProjectionTests { // `entryIDs` lists is gone with the child lists (Req 9.3), and what is // left is the Entry naming one of them. #expect(payload.works.count == 2)- let encoded = try BackupV7Codec.encode(+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))- _ = try BackupV7Codec.decode(encoded)+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ _ = try BackupV8Codec.decode(encoded) } // MARK: - Req 8.3: unique-UUID set members never block export@@ -224,7 +224,7 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 20, note: "from the laptop") try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.entries.count == 2) #expect(Set(payload.entries.map(\.note)) == ["from the phone", "from the laptop"])@@ -242,7 +242,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) } } #expect(payload.count == 1)@@ -268,7 +268,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) } } #expect(payload.count == 2)@@ -288,7 +288,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) } } #expect(payload.count == 1)@@ -321,7 +321,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) } } #expect(payload.count == 1)@@ -357,7 +357,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) } } #expect(payload.count == 2)@@ -393,7 +393,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) } } #expect(payload.count == 2)@@ -414,7 +414,7 @@ struct BackupGroupProjectionTests { try store.commit() _ = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) } } // The resolution outcome: both rows carry the chosen variant (Req@@ -423,7 +423,7 @@ struct BackupGroupProjectionTests { second.note = "from the phone" try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.entries.count == 1) #expect(payload.entries.first?.note == "from the phone") }@@ -441,7 +441,7 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 10, site: first) try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.titlePatterns.count == 1) #expect(payload.sites.count == 1)@@ -450,10 +450,10 @@ struct BackupGroupProjectionTests { // The archive re-decodes: a payload holding one rule UUID twice is what // the reference validator refuses, and what the store validates it must // be able to export (task 20.4).- let encoded = try BackupV7Codec.encode(+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))- _ = try BackupV7Codec.decode(encoded)+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ _ = try BackupV8Codec.decode(encoded) } /// The dedup must not cost a hostname its active title rule.@@ -476,14 +476,14 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 10, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.isActive == true)- let encoded = try BackupV7Codec.encode(+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))- _ = try BackupV7Codec.decode(encoded)+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ _ = try BackupV8Codec.decode(encoded) } /// The URL-rule half, which fails *silently* rather than refusing: nothing@@ -502,7 +502,7 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 10, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.urlRules.count == 1) #expect(payload.urlRules.first?.isCurrent == true)@@ -511,14 +511,14 @@ struct BackupGroupProjectionTests { /// The shape is a **fixed point**, which is what makes the refusal above /// permanent rather than an arrival window. ///- /// `alignVersions` asks per row whether the target version is free on that- /// row's Site, reading live values — so for two rows of one group on one- /// Site row the answer is always no: the target is the representative's- /// version and the representative is sitting on that very row. Decision 13's- /// "never within one Site row", derived rather than special-cased. And- /// `demoteWithinSites` demotes without ever promoting, so the flag stays- /// where it landed. A pass changes nothing, and the next one changes nothing- /// again.+ /// Nothing aligns the two rows' versions any more (T-2281): the column is+ /// advisory, so consolidation converges the *definitions* and leaves the+ /// integers as found. `demoteWithinSites` demotes without ever promoting, so+ /// the flag stays where it landed. A pass changes nothing, and the next one+ /// changes nothing again.+ ///+ /// Req 3.8 rides on the same fixture: the one record the group projects to+ /// carries the version stored on the row the reduction kept, unrenumbered. @Test("A same-Site rule group spanning versions is a fixed point, and exports") func versionSpanningRuleGroupIsAFixedPointAndExports() throws { let store = try DuplicateStore()@@ -536,10 +536,12 @@ struct BackupGroupProjectionTests { #expect(facts.first(where: \.isActive)?.version == 3) #expect(try store.diagnose().quarantineMap().isEmpty) - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.isActive == true)+ // Req 3.8: the version the kept row holds, not a renumbering of it.+ #expect(payload.titlePatterns.first?.version == 3) } /// Even in the benign direction — the active row representing on its own —@@ -556,39 +558,18 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 10, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.isActive == true) } - /// Req 6.2's export half. A citation naming the version the *dropped* row- /// held has nowhere to resolve once the group archives once, so the- /// projection emits `rewrites[ruleUUID] = representative.version` and the- /// citing record is archived against the version the file actually holds.- @Test("A citation naming the dropped row's version is rewritten to the archived one")- func droppedRuleRowVersionIsRewritten() throws {- let store = try DuplicateStore()- let ruleID = UUID()- let first = store.addSite(displayName: "first", mode: .taught)- let second = store.addSite(displayName: "second", mode: .taught)- try store.addPattern(id: ruleID, site: first, version: 1, active: true, createdAt: 0)- try store.addPattern(id: ruleID, site: second, version: 3, active: false, createdAt: 0)- let entry = store.addEntry(key: "chapter-1", capturedAt: 10, site: first)- entry.chapterTitle = "Chapter 1"- entry.editCitations {- $0.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: ruleID, patternVersion: 3)- }- try store.commit()-- let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }-- let pattern = try #require(payload.titlePatterns.first)- let archived = try #require(payload.entries.first)- #expect(archived.citations.chapterTitle.patternID == ruleID)- #expect(archived.citations.chapterTitle.patternVersion == pattern.version)- }+ // `droppedRuleRowVersionIsRewritten` stood here: a citation naming the+ // version the dropped row held had nowhere to resolve once the group+ // archived once, so the projection rewrote it to the representative's+ // version. A citation is the rule's UUID now (T-2281) and the archive holds+ // one record per UUID, so the citation resolves whichever row was kept and+ // there is no rewrite left to pin. /// `citerHostnames` walks every row, not the projected records: a rule whose /// own Site row has not arrived is placed through *any* row that names it,@@ -609,11 +590,11 @@ struct BackupGroupProjectionTests { losing.chapterTitle = "Chapter 1" losing.editCitations { $0.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: ruleID, patternVersion: 1)+ kind: .pattern, patternID: ruleID) } try store.commit() - let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) } let pattern = try #require(payload.titlePatterns.first) #expect(pattern.id == ruleID)@@ -627,7 +608,7 @@ struct BackupGroupProjectionTests { try body() Issue.record("expected a torn-groups refusal, but the export proceeded") return TornGroupsPayload(count: 0, blockingWorkSet: nil)- } catch let error as BackupV7ExportError {+ } catch let error as BackupV8ExportError { guard case .tornGroups(let payload) = error else { Issue.record("expected .tornGroups, got \(error)") return TornGroupsPayload(count: 0, blockingWorkSet: nil)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swiftindex 342aa2a..0a5f4f9 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift@@ -38,7 +38,7 @@ struct BackupGroupRoundTripTests { let entryID = UUID() try await repository.seedSplitEntryGroup(id: entryID) - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() let record = try #require(payload.entries.first) let before = try await repository.entryRows(id: entryID) #expect(before.count == 2)@@ -63,7 +63,7 @@ struct BackupGroupRoundTripTests { let sourceRepository = try await source.open() let entryID = UUID() try await sourceRepository.seedSplitEntryGroup(id: entryID)- let payload = try await sourceRepository.backupV7Snapshot()+ let payload = try await sourceRepository.backupV8Snapshot() let target = try RoundTripEnvironment() let targetRepository = try await target.open()@@ -87,15 +87,15 @@ struct BackupGroupRoundTripTests { let workID = UUID() try await repository.seedSplitWorkGroup(id: workID) - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() let record = try #require(payload.works.first) let before = try await repository.workRows(id: workID) #expect(before.count == 2) #expect(record.modifiedAt == before.map(\.modifiedAt).max()) #expect(record.genericNotes == "phone notes") // The Entries are the union across every row (Req 5.5): an Entry hanging- // off the losing row must not vanish from the Work it belongs to. A 7/8- // Work names none of them (Req 9.3), so the claim is asserted where the+ // off the losing row must not vanish from the Work it belongs to. An+ // archive Work names none of them, so the claim is asserted where the // reference now lives. #expect(payload.entries.count(where: { $0.workID == workID }) == 2) @@ -117,7 +117,7 @@ struct BackupGroupRoundTripTests { let sourceRepository = try await source.open() let workID = UUID() try await sourceRepository.seedSplitWorkGroup(id: workID)- let payload = try await sourceRepository.backupV7Snapshot()+ let payload = try await sourceRepository.backupV8Snapshot() let target = try RoundTripEnvironment() let targetRepository = try await target.open()@@ -129,6 +129,140 @@ struct BackupGroupRoundTripTests { #expect(rows.first?.entryCount == 2) #expect(rows.first?.createdAt == Self.epoch.addingTimeInterval(5)) }++ // MARK: - Citations and rule rows (Req 3.8, 5.1, 5.3)++ /// The 8/9 claim, end to end: what an archive says about provenance is what+ /// a library restored from it holds.+ ///+ /// The fixture carries the two shapes the version invariant used to refuse —+ /// a retired title rule at a *higher* version than the marked one, and a+ /// retired URL rule likewise — so a surviving renumbering pass would show up+ /// as a version that moved rather than as a refusal. Nothing renumbers, and+ /// nothing rewrites a citation: the Entry's citations decode equal, and each+ /// rule row keeps its `(id, isMarked, createdAt, version)`.+ @Test("An export re-imported into an empty library keeps its citations and rule rows")+ func citationsAndRuleRowsSurviveAnImportIntoAnEmptyLibrary() async throws {+ let payload = Self.versionSpreadPayload()++ let source = try RoundTripEnvironment()+ let sourceRepository = try await source.open()+ _ = try await sourceRepository.confirmImport(plan: RoundTripEnvironment.plan(payload))++ let exported = try await sourceRepository.backupV8Snapshot()++ let target = try RoundTripEnvironment()+ let targetRepository = try await target.open()+ _ = try await targetRepository.confirmImport(plan: RoundTripEnvironment.plan(exported))++ // Req 5.3's citation half, as decoded values rather than as bytes.+ let original = try #require(payload.entries.first?.citations)+ let citations = try await targetRepository.citations(entryID: BackupV8Fixtures.entryID)+ #expect(citations == original)+ // Named, so the comparison above cannot pass on two empty values.+ #expect(citations?.chapterSequence?.id == Self.citedURLRuleID)++ // Req 5.3's rule half, read off the restored rows.+ let rules = try await targetRepository.ruleRows(hostname: "example.com")+ #expect(rules.patterns == Self.expectedPatternRows(payload))+ #expect(rules.urlRules == Self.expectedURLRuleRows(payload))+ }++ /// Req 3.2, past the codec: the two shapes the retired version invariant+ /// used to refuse are not merely decodable, they **commit**, and the rows+ /// land holding the versions the archive named.+ ///+ /// `BackupV8ArchiveTests` stops at a decode, which only proves the reference+ /// checks let the file through. Here the same fixture goes through+ /// `confirmImport` into an empty library: two title patterns share version 1+ /// with one of them marked, and the marked URL rule sits *below* a retired+ /// one. A surviving invariant would refuse the commit; a surviving+ /// renumbering pass would admit it and move the versions.+ @Test("An archive with duplicate and non-greatest rule versions imports with its versions")+ func duplicateAndNonGreatestVersionsImportIntoAnEmptyLibrary() async throws {+ let payload = BackupV8Fixtures.duplicateVersionsPayload()+ let host = try #require(payload.sites.first?.hostname)++ let target = try RoundTripEnvironment()+ let repository = try await target.open()+ let result = try await repository.confirmImport(plan: RoundTripEnvironment.plan(payload))++ guard case .committed(let counts) = result else {+ Issue.record("expected a committed import, got \(result)")+ return+ }+ #expect(counts.titlePatterns == 2)+ #expect(counts.urlRulePatterns == 2)++ let rules = try await repository.ruleRows(hostname: host)+ #expect(rules.patterns == Self.expectedPatternRows(payload))+ #expect(rules.urlRules == Self.expectedURLRuleRows(payload))+ // Named, so neither comparison above can pass on a shape that has lost+ // the point of the fixture.+ #expect(rules.patterns.map(\.version) == [1, 1])+ #expect(rules.patterns.count(where: \.isMarked) == 1)+ let markedRule = try #require(rules.urlRules.first { $0.isMarked })+ let retiredRule = try #require(rules.urlRules.first { !$0.isMarked })+ #expect(markedRule.version == 2)+ #expect(retiredRule.version == 9)+ }++ /// The URL rule `composedPayload`'s Entry cites for its sequence.+ private static let citedURLRuleID = UUID(+ uuidString: "dddddddd-dddd-dddd-dddd-dddddddddddd")!++ private static func expectedPatternRows(_ payload: BackupV8Payload) -> [RuleRowFacts] {+ payload.titlePatterns+ .map {+ RuleRowFacts(+ id: $0.id, isMarked: $0.isActive, createdAt: $0.createdAt,+ version: $0.version)+ }+ .sorted { $0.id.uuidString < $1.id.uuidString }+ }++ private static func expectedURLRuleRows(_ payload: BackupV8Payload) -> [RuleRowFacts] {+ payload.urlRules+ .map {+ RuleRowFacts(+ id: $0.id, isMarked: $0.isCurrent, createdAt: $0.createdAt,+ version: $0.version)+ }+ .sorted { $0.id.uuidString < $1.id.uuidString }+ }++ /// `composedPayload` — one taught Site, one cited title rule, one cited URL+ /// rule, one Entry citing both — plus a retired row of each kind sitting at+ /// a higher version than the marked one.+ private static func versionSpreadPayload() -> BackupV8Payload {+ let base = BackupV8Fixtures.composedPayload()+ let host = "example.com"+ let retired = BackupV8Fixtures.created.addingTimeInterval(-60)++ let retiredPattern = BackupV8TitlePattern(+ id: UUID(uuidString: "cccccccc-cccc-cccc-cccc-ccccccccccc9")!,+ siteHostname: host, version: 9, isActive: false, createdAt: retired,+ definition: StoredPatternDefinition(definition: .wholeTitle))+ let retiredRule = BackupV8URLRule(+ id: UUID(uuidString: "dddddddd-dddd-dddd-dddd-ddddddddddd9")!,+ version: 7, isCurrent: false, createdAt: retired, origin: .readerTaught,+ definition: .sequence(locator: .query(name: ExactScalarString("part"))),+ siteHostname: host)++ return BackupV8Payload(+ entries: base.entries, works: base.works, sites: base.sites,+ titlePatterns: base.titlePatterns + [retiredPattern],+ urlRules: base.urlRules + [retiredRule],+ workTypes: base.workTypes, memberships: base.memberships)+ }+}++/// A rule row as Req 5.3 names it, for either kind.+private struct RuleRowFacts: Equatable, Sendable {+ let id: UUID+ let isMarked: Bool+ let createdAt: Date+ let version: Int } // MARK: - Fixtures@@ -154,10 +288,10 @@ private struct RoundTripEnvironment { /// The plan `confirmImport` takes, straight off a payload the export just /// produced — which is what a reader restoring their own backup hands it.- static func plan(_ payload: BackupV7Payload) -> BackupImportPlan {+ static func plan(_ payload: BackupV8Payload) -> BackupImportPlan { BackupImportPlan( metadata: BackupImportMetadata(- formatVersion: 7, schemaVersion: 8, appBuild: "test-1.0",+ formatVersion: 8, schemaVersion: 9, appBuild: "test-1.0", exportedAt: BackupGroupRoundTripTests.epoch, capabilityGate: "multi-site", entryCount: payload.entries.count, workCount: payload.works.count), payload: payload,@@ -254,6 +388,41 @@ extension LibraryRepository { } } + /// The Entry's citations as a decoded value, which is what Req 5.3 compares.+ fileprivate func citations(entryID: UUID) async throws -> EntryCitations? {+ try await withLockedContext(mode: .shared, operation: "reading Entry citations") {+ context in+ try context.fetch(FetchDescriptor<Entry>(predicate: #Predicate { $0.id == entryID }))+ .first?.citations+ }+ }++ /// Both rule kinds for a hostname, ordered by identifier so the comparison+ /// does not depend on fetch order.+ fileprivate func ruleRows(hostname: String) async throws+ -> (patterns: [RuleRowFacts], urlRules: [RuleRowFacts])+ {+ try await withLockedContext(mode: .shared, operation: "reading rule rows") { context in+ let sites = try context.fetch(+ FetchDescriptor<Site>(predicate: #Predicate { $0.hostname == hostname }))+ let patterns = sites.flatMap(\.patternValues)+ .map {+ RuleRowFacts(+ id: $0.id, isMarked: $0.isActive, createdAt: $0.createdAt,+ version: $0.version)+ }+ .sorted { $0.id.uuidString < $1.id.uuidString }+ let urlRules = sites.flatMap(\.urlRuleValues)+ .map {+ RuleRowFacts(+ id: $0.id, isMarked: $0.isCurrent, createdAt: $0.createdAt,+ version: $0.version)+ }+ .sorted { $0.id.uuidString < $1.id.uuidString }+ return (patterns, urlRules)+ }+ }+ fileprivate func workRows(id: UUID) async throws -> [WorkRowFacts] { try await withLockedContext(mode: .shared, operation: "reading Work rows") { context in try context.fetch(FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex 03006ba..6c69a4e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -384,6 +384,52 @@ struct BackupImportTransactionTests { private static let lowerPatternID = UUID(uuidString: "00000000-0000-0000-0000-0000000000C1")! private static let higherPatternID = UUID(uuidString: "00000000-0000-0000-0000-0000000000D2")! + /// Req 3.7, the import half: an archive's marked title rule joining a marked+ /// one the library already holds leaves **two** marked rules on one row, and+ /// the union demotes all but the Decision 1 pick.+ ///+ /// The two halves this pins are the ones that could each have gone the other+ /// way. *Which* row stays marked is decided by `createdAt`, not by the id and+ /// not by arrival order: the archived rule is the newer one and carries the+ /// **higher** UUID, so a pick that fell back on the id tiebreak — or one that+ /// just kept the row already in the store — marks the other row. And nothing+ /// is renumbered: both rows keep the version their own writer stamped, the+ /// stored 4 and the archived 1, even though the surviving marked rule is now+ /// the lower-numbered of the two. A version orders nothing since T-2281, so+ /// there is nothing for the merge to reconcile.+ @Test("An archived marked rule joining a marked one demotes to the newest")+ func importedMarkedRuleDemotesTheOlderOne() async throws {+ let env = try TestEnvironment()+ try createReadySiteStore(+ at: env.configuration, hostname: "novels.example", mode: .taught,+ activePattern: true, patternID: Self.storedPatternID, patternVersion: 4)+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)++ _ = try await repository.confirmImport(+ plan: try makeSiteDesignationPlan(+ hostname: "novels.example", mode: .taught, activePattern: true,+ patternID: Self.archivedPatternID, patternVersion: 1))++ #expect(+ try await repository.patternFacts() == [+ ImportedPatternFacts(id: Self.storedPatternID, version: 4, isActive: false),+ ImportedPatternFacts(id: Self.archivedPatternID, version: 1, isActive: true),+ ])+ // One row, one marked rule: the tuple the validator accepts, so the+ // library the reader is handed back is one the app can open.+ #expect(+ try await repository.siteFacts() == [+ SiteFacts(+ hostname: "novels.example", displayName: "novels.example", mode: .taught,+ junkSuffixRule: nil, activePatterns: 1)+ ])+ }++ /// The stored rule sorts **below** the archived one, so the id tiebreak and+ /// the recency rule disagree about which stays marked.+ private static let storedPatternID = UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!+ private static let archivedPatternID = UUID(uuidString: "00000000-0000-0000-0000-0000000000F2")!+ @Test("Re-importing the same archive changes nothing") func reimportIsIdempotent() async throws { let env = try TestEnvironment()@@ -714,7 +760,9 @@ private func createReadySiteStore( displayName: String? = nil, mode: SiteMode, junkSuffixRule: JunkSuffixRule? = nil,- activePattern: Bool = false+ activePattern: Bool = false,+ patternID: UUID? = nil,+ patternVersion: Int = 1 ) throws { let fileManager = FileManager.default try fileManager.createDirectory(@@ -740,7 +788,9 @@ private func createReadySiteStore( site.junkSuffixRule = junkSuffixRule if activePattern { let pattern = try TitlePattern(- version: 1, isActive: true, createdAt: Date(timeIntervalSince1970: 1000),+ id: patternID ?? UUID(),+ version: patternVersion, isActive: true,+ createdAt: Date(timeIntervalSince1970: 1000), definition: .wholeTitle, site: site) context.insert(pattern) }@@ -809,26 +859,29 @@ private func makeSiteDesignationPlan( displayName: String? = nil, mode: SiteMode, junkSuffixRule: JunkSuffixRule? = nil,- activePattern: Bool = false+ activePattern: Bool = false,+ patternID: UUID = UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!,+ patternVersion: Int = 1 ) throws -> BackupImportPlan { let epoch = Date(timeIntervalSince1970: 1_800_000_000)- let site = BackupV7Site(+ let site = BackupV8Site( hostname: hostname, displayName: displayName ?? hostname, mode: mode, junkSuffixRule: junkSuffixRule)- let patterns: [BackupV7TitlePattern] = activePattern+ let patterns: [BackupV8TitlePattern] = activePattern ? [- BackupV7TitlePattern(+ BackupV8TitlePattern( // 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,+ id: patternID,+ siteHostname: hostname, version: patternVersion, 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,+ formatVersion: 8, schemaVersion: 9, appBuild: "test-1.0", exportedAt: epoch, capabilityGate: "multi-site", entryCount: 0, workCount: 0) return BackupImportPlan( metadata: metadata, payload: payload,@@ -842,11 +895,11 @@ private func makeSiteDesignationPlan( private func makeBulkImportPlan(entryCount: Int) throws -> BackupImportPlan { let hostname = "bulk.example" let epoch = Date(timeIntervalSince1970: 1_800_000_000)- let site = BackupV7Site(+ let site = BackupV8Site( hostname: hostname, displayName: hostname, mode: .untaught, junkSuffixRule: nil)- let entries = (0..<entryCount).map { index -> BackupV7Entry in+ let entries = (0..<entryCount).map { index -> BackupV8Entry in let rawURL = "https://\(hostname)/read?chapter=\(index)"- return BackupV7Entry(+ return BackupV8Entry( id: UUID(), captureTitle: "Chapter \(index)", captureTitleSource: .host, rawURL: rawURL, canonicalURL: nil, hostname: hostname, entryIdentityKey: rawURL,@@ -860,7 +913,7 @@ private func makeBulkImportPlan(entryCount: Int) throws -> BackupImportPlan { let payload = BackupImportPayload( entries: entries, works: [], sites: [site], titlePatterns: [], urlRules: []) let metadata = BackupImportMetadata(- formatVersion: 7, schemaVersion: 8, appBuild: "test-1.0", exportedAt: epoch,+ formatVersion: 8, schemaVersion: 9, appBuild: "test-1.0", exportedAt: epoch, capabilityGate: "multi-site", entryCount: entryCount, workCount: 0) return BackupImportPlan( metadata: metadata, payload: payload,@@ -900,9 +953,9 @@ private func makeMinimalImportPlan( let epoch = Date(timeIntervalSince1970: 1000000) let rawURL = "https://imported.example.com/chapter/1" let patternProvenance = try FieldProvenance(- kind: .pattern, patternID: patternID, patternVersion: 1)+ kind: .pattern, patternID: patternID) - let entry = BackupV7Entry(+ let entry = BackupV8Entry( id: entryID, captureTitle: "Imported Chapter", captureTitleSource: .networkFetch,@@ -925,10 +978,10 @@ private func makeMinimalImportPlan( intentionallyUnattached: false, citations: EntryCitations( chapterTitle: patternProvenance,- workAssignment: .pattern(CitedRule(id: patternID, version: 1)))+ workAssignment: .pattern(CitedRule(id: patternID))) ) - let work = BackupV7Work(+ let work = BackupV8Work( id: workID, displayTitle: "Imported Work", lastParsedTitle: "Imported Work",@@ -943,7 +996,7 @@ private func makeMinimalImportPlan( // Req 9.1: the Work's site presence is its membership, and Req 9.5 requires // one on the Entry's hostname.- let membership = BackupV7Membership(+ let membership = BackupV8Membership( // Derived from the Work rather than minted: two archives *of one // library* carry the same membership row, which is what makes a // re-import an update rather than a second row on the same hostname.@@ -957,7 +1010,7 @@ private func makeMinimalImportPlan( workURLString: workURL ) - let pattern = BackupV7TitlePattern(+ let pattern = BackupV8TitlePattern( id: patternID, siteHostname: siteHostname, version: 1,@@ -969,8 +1022,8 @@ private func makeMinimalImportPlan( ignored: [])) ) - let urlRules: [BackupV7URLRule] = includeURLRule ? [- BackupV7URLRule(+ let urlRules: [BackupV8URLRule] = includeURLRule ? [+ BackupV8URLRule( id: urlRuleID, version: 1, isCurrent: true,@@ -986,7 +1039,7 @@ private func makeMinimalImportPlan( ) ] : [] - let site = BackupV7Site(+ let site = BackupV8Site( hostname: siteHostname, displayName: siteHostname, mode: .taught,@@ -994,8 +1047,8 @@ private func makeMinimalImportPlan( ) let metadata = BackupImportMetadata(- formatVersion: 7,- schemaVersion: 8,+ formatVersion: 8,+ schemaVersion: 9, appBuild: "test-1.0", exportedAt: epoch, capabilityGate: "multi-site",@@ -1075,6 +1128,14 @@ private struct SiteFacts: Equatable, Sendable { var activePatterns: Int } +/// One title-rule row after an import: which it is, the version it kept, and+/// whether the union left it marked.+private struct ImportedPatternFacts: Equatable, Sendable {+ var id: UUID+ var version: Int+ var isActive: Bool+}+ // MARK: - Repository probes extension LibraryRepository {@@ -1103,6 +1164,16 @@ extension LibraryRepository { } } + /// Every title rule the library holds, by id — what the union marked and+ /// what version each row kept.+ fileprivate func patternFacts() async throws -> [ImportedPatternFacts] {+ try await withLockedContext(mode: .shared, operation: "reading patterns") { context in+ try context.fetch(FetchDescriptor<TitlePattern>())+ .map { ImportedPatternFacts(id: $0.id, version: $0.version, isActive: $0.isActive) }+ .sorted { $0.id.uuidString < $1.id.uuidString }+ }+ }+ fileprivate func membershipFacts() async throws -> [MembershipFacts] { try await withLockedContext(mode: .shared, operation: "reading memberships") { context in try context.fetch(FetchDescriptor<WorkSiteMembership>())
(diff fragment 'diff-Packages_AsterismCore_Tests_AsterismCoreTests_{BackupV7ArchiveTests.swift => BackupV8ArchiveTests.swift}.txt' missing)
(diff fragment 'diff-Packages_AsterismCore_Tests_AsterismCoreTests_{BackupV7Fixtures.swift => BackupV8Fixtures.swift}.txt' missing)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swiftindex 74a16bb..9a6ea87 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift@@ -296,8 +296,8 @@ struct CharacterConvergenceTests { M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "reckless", workID: Self.workID), ]) - await #expect(throws: BackupV7ExportError.self) {- _ = try await fixture.repository.backupV7Snapshot()+ await #expect(throws: BackupV8ExportError.self) {+ _ = try await fixture.repository.backupV8Snapshot() } withExtendedLifetime(fixture) {} }@@ -379,7 +379,7 @@ struct CharacterCitationRepointingTests { /// The reconciler derives the stamp from the collapsing Entries rather than /// a clock (Q56), so it is routinely *older* than the character it rewrites.- /// `CharacterGroup.modifiedAt` is what `BackupV7Character` carries as its+ /// `CharacterGroup.modifiedAt` is what `BackupV8Character` carries as its /// import value guard, so a backwards stamp would let an archive taken /// before the character's last edit overwrite it. @Test("Repointing never moves a character's modifiedAt backwards")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swiftindex 12ac13b..5559a00 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift@@ -41,7 +41,7 @@ struct CitationBlobRefreshTests { entry.chapterTitle = "Chapter 7" #expect(entry.editCitations { $0.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: UUID(), patternVersion: 1)+ kind: .pattern, patternID: UUID()) }) #expect(entry.citationsData != minted) #expect(entry.chapterTitleProvenance == .pattern)@@ -91,23 +91,25 @@ struct CitationBlobRefreshTests { } /// A nil blob is **not** undecodable: an edit that changes nothing leaves it- /// nil (Q23). `SiteReconciler.rewriteCitations` walks every Entry on a- /// consolidation and rewrites nothing in the default value, so a converting- /// `editCitations` would zero `entriesWithoutCitationBlobCount` — the one- /// counter that tells the reader which rows lost their provenance — on the- /// first reconcile after the upgrade.- @Test("A citation rewrite over a nil-blob Entry leaves the blob nil")- func rewritingCitationsLeavesANilBlobNil() throws {+ /// nil (Q23), which is what keeps `entriesWithoutCitationBlobCount` — the+ /// counter telling the reader which rows lost their provenance — from being+ /// zeroed by a pass that walks every Entry.+ ///+ /// `SiteReconciler.rewriteCitations` was that pass and is gone with the+ /// version machinery (T-2281): no bulk walk touches citations any more, so+ /// what is left to pin is the value guard itself.+ @Test("A no-op edit over a nil-blob Entry leaves the blob nil")+ func aNoOpEditLeavesANilBlobNil() throws { let store = try BlobStore() let entry = store.insertEntry(hostname: host, seconds: 10) entry.citationsData = nil try store.save() - #expect(!SiteReconciler.rewriteCitations(of: entry, [UUID(): 3]))+ #expect(!entry.editCitations { _ in }) #expect(entry.citationsData == nil) - // A rewrite over a *real* nil-blob-read edit still writes: it is only- // the no-op that leaves the row alone.+ // A *real* edit over a nil-blob read still writes: it is only the no-op+ // that leaves the row alone. #expect(entry.editCitations { $0.workAssignment = .manual }) #expect(entry.citationsData != nil) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swiftindex abc8514..b55c13c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift@@ -34,22 +34,23 @@ struct CitationResolutionParityTests { // MARK: - Validator: the superseded-rule cases (Req 4.1) /// Chapter provenance cites the title pattern that produced it, not the- /// site's active one. Retaining v1 and activating v2 must leave the v1- /// citation resolving — under the union because the site's rows own it,- /// under the relationship because `entry.site` owns it.- @Test("A superseded title pattern keeps resolving at chapter provenance")+ /// site's active one. Retaining the cited row and activating a replacement+ /// must leave the citation resolving — and since T-2281 that has to hold+ /// **whatever version either row carries** (Req 2.1): the replacement here+ /// is minted at the same version the cited row holds, which the retired+ /// Site-unique-version invariant refused outright.+ @Test("A superseded title pattern keeps resolving at chapter provenance, at any row version") func supersededPatternResolvesAtChapterProvenance() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() fixture.titlePattern.isActive = false- let v2 = try TitlePattern(- version: 2, isActive: true, createdAt: Self.epoch,+ let replacement = try TitlePattern(+ version: fixture.titlePattern.version, isActive: true, createdAt: Self.epoch, definition: .phrase(prefix: "", separator: " — ", suffix: "", order: .chapterThenWork), site: fixture.site)- fixture.site.patterns = fixture.site.patternValues + [v2]- // The Entry still cites (v1 id, version 1) for its chapter.+ fixture.site.patterns = fixture.site.patternValues + [replacement]+ // The Entry still cites the superseded rule for its chapter. #expect( fixture.entry.citationValues.chapterTitle.patternID == fixture.titlePattern.id)- #expect(fixture.entry.citationValues.chapterTitle.patternVersion == 1) let diagnostics = try LibraryValidator.validate(graph: fixture.graph) #expect(diagnostics.quarantineMap().isEmpty)@@ -57,16 +58,17 @@ struct CitationResolutionParityTests { } /// The v3 identity's name contributor is replayed from the *cited* pattern- /// (Req 4.2), so superseding it must not break the replay.- @Test("A superseded name contributor keeps resolving at the v3 identity")+ /// (Req 4.2), so superseding it must not break the replay — again with the+ /// replacement sharing the cited row's version (Req 3.1).+ @Test("A superseded name contributor keeps resolving at the v3 identity, at any row version") func supersededNameContributorResolvesAtV3Identity() throws { let fixture = try ValidatorFixtures.wholeTitleSequence() fixture.titlePattern.isActive = false- let v2 = try TitlePattern(- version: 2, isActive: true, createdAt: Self.epoch,+ let replacement = try TitlePattern(+ version: fixture.titlePattern.version, isActive: true, createdAt: Self.epoch, definition: .wholeTitle, site: fixture.site)- fixture.site.patterns = fixture.site.patternValues + [v2]- #expect(fixture.entry.citationValues.identity.nameTitleRule?.version == 1)+ fixture.site.patterns = fixture.site.patternValues + [replacement]+ #expect(fixture.entry.citationValues.identity.nameTitleRule?.id == fixture.titlePattern.id) let diagnostics = try LibraryValidator.validate(graph: fixture.graph) #expect(diagnostics.quarantineMap().isEmpty)@@ -77,18 +79,24 @@ struct CitationResolutionParityTests { /// references all cite the superseded URL rule; a new current rule must not /// unresolve any of them. Covers both `resolves(citedRule:…)` sites — the /// Work rule identity and `requiredReference`.+ ///+ /// The replacement is minted *below* the retained row's version, which the+ /// retired "current URL rule must have the greatest retained version"+ /// invariant refused (Req 3.1): a citation resolves on its UUID and the row+ /// versions take no part. @Test("A superseded URL rule keeps resolving at the Work identity and requiredReference") func supersededURLRuleResolvesAtBothRuleSites() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() fixture.rule.isCurrent = false- let v3 = try URLRulePattern(- version: 3, isCurrent: true, createdAt: Self.epoch, origin: .readerTaught,+ let replacement = try URLRulePattern(+ version: fixture.rule.version - 1, isCurrent: true, createdAt: Self.epoch,+ origin: .readerTaught, definition: .workAndSequence( work: URLFieldSelector(locator: .query(name: ExactScalarString("series"))), sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter")))), site: fixture.site)- fixture.site.urlRules = fixture.site.urlRuleValues + [v3]- #expect(fixture.entry.citationValues.identity.urlRule?.version == 2)+ fixture.site.urlRules = fixture.site.urlRuleValues + [replacement]+ #expect(fixture.entry.citationValues.identity.urlRule?.id == fixture.rule.id) let diagnostics = try LibraryValidator.validate(graph: fixture.graph) #expect(diagnostics.quarantineMap().isEmpty)@@ -104,9 +112,9 @@ struct CitationResolutionParityTests { /// `LibraryValidatorNilSiteToleranceTests` (the "populated site still diagnoses" /// pair); these cover the rule helper's two sites. /// **The Work identity is no longer a cited-rule site** (V8, Req 10.4/Q28).- /// A `WorkSiteMembership` cites its rule by UUID alone and carries no+ /// A `WorkSiteMembership` cites its rule row by UUID alone and carries no /// version, so there is no `(id, version)` pair to resolve and no version to- /// mismatch — the version for display resolves by row lookup at read time.+ /// mismatch. /// The retained column that kept a stale value beside it went at V9. @Test("The Work rule identity no longer resolves a cited version at all") func workIdentityIsNoLongerACitedRuleSite() throws {@@ -118,18 +126,90 @@ struct CitationResolutionParityTests { #expect(diagnostics.tupleDiagnoses.isEmpty) } - @Test("A version mismatch on the assignment reference is diagnosed, not resolved")- func ruleVersionMismatchDiagnosesAtRequiredReference() throws {- let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()- fixture.entry.citeWorkURLRule(version: 99)+ // `ruleVersionMismatchDiagnosesAtRequiredReference` stood here: a right id+ // at a version the Site never retained was a diagnosis. A citation is the+ // UUID alone since T-2281, so the state it named cannot be written.++ // MARK: - Several rows carrying the cited UUID (Req 2.6, Q6)++ /// A cited UUID can name more than one row of the citing Site — the+ /// same-UUID group T-2282 owns. This spec only has to be **deterministic**,+ /// so resolution picks the row the existing rule-row group ordering ranks+ /// first, and picks it on every read whatever order the store hands the+ /// to-many over.+ ///+ /// The two rows carry different definitions, which is what makes the pick+ /// observable: the representative is the segment rule, which replays+ /// "A Cited Work"; the other row is whole-title, which would replay the+ /// entire capture title.+ @Test("A same-UUID group resolves to the group representative, in any row order")+ func sameUUIDGroupResolvesToTheRepresentative() throws {+ let shared = UUID()+ let site = Site(hostname: "group.example")+ site.mode = .taught+ let representativeRow = try TitlePattern(+ id: shared, version: 1, isActive: true, createdAt: Self.epoch,+ definition: .segmented, site: site)+ let twin = try TitlePattern(+ id: shared, version: 2, isActive: false,+ createdAt: Self.epoch.addingTimeInterval(100),+ definition: .wholeTitle, site: site)+ #expect(+ GroupOrdering.representativePattern([representativeRow, twin])?.version+ == representativeRow.version,+ "the fixture's assumption about which row the group ordering ranks first is wrong")++ for rows in [[representativeRow, twin], [twin, representativeRow]] {+ site.patterns = rows+ let entry = Entry(+ captureTitle: "A Cited Work - Chapter 3", captureTitleSource: .host,+ rawURLString: "https://group.example/read", hostname: "group.example",+ entryIdentityKey: "https://group.example/read", timestamp: Self.epoch)+ entry.conservativeIdentityKey = "https://group.example/read"+ entry.site = site+ entry.editCitations { $0.workAssignment = .pattern(CitedRule(id: shared)) }++ let replay = LibraryRepository.replayCitedPattern(+ for: try LibraryRepository.snapshot(entry), citingSite: { site })++ #expect(replay == .replayed("A Cited Work"))+ }+ } - let diagnostics = try LibraryValidator.validate(graph: fixture.graph)- #expect(diagnostics.quarantineMap()[fixture.site.hostname] != nil)+ /// The one cited-rule site that is a bare dictionary lookup rather than a+ /// resolution helper: a membership citing a rule the Site does not hold+ /// still degrades to `.legacyUnverified` rather than refusing the read+ /// (Q54, Q72). Deleting the version from `URLRuleReference` must not have+ /// changed that.+ @Test("The Work identity snapshot still derives legacyUnverified for an unknown rule id")+ func workMergeSnapshotDerivesLegacyUnverifiedForAnUnknownID() throws {+ let site = Site(hostname: "merge.example")+ let rule = try URLRulePattern(+ version: 1, isCurrent: true, createdAt: Self.epoch, origin: .readerTaught,+ definition: .sequence(locator: .query(name: ExactScalarString("chapter"))), site: site)+ let work = Work(displayTitle: "A Work", timestamp: Self.epoch)+ let membership = WorkSiteMembership(+ hostname: "merge.example", createdAt: Self.epoch, workID: work.id, work: work,+ site: site)+ membership.urlIdentity = "42"+ membership.urlIdentityState = .rule+ membership.urlIdentityRuleID = UUID() // no row carries it++ let unknown = LibraryRepository.identitySnapshot(of: membership, rules: [rule])+ #expect(unknown.state == .legacyUnverified)+ #expect(unknown.ruleReference == nil)++ // The control: a cited id the Site does hold resolves, and the+ // reference is the UUID alone.+ membership.urlIdentityRuleID = rule.id+ let known = LibraryRepository.identitySnapshot(of: membership, rules: [rule])+ #expect(known.state == .rule)+ #expect(known.ruleReference == URLRuleReference(id: rule.id)) } // MARK: - Recent's candidate replay (Req 4.1, 4.2) - /// The replay resolves the cited `(id, version)`, never the active pattern.+ /// The replay resolves the cited id, never the active pattern. /// The superseded v1 is a segment rule producing "A Cited Work"; the active /// v2 is whole-title, which would produce the entire capture title — so a /// resolution that reached for the active pattern is distinguishable from@@ -148,7 +228,7 @@ struct CitationResolutionParityTests { let entry = store.insertEntry( hostname: "cited.example", title: "A Cited Work - Chapter 3") entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: citedID, version: 1))+ $0.workAssignment = .pattern(CitedRule(id: citedID)) } } let repository = try await library.openForApp()@@ -160,30 +240,9 @@ struct CitationResolutionParityTests { #expect(row.attention == nil) } - @Test("Recent renders a right-id wrong-version citation as unresolvable")- func recentVersionMismatchRendersUnresolvable() async throws {- let library = try ParityFixture()- let citedID = UUID()- try library.seed { store in- let site = store.insertSite(hostname: "cited.example")- site.mode = .taught- try store.insertTitlePattern(- id: citedID, site: site, isActive: true, version: 1,- definition: .segmented)- let entry = store.insertEntry(- hostname: "cited.example", title: "A Cited Work - Chapter 3")- entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: citedID, version: 99))- }- }- let repository = try await library.openForApp()-- let presentation = try await repository.recentPresentation(calendar: .current)-- let row = try #require(presentation.allRows.first)- #expect(row.attention == .citationUnresolved)- #expect(row.unresolvedCandidateTitle == nil)- }+ // `recentVersionMismatchRendersUnresolvable` stood here, for the same+ // reason: Recent rendered a right-id wrong-version citation as+ // unresolvable. There is no wrong version to cite any more. /// A record whose Site never arrived: resolution yields nothing, and the /// caller renders instead of throwing (Req 3.4). The citation's evidence is@@ -197,7 +256,7 @@ struct CitationResolutionParityTests { let orphan = store.insertEntry( hostname: "orphan.example", title: "orphaned citer") orphan.editCitations {- $0.workAssignment = .pattern(CitedRule(id: UUID(), version: 1))+ $0.workAssignment = .pattern(CitedRule(id: UUID())) } } let repository = try await library.openForApp()@@ -230,7 +289,7 @@ struct CitationResolutionParityTests { let entry = store.insertEntry( id: entryID, hostname: "cited.example", title: "A Cited Work - Chapter 3") entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: citedID, version: 1))+ $0.workAssignment = .pattern(CitedRule(id: citedID)) } } let repository = try await library.openForApp()@@ -252,7 +311,7 @@ struct CitationResolutionParityTests { let orphan = store.insertEntry( id: entryID, hostname: "orphan.example", title: "Orphaned Citer") orphan.editCitations {- $0.workAssignment = .pattern(CitedRule(id: citedID, version: 1))+ $0.workAssignment = .pattern(CitedRule(id: citedID)) } } let repository = try await library.openForApp()@@ -264,9 +323,8 @@ struct CitationResolutionParityTests { #expect(detail.displayTitle == "Orphaned Citer") // The citation is retained as evidence, whatever settlement wording the // screen chooses for it.- if case .patternUnsettled(let patternID, let version, _) = detail.assignmentSettlement {+ if case .patternUnsettled(let patternID, _) = detail.assignmentSettlement { #expect(patternID == citedID)- #expect(version == 1) } else { Issue.record("expected a pattern settlement carrying the cited identity, got \(detail.assignmentSettlement)") }@@ -317,17 +375,16 @@ struct CitationResolutionParityTests { .first { $0.id == snapshot.id }) let assigned = try #require(entry.site, "the capture left its Entry unassigned") let citations = entry.citationValues- if let chapter = citations.chapterTitle.patternID,- let chapterVersion = citations.chapterTitle.patternVersion {+ if let chapter = citations.chapterTitle.patternID { #expect(assigned.patternValues.contains {- $0.id == chapter && $0.version == chapterVersion+ $0.id == chapter }, "the cited chapter pattern is not owned by the assigned Site row") } else { Issue.record("the capture cited no chapter pattern; the search-space assertion proves nothing") } let cited = try #require(citations.identity.urlRule) #expect(assigned.urlRuleValues.contains {- $0.id == cited.id && $0.version == cited.version+ $0.id == cited.id }, "the cited URL rule is not owned by the assigned Site row") } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationSurgery.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationSurgery.swiftindex 567e61f..4574ede 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationSurgery.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationSurgery.swift@@ -4,8 +4,9 @@ import Foundation /// **Test-only citation surgery.** ///-/// Several suites seed a *broken* citation — a version no rule holds, an id no-/// Site owns — to prove the validator reports it or the reconciler repairs it.+/// Several suites seed a *broken* citation — an id no Site owns — to prove the+/// validator reports it or the reconciler repairs it. Breaking one by version+/// is no longer possible: a citation is the rule's UUID (T-2281). /// Each of those used to be a one-line write to a retired column. Since V9 the /// blob is a citation's only home and its arms are typed, so breaking one means /// naming the arm; these helpers say that once instead of at twenty call sites.@@ -14,10 +15,10 @@ import Foundation /// only a partial write produced, and the columns that made one spellable are /// gone. extension EntryCitations.Identity {- /// The same arm with the name contributor cited at a different version.- func citingNameTitleVersion(_ version: Int) -> Self {- guard let url = urlRule, let name = nameTitleRule else { return self }- let replaced = CitedRule(id: name.id, version: version)+ /// The same arm citing a different title-pattern row as the name contributor.+ func citingNameTitleID(_ id: UUID) -> Self {+ guard let url = urlRule, nameTitleRule != nil else { return self }+ let replaced = CitedRule(id: id) switch self { case .rawURL: return self case .rule: return .rule(url: url, nameTitle: replaced)@@ -37,8 +38,8 @@ extension EntryCitations.Identity { /// The same arm citing a different URL rule row. func citingURLRuleID(_ id: UUID) -> Self {- guard let url = urlRule else { return self }- let replaced = CitedRule(id: id, version: url.version)+ guard urlRule != nil else { return self }+ let replaced = CitedRule(id: id) switch self { case .rawURL: return self case .rule: return .rule(url: replaced, nameTitle: nameTitleRule)@@ -48,14 +49,9 @@ extension EntryCitations.Identity { } extension FieldProvenance {- /// The same provenance citing its pattern at a different version.- func citingPatternVersion(_ version: Int) -> FieldProvenance {- FieldProvenance.tolerant(kind: kind, patternID: patternID, patternVersion: version)- }- /// The same provenance citing a different pattern row. func citingPatternID(_ id: UUID) -> FieldProvenance {- FieldProvenance.tolerant(kind: kind, patternID: id, patternVersion: patternVersion)+ FieldProvenance.tolerant(kind: kind, patternID: id) } } @@ -67,23 +63,22 @@ extension Entry { func citeURLRule(id: UUID) { editCitations { citations in citations.identity = citations.identity.citingURLRuleID(id)- if let urlWork = citations.urlWork {- citations.urlWork = CitedRule(id: id, version: urlWork.version)+ if citations.urlWork != nil {+ citations.urlWork = CitedRule(id: id) }- if let sequence = citations.chapterSequence {- citations.chapterSequence = CitedRule(id: id, version: sequence.version)+ if citations.chapterSequence != nil {+ citations.chapterSequence = CitedRule(id: id) } } } /// Re-points the Work-assignment URL rule — both the `.urlRule` arm and the /// `workURL` citation beside it, which are one column pair's worth of state.- func citeWorkURLRule(id: UUID? = nil, version: Int? = nil) {+ func citeWorkURLRule(id: UUID? = nil) { editCitations { citations in guard let current = citations.workAssignment.urlRuleRule ?? citations.workURL else { return }- let replaced = CitedRule(- id: id ?? current.id, version: version ?? current.version)+ let replaced = CitedRule(id: id ?? current.id) citations.workAssignment = .urlRule(replaced) citations.workURL = replaced }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swiftindex f958c0e..ef77e94 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swift@@ -291,14 +291,14 @@ private final class CitedResolutionFixture { entry.urlWorkIdentity = workIdentity entry.chapterSequence = sequence entry.chapterTitle = "Chapter \(sequence)"- let citedRule = CitedRule(id: rule.id, version: rule.version)+ let citedRule = CitedRule(id: rule.id) try entry.setCitations( EntryCitations( identity: .rule(url: citedRule, nameTitle: nil), urlWork: citedRule, chapterSequence: citedRule, chapterTitle: FieldProvenance.tolerant(- kind: .pattern, patternID: pattern.id, patternVersion: pattern.version),+ kind: .pattern, patternID: pattern.id), workAssignment: .urlRule(citedRule), workURL: citedRule, workURLAssignmentKind: .identity))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swiftindex c7581a3..fd22cf9 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swift@@ -191,7 +191,7 @@ struct ComposedTeachingProjectionTests { let workID = UUID(), e1 = UUID(), e2 = UUID() let identity = WorkIdentitySnapshot( value: ExactScalarString("42"), state: .rule,- ruleReference: try URLRuleReference(id: UUID(), version: 1))+ ruleReference: URLRuleReference(id: UUID())) let basis = ComposedTeachingBasis( siteMode: .taught, hostname: host, entries: [
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swiftindex 735814f..74d4773 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift@@ -80,7 +80,7 @@ struct ComposedTeachingRepositoryTests { let request = ComposedTeachingRequest(titleDefinition: .wholeTitle, urlDefinition: sequenceURL()) let contract = try await fixture.repository.projectComposedTeaching(hostname: host, request: request)- guard case .committed(let titleID, let titleVersion, _, _) =+ guard case .committed(let titleID, _) = try await fixture.repository.commitComposedTeaching(contract) else { Issue.record("expected committed"); return }@@ -91,7 +91,7 @@ struct ComposedTeachingRepositoryTests { #expect(entry.citationValues.identity.keyVersion == 3) #expect( entry.citationValues.identity.nameTitleRule- == CitedRule(id: titleID, version: titleVersion))+ == CitedRule(id: titleID)) #expect(entry.urlWorkIdentity == nil) #expect(entry.chapterSequence == "7") }@@ -107,11 +107,13 @@ struct ComposedTeachingRepositoryTests { let request = ComposedTeachingRequest( titleDefinition: try wcSegment(), urlDefinition: identitySequenceURL()) let first = try await fixture.repository.projectComposedTeaching(hostname: host, request: request)- guard case .committed(let titleID, let titleV, let urlID, let urlVOpt) =+ guard case .committed(let titleID, let urlIDOpt) = try await fixture.repository.commitComposedTeaching(first) else { Issue.record("expected committed"); return }- let urlV = try #require(urlVOpt)+ let urlID = try #require(urlIDOpt)+ let titleV = try #require(first.outcome.titleVersion.committedVersion)+ let urlV = try #require(first.outcome.urlVersion?.committedVersion) let beforeContext = fixture.freshContext() let beforeEntry = try #require(try beforeContext.fetch(FetchDescriptor<Entry>()).first { $0.id == e1 })@@ -124,12 +126,12 @@ struct ComposedTeachingRepositoryTests { let second = try await fixture.repository.projectComposedTeaching(hostname: host, request: request) #expect(second.outcome.titleVersion == .unchanged(titleV)) #expect(second.outcome.urlVersion == .unchanged(urlV))- guard case .committed(let titleID2, let titleV2, let urlID2, let urlV2) =+ guard case .committed(let titleID2, let urlID2) = try await fixture.repository.commitComposedTeaching(second) else { Issue.record("expected committed re-teach"); return }- #expect(titleID2 == titleID && titleV2 == titleV)- #expect(urlID2 == urlID && urlV2 == urlV)+ #expect(titleID2 == titleID)+ #expect(urlID2 == urlID) let afterContext = fixture.freshContext() // No new rule records; the version identity is unchanged (no bump).@@ -156,7 +158,7 @@ struct ComposedTeachingRepositoryTests { let midContext = fixture.freshContext() let midEntry = try #require(try midContext.fetch(FetchDescriptor<Entry>()).first { $0.id == e1 }) let firstKey = midEntry.entryIdentityKey- let firstNameVersion = midEntry.citationValues.identity.nameTitleRule?.version+ let firstNameRuleID = midEntry.citationValues.identity.nameTitleRule?.id // Re-teach the title rule with a leading trim → name "Solo Story". let second = try await fixture.repository.projectComposedTeaching(@@ -165,7 +167,7 @@ struct ComposedTeachingRepositoryTests { titleDefinition: .wholeTitle, trimPrefix: "XX ", urlDefinition: sequenceURL())) #expect(second.outcome.titleVersion == .available(2)) #expect(second.outcome.urlVersion == .unchanged(1))- guard case .committed(_, let titleV2, _, _) =+ guard case .committed(let titleID2, _) = try await fixture.repository.commitComposedTeaching(second) else { Issue.record("expected committed"); return }@@ -174,9 +176,9 @@ struct ComposedTeachingRepositoryTests { #expect(try LibraryValidator.validate(context: afterContext).tupleDiagnoses[host] == nil) let afterEntry = try #require(try afterContext.fetch(FetchDescriptor<Entry>()).first { $0.id == e1 }) #expect(afterEntry.entryIdentityKey != firstKey) // key recomputed with the trimmed name- let afterNameVersion = afterEntry.citationValues.identity.nameTitleRule?.version- #expect(afterNameVersion == titleV2)- #expect(afterNameVersion != firstNameVersion)+ let afterNameRuleID = afterEntry.citationValues.identity.nameTitleRule?.id+ #expect(afterNameRuleID == titleID2)+ #expect(afterNameRuleID != firstNameRuleID) let expected = EntryIdentityKeyV3Codec.encode(try URLSequenceNameIdentity( hostname: ExactScalarString(host), workName: ExactScalarString("Solo Story"), chapterSequence: ExactScalarString("7")))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swiftindex fb1ca07..a02543a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift@@ -105,11 +105,13 @@ struct ConvergedRuleGroupValidationTests { #expect(diagnostics.quarantineMap().isEmpty) } - /// Decision 13's guard has to stay meaningful: the uniqueness is over the- /// Site row's whole rule membership, and an *unrelated* rule colliding on a- /// version is the collision that guard exists to prevent.- @Test("A group colliding with an unrelated rule's version still fails")- func collisionWithAnUnrelatedRuleStillFails() throws {+ /// Decision 13's guard is **retired** (T-2281 Req 3.1). Version uniqueness+ /// across a Site row's rule membership was the invariant two teaches of one+ /// site collided on, and a citation no longer pins a version, so an+ /// unrelated rule landing on a version this group holds is not a collision+ /// and not a diagnosis.+ @Test("A group colliding with an unrelated rule's version validates")+ func collisionWithAnUnrelatedRuleValidates() throws { let store = try RuleGroupStore() let shared = UUID() try store.seedTaughtSite(patternID: shared, version: 1)@@ -120,7 +122,7 @@ struct ConvergedRuleGroupValidationTests { let diagnostics = try LibraryValidator.validate(context: store.context) - #expect(diagnostics.quarantineMap()[RuleGroupStore.hostname] != nil)+ #expect(diagnostics.quarantineMap().isEmpty) } // MARK: - The two clauses task 20.2 decides@@ -160,10 +162,13 @@ struct ConvergedRuleGroupValidationTests { #expect(diagnostics.quarantineMap().isEmpty) } - /// The clause still means something: an *unrelated* rule above the current- /// one is the state it was written for.- @Test("A current URL rule below an unrelated retained rule still fails")- func currentURLRuleBelowAnUnrelatedRuleStillFails() throws {+ /// Decision 16's clause is **retired** with the invariant it guarded+ /// (T-2281 Req 3.1): an unrelated rule above the current one — the state the+ /// clause was written for — is now a Site that validates. Which row is+ /// current is the marked flag plus the Decision 1 tiebreak, never an integer+ /// ordering.+ @Test("A current URL rule below an unrelated retained rule validates")+ func currentURLRuleBelowAnUnrelatedRuleValidates() throws { let store = try RuleGroupStore() let shared = UUID() try store.seedTaughtSite(urlRuleID: shared, urlRuleVersion: 3)@@ -173,6 +178,20 @@ struct ConvergedRuleGroupValidationTests { let diagnostics = try LibraryValidator.validate(context: store.context) + #expect(diagnostics.quarantineMap().isEmpty)+ }++ /// What survives on the same tuple: **two** current rows are still the+ /// diagnosed state (Req 3.3), whatever versions they carry.+ @Test("Two current URL rules on one Site are still diagnosed")+ func twoCurrentURLRulesStillFail() throws {+ let store = try RuleGroupStore()+ try store.seedTaughtSite(urlRuleVersion: 3)+ try store.addURLRule(id: UUID(), version: 9, isCurrent: true)+ try store.save()++ let diagnostics = try LibraryValidator.validate(context: store.context)+ #expect(diagnostics.quarantineMap()[RuleGroupStore.hostname] != nil) } @@ -230,17 +249,17 @@ struct ConvergedRuleGroupValidationTests { // The premise: the store says this library is fine. #expect(await repository.diagnostics.quarantineMap().isEmpty) - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.id == shared) #expect(payload.titlePatterns.first?.siteHostname == payload.sites.first?.hostname) // The reference validator is what refuses a payload holding one rule // UUID twice, so a decode is the assertion that matters here.- let encoded = try BackupV7Codec.encode(+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))- let decoded = try BackupV7Codec.decode(encoded)+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+ let decoded = try BackupV8Codec.decode(encoded) #expect(decoded.payload.titlePatterns.count == 1) } @@ -270,15 +289,15 @@ struct ConvergedRuleGroupValidationTests { // The premise, again: the store says this library is fine. #expect(await repository.diagnostics.quarantineMap().isEmpty) - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.isActive == true) #expect(payload.sites.first?.mode == .taught)- let encoded = try BackupV7Codec.encode(+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))- _ = try BackupV7Codec.decode(encoded)+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+ _ = try BackupV8Codec.decode(encoded) } /// The URL-rule counterpart, which is the quieter failure: the archive's@@ -297,7 +316,7 @@ struct ConvergedRuleGroupValidationTests { #expect(await repository.diagnostics.quarantineMap().isEmpty) - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() #expect(payload.urlRules.count == 1) #expect(payload.urlRules.first?.isCurrent == true)@@ -318,15 +337,15 @@ struct ConvergedRuleGroupValidationTests { #expect(await repository.diagnostics.quarantineMap().isEmpty) - let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() #expect(payload.urlRules.count == 1) #expect(payload.urlRules.first?.id == shared) #expect(payload.urlRules.first?.isCurrent == true)- let encoded = try BackupV7Codec.encode(+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))- _ = try BackupV7Codec.decode(encoded)+ metadata: BackupV8Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+ _ = try BackupV8Codec.decode(encoded) } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swiftindex 935d29f..451f785 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swift@@ -456,7 +456,7 @@ enum DuplicateShapes { note: note, work: work.flatMap { seededWorks.rows[$0] }, site: site) guard entry.work != nil else { return } entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: patternID, version: 1))+ $0.workAssignment = .pattern(CitedRule(id: patternID)) } } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swiftindex ff048e1..246dd9e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift@@ -344,7 +344,6 @@ struct EntryFacts: Equatable, Sendable { let lastSharedAt: Date let modifiedAt: Date let chapterPatternID: UUID?- let chapterPatternVersion: Int? init(_ entry: Entry) { id = entry.id@@ -360,7 +359,6 @@ struct EntryFacts: Equatable, Sendable { lastSharedAt = entry.lastSharedAt modifiedAt = entry.modifiedAt chapterPatternID = entry.citationValues.chapterTitle.patternID- chapterPatternVersion = entry.citationValues.chapterTitle.patternVersion } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swiftindex fca963b..f7b0b31 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift@@ -57,41 +57,13 @@ struct DuplicateReconcilerTests { let second = try store.reconcile() #expect(second.convergedRuleRows == 0)- #expect(second.rewrittenCitations == 0) #expect(store.saveRecorder.attemptCount == 0, "a converged rule group was written again") } - // MARK: - Req 6.2: citations keep resolving-- @Test("A citation naming a losing version is rewritten to the surviving one")- func citationsFollowTheConvergedVersion() throws {- let store = try DuplicateStore()- let site = store.addSite(displayName: "first", mode: .taught)- let twinRow = store.addSite(displayName: "second", mode: .taught)- let ruleID = DuplicateStore.rankedID(1)- // One rule UUID materialised onto two Site rows of one hostname — the- // shape the Site phase declines, because rows sharing rule UUIDs tie in- // its resolution order. Versions align across Site rows, never within- // one, so this is where the alignment and its citation rewrite happen.- try store.addPattern(id: ruleID, site: site, version: 3, active: true, createdAt: 0)- try store.addPattern(id: ruleID, site: twinRow, version: 7, active: true, createdAt: 10)- let entry = store.addEntry(key: "chapter-1", capturedAt: 0, site: site)- entry.chapterTitle = "Chapter 1"- entry.editCitations {- $0.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: ruleID, patternVersion: 7)- }- try store.commit()-- let outcome = try store.reconcile()-- #expect(outcome.rewrittenCitations == 1)- let facts = try #require(try store.entryFacts().first)- #expect(facts.chapterPatternID == ruleID)- #expect(facts.chapterPatternVersion == 3)- // Every stored row now holds version 3, so the citation resolves.- #expect(Set(try store.patternFacts().map(\.version)) == [3])- }+ // `citationsFollowTheConvergedVersion` stood here (Req 6.2 of+ // `relational-references`): a citation naming the losing row.s version was+ // rewritten to the surviving one. A citation is the rule.s UUID since+ // T-2281, so convergence moves nothing it reads. @Test("Convergence demotes a duplicate active flag and never activates a row") func convergenceDemotesRatherThanActivates() throws {@@ -140,62 +112,90 @@ struct DuplicateReconcilerTests { #expect(Set(rows.map(\.siteObjectID)).count == 2) } - @Test("Version alignment never collides with another rule on the target Site row")- func alignmentNeverManufacturesAVersionCollision() throws {+ // `alignmentNeverManufacturesAVersionCollision` and+ // `alignmentNeverDemotesTheCurrentURLRule` stood here. Both pinned the+ // guards on `alignVersions` — the predicate that asked, per row, whether+ // pulling it onto the representative's version would manufacture a+ // `.siteTuple` out of the Site-unique or greatest-version invariant. T-2281+ // retires both invariants and deletes the alignment with them, so there is+ // no write left to guard. What replaces them is+ // `convergenceLeavesVersionsAsFound` below.++ /// Req 3.5: convergence repairs the **definition** and the flags, and leaves+ /// every row's `version` exactly as found. It used to pull the group onto+ /// the representative's version, which existed only so a citation pinning+ /// `(id, version)` still resolved.+ @Test("A group with divergent versions converges its definitions and leaves the versions alone")+ func convergenceLeavesVersionsAsFound() throws { let store = try DuplicateStore() let first = store.addSite(displayName: "first", mode: .taught) let second = store.addSite(displayName: "second", mode: .taught)- let groupID = DuplicateStore.rankedID(2)- let unrelatedID = DuplicateStore.rankedID(1)- // The uniqueness the validator enforces is over *all* of a Site row's- // patterns, not over this group's: every new rule version gets a new- // UUID, so a Site row routinely holds several rules at several versions.- // Here the first row holds an unrelated rule at version 3 and one row of- // the group at 5, and the second row holds the group's other row at 3 and- // represents it. Pulling the group's row onto 3 would put two of the- // first row's patterns on one version — a `.siteTuple`, which quarantines- // the hostname and takes teaching off capture.- try store.addPattern(id: unrelatedID, site: first, version: 3, active: true, createdAt: 20)- try store.addPattern(id: groupID, site: first, version: 5, active: false, createdAt: 20)- try store.addPattern(id: groupID, site: second, version: 3, active: true, createdAt: 10)+ let patternGroupID = DuplicateStore.rankedID(1)+ let ruleGroupID = DuplicateStore.rankedID(2)+ // Each Site row holds one row of each group, so nothing here blocks the+ // alignment that used to run: the older row represents, and the newer+ // one would have been pulled onto its version on both halves.+ try store.addPattern(+ id: patternGroupID, site: first, version: 5, active: true, createdAt: 20,+ trimPrefix: "Read ")+ try store.addPattern(+ id: patternGroupID, site: second, version: 3, active: true, createdAt: 10)+ try store.addURLRule(+ id: ruleGroupID, site: first, version: 7, current: true, createdAt: 20,+ definition: .work(locator: .query(name: ExactScalarString("series"))))+ try store.addURLRule(+ id: ruleGroupID, site: second, version: 3, current: false, createdAt: 10,+ definition: .work(locator: .query(name: ExactScalarString("identity")))) try store.commit() #expect(try store.diagnose().tupleDiagnoses.isEmpty, "the fixture did not start valid") _ = try store.reconcile() #expect(try store.diagnose().tupleDiagnoses.isEmpty, "convergence manufactured a .siteTuple")- let versions = try store.patternFacts().filter { $0.id == groupID }.map(\.version).sorted()- #expect(versions == [3, 5], "a row took a version its own Site row already held")+ let group = try store.patternFacts().filter { $0.id == patternGroupID }+ #expect(group.map(\.version).sorted() == [3, 5], "a title rule row was renumbered")+ // The definitions did converge — that is the repair which remains.+ #expect(Set(group.map(\.canonicalDefinition)).count == 1)++ let rules = try store.read { context in+ try context.fetch(FetchDescriptor<URLRulePattern>()).filter { $0.id == ruleGroupID }+ }+ #expect(rules.map(\.version).sorted() == [3, 7], "a URL rule row was renumbered")+ #expect(Set(rules.map(GroupOrdering.canonicalDefinition)).count == 1) } - @Test("Version alignment never demotes a current URL rule below a retained one")- func alignmentNeverDemotesTheCurrentURLRule() throws {+ /// Req 3.5, the other half: a convergence pass rewrites **no citation+ /// blob**. The `rewriteCitations` walk that would have is gone, so the bytes+ /// an Entry holds must come through a pass untouched — not merely still name+ /// the same rule.+ @Test("Convergence leaves every Entry's citation bytes untouched")+ func convergenceRewritesNoCitationBlob() throws { let store = try DuplicateStore() let first = store.addSite(displayName: "first", mode: .taught) let second = store.addSite(displayName: "second", mode: .taught)+ let groupID = DuplicateStore.rankedID(2) try store.addPattern(- id: DuplicateStore.rankedID(1), site: first, version: 1, active: true)- try store.addPattern(- id: DuplicateStore.rankedID(2), site: second, version: 1, active: true)- let groupID = DuplicateStore.rankedID(4)- // The validator's other Site-row clause: the current URL rule must hold- // the greatest retained version (`LibraryValidator:523-525`). Aligning- // the current row down to 3 would put it under the retained rule at 5.- try store.addURLRule(- id: DuplicateStore.rankedID(3), site: first, version: 5, current: false, createdAt: 20)- try store.addURLRule(id: groupID, site: first, version: 7, current: true, createdAt: 20)- try store.addURLRule(id: groupID, site: second, version: 3, current: false, createdAt: 10)+ id: groupID, site: first, version: 5, active: true, createdAt: 20,+ trimPrefix: "Read ")+ try store.addPattern(id: groupID, site: second, version: 3, active: true, createdAt: 10)+ let entry = store.addEntry(key: "cites-the-group", capturedAt: 0, site: first)+ entry.editCitations { $0.workAssignment = .pattern(CitedRule(id: groupID)) } try store.commit()- #expect(try store.diagnose().tupleDiagnoses.isEmpty, "the fixture did not start valid")+ // Read through a fresh context, as the pass does: the seeded object's+ // own bytes would not show another context's write.+ let before = try #require(try store.read { context in+ try context.fetch(FetchDescriptor<Entry>()).first?.citationsData+ }) - _ = try store.reconcile()+ let outcome = try store.reconcile() - #expect(try store.diagnose().tupleDiagnoses.isEmpty, "convergence manufactured a .siteTuple")- let versions = try store.read { context in- try context.fetch(FetchDescriptor<URLRulePattern>())- .filter { $0.id == groupID }.map(\.version).sorted()+ #expect(outcome.convergedRuleRows > 0, "the premise: the group converged")+ let after = try store.read { context -> (Data?, EntryCitations?) in+ let row = try context.fetch(FetchDescriptor<Entry>()).first+ return (row?.citationsData, row?.citationValues) }- #expect(versions == [3, 7], "the current URL rule was demoted under a retained one")+ #expect(after.0 == before, "a convergence pass rewrote a citation blob")+ #expect(after.1?.workAssignment == .pattern(CitedRule(id: groupID))) } @Test("A URL rule group converges on the representative's definition")@@ -222,44 +222,11 @@ struct DuplicateReconcilerTests { #expect(definitions.first?.contains("series") == true) } - /// Req 10.4 / Q28 moved a Work's identity citation to `WorkSiteMembership`,- /// which cites a rule **by UUID only** and carries no version — so the- /// membership has nothing to renumber, and nothing here touches it.- ///- /// The `Work.urlIdentityRuleVersion` column this also pinned as left alone- /// went at V9, and so did the half of the assertion that read it.- @Test("Rule renumbering leaves the Work's membership citation alone")- func workCitationColumnFollowsTheRenumbering() throws {- let store = try DuplicateStore()- let site = store.addSite(displayName: "first", mode: .taught)- let twinRow = store.addSite(displayName: "second", mode: .taught)- let ruleID = DuplicateStore.rankedID(1)- try store.addURLRule(id: ruleID, site: site, version: 3, current: true, createdAt: 0)- try store.addURLRule(id: ruleID, site: twinRow, version: 7, current: true, createdAt: 10)- let work = store.addWork(- id: DuplicateStore.rankedID(2), title: "The Serial", urlIdentity: "series-a",- createdAt: 0, site: site)- work.primaryMembershipEdit { $0.urlIdentityRuleID = ruleID }- try store.commit()-- let outcome = try store.reconcile()-- // No Entry cites the rule and the Work side has no writer left, so the- // renumbering rewrites nothing.- #expect(outcome.rewrittenCitations == 0)- let cited = try store.read { context in- let works = try context.fetch(FetchDescriptor<Work>())- let versions = Set(try context.fetch(FetchDescriptor<URLRulePattern>()).map(\.version))- return (- converged: versions,- membershipRules: works.flatMap { $0.membershipValues.map(\.urlIdentityRuleID) }- )- }- #expect(cited.converged.count == 1, "the premise: the group converged on one version")- // The membership cites by UUID and carries no version, so renumbering- // leaves it exactly as it was.- #expect(cited.membershipRules.allSatisfy { $0 == nil || $0 == ruleID })- }+ // `workCitationColumnFollowsTheRenumbering` stood here. Its subject was the+ // renumbering leaving a Work's membership citation alone — Req 10.4 / Q28+ // had already moved that citation to a UUID with no version, and V9 dropped+ // the stale column beside it, so the last thing it asserted was that a+ // renumbering nothing reads changed nothing. T-2281 deletes the renumbering. // MARK: - Req 3.1: silent Entry collapse
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryCitationsTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryCitationsTests.swiftindex 4fa3fa5..7e9e921 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryCitationsTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryCitationsTests.swift@@ -43,7 +43,7 @@ struct EntryCitationsTests { // MARK: - Req 10.2: the citation blob - /// The seven `(id, version, target, label)` rows, written out by hand so a+ /// The seven `(id, target, label)` rows, written out by hand so a /// projection that quietly reorders, relabels or drops a slot fails here /// rather than in an archive refusal three subsystems away. ///@@ -58,14 +58,14 @@ struct EntryCitationsTests { func sevenRowsProjectInOrder() throws { let citations = EntryCitations( identity: .composed(- url: CitedRule(id: Self.ruleA, version: 4),- nameTitle: CitedRule(id: Self.patternA, version: 2)),- urlWork: CitedRule(id: Self.ruleB, version: 5),- chapterSequence: CitedRule(id: Self.ruleB, version: 5),+ url: CitedRule(id: Self.ruleA),+ nameTitle: CitedRule(id: Self.patternA)),+ urlWork: CitedRule(id: Self.ruleB),+ chapterSequence: CitedRule(id: Self.ruleB), chapterTitle: FieldProvenance.tolerant(- kind: .pattern, patternID: Self.patternB, patternVersion: 3),- workAssignment: .pattern(CitedRule(id: Self.patternA, version: 7)),- workURL: CitedRule(id: Self.ruleA, version: 9),+ kind: .pattern, patternID: Self.patternB),+ workAssignment: .pattern(CitedRule(id: Self.patternA)),+ workURL: CitedRule(id: Self.ruleA), workURLAssignmentKind: .identity) let rows = citations.ruleCitations@@ -76,7 +76,6 @@ struct EntryCitationsTests { Self.ruleA, Self.patternA, Self.ruleB, Self.ruleB, Self.patternB, Self.patternA, Self.ruleA, ])- #expect(rows.compactMap(\.version) == [4, 2, 5, 5, 3, 7, 9]) // The blob is what the Entry answers with, and it answers the same rows. let entry = Self.makeEntry()@@ -91,7 +90,7 @@ struct EntryCitationsTests { func defaultCitesNothing() { let rows = EntryCitations().ruleCitations #expect(rows.count == Self.expectedLabels.count)- #expect(rows.allSatisfy { $0.id == nil && $0.version == nil })+ #expect(rows.allSatisfy { $0.cited == nil }) } @@ -105,7 +104,7 @@ struct EntryCitationsTests { /// archive's reverse projection. @Test("Identity cases carry their key version back") func identityCases() {- let url = CitedRule(id: Self.ruleA, version: 4)+ let url = CitedRule(id: Self.ruleA) #expect(EntryCitations.Identity.rawURL.keyVersion == 1) #expect(EntryCitations.Identity.rule(url: url, nameTitle: nil).keyVersion == 2) #expect(EntryCitations.Identity.composed(url: url, nameTitle: nil).keyVersion == 3)@@ -121,26 +120,92 @@ struct EntryCitationsTests { // its citation inside the case (Q39), so there is no mismatched value left // to normalise. - /// Rule renumbering rewrites versions and nothing else, and reports "no- /// change" when the map names no version the row cites.- @Test("Version rewriting touches every cited slot and only the versions")- func versionRewrite() throws {- let citations = EntryCitations(+ // `versionRewrite` stood here. It drove `EntryCitations.rewritingVersions`,+ // the walk that kept every citation replaying after a rule group converged+ // on one version. T-2281 cites a rule by UUID alone, so there is no version+ // to rewrite and no walk to drive.++ // MARK: - Req 1.2, 1.3: citations pin the UUID only++ /// A citation blob a **previous build** wrote, captured from that build's+ /// encoder before the field was dropped (Q17).+ ///+ /// Every slot is populated and every one of them carries a version: the two+ /// `CitedRule` halves of a `.composed` identity, the four bare `CitedRule`+ /// slots, and `chapterTitle`'s `patternVersion`. It is a literal rather than+ /// something the current encoder produces, because the current encoder emits+ /// the *new* shape — a tolerance test over bytes this build wrote would+ /// prove nothing.+ private static let oldShapeBlob = Data(+ """+ {"chapterSequence":{"version":5,"id":"AAAAAAAA-0000-4000-8000-000000000002"},\+ "workAssignment":{"pattern":{"_0":{"id":"BBBBBBBB-0000-4000-8000-000000000001",\+ "version":7}}},"identity":{"composed":{"nameTitle":\+ {"id":"BBBBBBBB-0000-4000-8000-000000000001","version":2},"url":\+ {"id":"AAAAAAAA-0000-4000-8000-000000000001","version":4}}},"urlWork":\+ {"version":5,"id":"AAAAAAAA-0000-4000-8000-000000000002"},\+ "workURLAssignmentKind":"identity","workURL":{"version":9,\+ "id":"AAAAAAAA-0000-4000-8000-000000000001"},"chapterTitle":{"patternVersion":3,\+ "patternID":"BBBBBBBB-0000-4000-8000-000000000002","kind":"pattern"}}+ """.utf8)++ /// The value those bytes mean, built version-free.+ private static var oldShapeEquivalent: EntryCitations {+ EntryCitations( identity: .composed(- url: CitedRule(id: Self.ruleA, version: 1),- nameTitle: CitedRule(id: Self.patternA, version: 1)),- urlWork: CitedRule(id: Self.ruleA, version: 1),- chapterSequence: CitedRule(id: Self.ruleA, version: 1),- chapterTitle: FieldProvenance.tolerant(- kind: .pattern, patternID: Self.patternA, patternVersion: 1),- workAssignment: .pattern(CitedRule(id: Self.patternA, version: 1)),- workURL: CitedRule(id: Self.ruleA, version: 1))-- #expect(citations.rewritingVersions([:]) == nil)- #expect(citations.rewritingVersions([Self.ruleB: 9]) == nil)- let rewritten = try #require(citations.rewritingVersions([Self.ruleA: 6, Self.patternA: 8]))- #expect(rewritten.ruleCitations.compactMap(\.version) == [6, 8, 6, 6, 8, 8, 6])- #expect(rewritten.ruleCitations.map(\.id) == citations.ruleCitations.map(\.id))+ url: CitedRule(id: ruleA), nameTitle: CitedRule(id: patternA)),+ urlWork: CitedRule(id: ruleB),+ chapterSequence: CitedRule(id: ruleB),+ chapterTitle: FieldProvenance.tolerant(kind: .pattern, patternID: patternB),+ workAssignment: .pattern(CitedRule(id: patternA)),+ workURL: CitedRule(id: ruleA),+ workURLAssignmentKind: .identity)+ }++ /// Req 1.3: an old-shape blob decodes, and the version it carries is ignored+ /// rather than read — the decoded value equals the same citations encoded+ /// with no versions at all.+ @Test("A blob written with per-citation versions decodes to the version-free value")+ func oldShapeBlobDecodes() throws {+ let decoded = try JSONBlob.decode(EntryCitations.self, from: Self.oldShapeBlob)+ #expect(decoded == Self.oldShapeEquivalent)++ // And it reads through the Entry the same way, so a row that is never+ // edited keeps its old bytes and still answers its seven citations (Q3).+ let entry = Self.makeEntry()+ entry.citationsData = Self.oldShapeBlob+ #expect(try entry.citations == Self.oldShapeEquivalent)+ #expect(+ try entry.ruleCitations.map(\.id) == [+ Self.ruleA, Self.patternA, Self.ruleB, Self.ruleB, Self.patternB,+ Self.patternA, Self.ruleA,+ ])+ }++ /// Req 1.2: nothing this build encodes carries a per-citation version, at+ /// any depth of the blob.+ ///+ /// Walked rather than string-matched so a version nested inside an enum's+ /// payload — where every citation but `chapterTitle` lives — is caught.+ @Test("The encoded blob carries no version key at any depth")+ func encodedBlobCarriesNoVersion() throws {+ func versionKeys(in value: Any) -> [String] {+ if let object = value as? [String: Any] {+ return object.flatMap { key, nested in+ (key == "version" || key == "patternVersion" ? [key] : []) + versionKeys(in: nested)+ }+ }+ if let array = value as? [Any] { return array.flatMap(versionKeys(in:)) }+ return []+ }++ let encoded = try JSONBlob.encode(Self.oldShapeEquivalent)+ let tree = try JSONSerialization.jsonObject(with: encoded)+ #expect(versionKeys(in: tree).isEmpty)++ // Anti-vacuity: the same walk finds all seven in the old shape.+ let oldTree = try JSONSerialization.jsonObject(with: Self.oldShapeBlob)+ #expect(versionKeys(in: oldTree).count == 7) } @@ -219,7 +284,7 @@ struct EntryCitationsTests { func failedCitationEncode() throws { struct Boom: Error {} let entry = Self.makeEntry()- let original = EntryCitations(urlWork: CitedRule(id: Self.ruleA, version: 1))+ let original = EntryCitations(urlWork: CitedRule(id: Self.ruleA)) try entry.setCitations(original) let bytes = entry.citationsData #expect(throws: Boom.self) {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swiftindex f2cedeb..ed62869 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift@@ -89,12 +89,21 @@ struct EntryDetailAndMergeToleranceTests { func entryDetailToleratesAnIllegalTupleInsteadOfRefusing() async throws { let library = try ToleranceFixture() let entryID = UUID()+ // The two marked rows, named so the assertions below can say that+ // *neither* is disclosed — including the one the T-2281 Decision 1+ // tiebreak would not pick. The history is "everything but the pick"+ // (`+EntryDetail.swift`), so the loser would land there if the tuple+ // were legal; it is not, and the withholding comes first.+ let picked = UUID()+ let lostTheTiebreak = UUID() try library.seed { store in let site = store.insertSite(hostname: "collided.example") site.mode = .taught- try store.insertTitlePattern(site: site, isActive: true, definition: .segmented) try store.insertTitlePattern(- site: site, isActive: true, offset: 1, definition: .segmentedFromTheEnd)+ id: lostTheTiebreak, site: site, isActive: true, definition: .segmented)+ try store.insertTitlePattern(+ id: picked, site: site, isActive: true, offset: 1,+ definition: .segmentedFromTheEnd) store.insertEntry(id: entryID, hostname: "collided.example", title: "A Work - Chapter 1") } let repository = try await library.openForApp()@@ -106,7 +115,11 @@ struct EntryDetailAndMergeToleranceTests { // tuple: no pattern summary, no title cleaning, no teaching action — // the diagnostics screen is the only repair route (Q52). #expect(detail.activePatternSummary == nil)+ // Neither marked row is disclosed — not the one the tiebreak picks, and+ // not the one it drops. #expect(detail.historicalPatternSummaries.isEmpty)+ #expect(!detail.historicalPatternSummaries.map(\.id).contains(lostTheTiebreak))+ #expect(detail.activePatternSummary?.id != picked) #expect(detail.availableActions.isEmpty) #expect(!detail.hasCurrentURLRule) #expect(detail.displayTitle == "A Work - Chapter 1")@@ -165,7 +178,7 @@ struct EntryDetailAndMergeToleranceTests { let entry = store.insertEntry( id: entryID, hostname: "dup.example", title: "A Cited Work - Chapter 3") entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: losingPatternID, version: 1))+ $0.workAssignment = .pattern(CitedRule(id: losingPatternID)) } } // Pin the Entry to the row that owns its citation, undoing the seed's@@ -187,25 +200,29 @@ struct EntryDetailAndMergeToleranceTests { } /// The other half of Q13 (relational-references Req 3.4). This replay threw- /// `corruptLibrary` uncaught as well, so an Entry citing a pattern version its- /// Site never retained lost the whole detail screen. The version test is not- /// optional — Req 4.2 requires it — so the citation must fail to resolve here- /// and still be disclosed rather than thrown.- @Test("Entry detail renders when the cited pattern version resolves nowhere")+ /// `corruptLibrary` uncaught as well, so an Entry citing a rule its Site+ /// holds no row for lost the whole detail screen. It must fail to resolve+ /// here and still be disclosed rather than thrown.+ ///+ /// It used to break the citation by *version* — the right id at a version+ /// the Site never retained. T-2281 cites a rule by UUID alone, so the only+ /// way a citation fails to resolve is an id no row in the Site carries.+ @Test("Entry detail renders when the cited pattern resolves nowhere") func entryDetailRendersAnUnresolvableCitation() async throws { let library = try ToleranceFixture() let entryID = UUID()- let patternID = UUID()+ let activeID = UUID()+ let citedID = UUID() try library.seed { store in let site = store.insertSite(hostname: "taught.example") site.mode = .taught try store.insertTitlePattern(- id: patternID, site: site, isActive: true, version: 1, definition: .segmented)+ id: activeID, site: site, isActive: true, version: 1, definition: .segmented) let entry = store.insertEntry( id: entryID, hostname: "taught.example", title: "A Cited Work - Chapter 3")- // The right id at a version the Site never retained.+ // An id the Site owns no row for. entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: patternID, version: 2))+ $0.workAssignment = .pattern(CitedRule(id: citedID)) } } let repository = try await library.openForApp()@@ -215,13 +232,13 @@ struct EntryDetailAndMergeToleranceTests { #expect(detail.unresolvedCandidateTitle == nil) // Everything the screen *could* resolve is still disclosed. #expect(detail.siteMode == .taught)- #expect(detail.activePatternSummary?.id == patternID)+ #expect(detail.activePatternSummary?.id == activeID) // Marked as needing attention in the vocabulary this screen already uses // for a degraded field, with the cited identity kept as evidence (Req 4.2). #expect( detail.assignmentSettlement == .patternUnsettled(- patternID: patternID, version: 2,+ patternID: citedID, reason: "Cited title pattern does not resolve")) } @@ -255,7 +272,7 @@ struct EntryDetailAndMergeToleranceTests { let entry = store.insertEntry( id: entryID, hostname: "dup.example", title: "A Cited Work - Chapter 3") entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: losingPatternID, version: 1))+ $0.workAssignment = .pattern(CitedRule(id: losingPatternID)) } } try library.mutate { context in
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swiftindex ee9367e..266350c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift@@ -172,9 +172,9 @@ struct EnumTolerancePolicyTests { #expect(snapshot.works.contains { $0.id == workID }) do {- _ = try await repository.backupV7Snapshot()+ _ = try await repository.backupV8Snapshot() Issue.record("the export archived an unrepresentable value")- } catch let error as BackupV7ExportError {+ } catch let error as BackupV8ExportError { guard case .unrepresentableValue(let record, let field, let value) = error else { Issue.record("expected .unrepresentableValue, got \(error)") return@@ -211,9 +211,9 @@ struct EnumTolerancePolicyTests { let repository = try await library.openForApp() do {- _ = try await repository.backupV7Snapshot()+ _ = try await repository.backupV8Snapshot() Issue.record("the export archived an unreadable citation blob")- } catch let error as BackupV7ExportError {+ } catch let error as BackupV8ExportError { guard case .unrepresentableValue(_, let refused, _) = error else { Issue.record("expected .unrepresentableValue, got \(error)") return
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swiftindex d97285b..da9f465 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift@@ -578,7 +578,7 @@ final class WriteFixture { /// identity key — the upsert shape (Decision 8), so the plan updates rather /// than inserts. func importPlan(entryID: UUID, note: String, modifiedAt: Date) throws -> BackupImportPlan {- let entry = BackupV7Entry(+ let entry = BackupV8Entry( id: entryID, captureTitle: "Chapter", captureTitleSource: .host, rawURL: identityKey, canonicalURL: nil, hostname: "dup.example", entryIdentityKey: identityKey,@@ -587,13 +587,13 @@ final class WriteFixture { note: note, rating: nil, firstCapturedAt: Self.epoch, lastSharedAt: Self.epoch, modifiedAt: modifiedAt, workID: nil, intentionallyUnattached: false, citations: EntryCitations())- let site = BackupV7Site(+ let site = BackupV8Site( hostname: "dup.example", displayName: "Dup", mode: .untaught, junkSuffixRule: nil) let payload = BackupImportPayload( entries: [entry], works: [], sites: [site], titlePatterns: [], urlRules: []) return BackupImportPlan( metadata: BackupImportMetadata(- formatVersion: 7, schemaVersion: 8, appBuild: "test",+ formatVersion: 8, schemaVersion: 9, appBuild: "test", exportedAt: Self.epoch, capabilityGate: "multi-site", entryCount: 1, workCount: 0), payload: payload,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swiftindex 7787d01..f8e9fe6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift@@ -29,8 +29,8 @@ import Testing /// extension is greyed out and cannot be selected on the device. The exporter's /// own filenames are `Asterism-backup-v4-<timestamp>.json` for the same reason. ///-/// The archive is produced through the real `BackupV7Exporter` — the same-/// `backupV7Snapshot()` → `BackupV7Codec.encode` → decode-validate → write path+/// The archive is produced through the real `BackupV8Exporter` — the same+/// `backupV8Snapshot()` → `BackupV8Codec.encode` → decode-validate → write path /// the app's Settings export uses — so what lands on disk is byte-for-byte the /// kind of file the app produces, checksum and all. The generator then re-reads /// the written file through `BackupImporter.plan(from:)`, which is the same@@ -96,9 +96,9 @@ struct FixtureArchiveGeneratorTests { // it just produced. It picks its own filename in the staging directory; // the archive is moved to `destination` afterwards. let staging = root.appending(path: "staging", directoryHint: .isDirectory)- let exporter = BackupV7Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging) let result = try await exporter.export(- metadata: BackupV7Metadata(appBuild: "fixture-5k", exportedAt: exportedAt))+ metadata: BackupV8Metadata(appBuild: "fixture-5k", exportedAt: exportedAt)) withExtendedLifetime(container) {} try FileManager.default.createDirectory(
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-7-8-golden.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-7-8-golden.jsondeleted file mode 100644index 867a1c3..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-7-8-golden.json+++ /dev/null@@ -1 +0,0 @@-{"appBuild":"golden","backupFormatVersion":7,"capabilityGate":"multi-site","checksum":"2cafdcd01565e2b1d93ad6f58d0678707300bd6b8523fa11d967e46558af6f37","databaseSchemaVersion":8,"entryCount":4,"exportedAt":"1970-01-12T13:46:40.000Z","payload":{"characters":[{"aliases":["Klar"],"createdAt":"1970-01-12T13:46:40.000Z","facts":[{"nameKey":"grover","quote":"promised to guide them home","source":{"entryID":"22222222-2222-2222-2222-222222222222","kind":"entry"},"statement":"Promised to guide them home."}],"id":"C4A2ACE0-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Grover","nameKey":"grover","note":"The guide.","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"aliases":[],"createdAt":"1970-01-12T13:46:40.000Z","facts":[],"id":"C4A2ACE0-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","name":"The Stranger","nameKey":"the stranger","note":""}],"distinctPairs":[{"higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","id":"88888888-0000-4000-8000-000000000001","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","recordedAt":"1970-01-12T13:46:40.000Z"}],"entries":[{"captureTitle":"TtH • Story • Actual Title","captureTitleSource":"host","chapterSequence":"94","characterExtractionFingerprint":"448c04a700521270a7f5215cd2cfbbe77818591b29899fa94ca100201738f368","citations":{"chapterSequence":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","version":1},"chapterTitle":{"kind":"none"},"identity":{"composed":{"nameTitle":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC","version":1},"url":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","version":1}}},"workAssignment":{"pattern":{"_0":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC","version":1}}}},"conservativeIdentityKey":"https://golden.example/read?chapter=94&x=1","entryIdentityKey":"v3|h14:golden.example|n12:Actual Title|s2:94","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"22222222-2222-2222-2222-222222222222","identityBasis":"urlRule","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"Grover promised to guide them home.","rating":"up","rawURL":"https://golden.example/read?chapter=94&x=1","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"captureTitle":"Plain Work","captureTitleSource":"manual","chapterTitle":"A Plain Chapter","citations":{"chapterTitle":{"kind":"manual"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://plain.example/read/7","entryIdentityKey":"https://plain.example/read/7","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"22222222-2222-2222-2222-222222222223","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://plain.example/read/7","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"canonicalURL":"https://articles.example/posts/hello","captureTitle":"An Article - Articles Example","captureTitleSource":"host","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://articles.example/posts/hello?utm_source=share","entryIdentityKey":"https://articles.example/posts/hello?utm_source=share","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"22222222-2222-2222-2222-222222222224","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://articles.example/posts/hello?utm_source=share","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"captureTitle":"Twice Over","captureTitleSource":"manual","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://dupe.example/read/1","entryIdentityKey":"https://dupe.example/read/1","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"D0000000-0000-4000-8000-000000000002","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://dupe.example/read/1","workID":"D0000000-0000-4000-8000-000000000001"}],"memberships":[{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"77777777-0000-4000-8000-000000000001","urlIdentity":"golden.example/story/actual-title","urlIdentityRuleID":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","urlIdentityState":"rule","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://golden.example/story/actual-title"},{"createdAt":"1970-01-12T13:46:41.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000002","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://plain.example/works/actual-title"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000003","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"77777777-0000-4000-8000-000000000004","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000005","urlIdentity":"plain.example/absent","urlIdentityState":"legacyUnverified","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"77777777-0000-4000-8000-000000000006","urlIdentityState":"none","workID":"D0000000-0000-4000-8000-000000000001"}],"sites":[{"displayName":"Articles","hostname":"articles.example","mode":"articles"},{"displayName":"Dupe","hostname":"dupe.example","mode":"untaught"},{"displayName":"Golden","hostname":"golden.example","junkSuffixRule":{"anchors":[{"offset":0,"origin":"end"}],"version":1},"mode":"taught"},{"displayName":"Plain","hostname":"plain.example","mode":"untaught"}],"suppressions":[{"actionAt":"1970-01-12T13:46:40.000Z","id":"5099E5ED-0000-4000-8000-000000000001","kindRaw":"candidate","nameKey":"the crowned one","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"actionAt":"1970-01-12T13:46:40.000Z","evidence":"promised to guide them home","id":"5099E5ED-0000-4000-8000-000000000002","kindRaw":"fact","nameKey":"grover","sourceEntryID":"22222222-2222-2222-2222-222222222222","sourceKindRaw":"entry","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimSuffix":" - Articles Example"},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCC2","isActive":false,"siteHostname":"articles.example","version":1},{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimPrefix":"TtH • Story • "},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC","isActive":true,"siteHostname":"golden.example","version":1}],"urlRules":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"sequence":{"locator":{"query":{"name":"chapter"}}}},"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","isCurrent":true,"origin":"readerTaught","siteHostname":"golden.example","version":1}],"workTypes":[{"canonicalID":"D0000001-0000-4000-8000-000000000001","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A1","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novel","stateRaw":"merged"},{"canonicalID":"00000000-0000-0000-0000-0000000000A1","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A2","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novella","stateRaw":"merged"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000001-0000-4000-8000-000000000001","modifiedAt":"1970-01-01T00:00:00.000Z","name":"novel","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000002-0000-4000-8000-000000000002","modifiedAt":"1970-01-01T00:00:00.000Z","name":"webtoon","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000003-0000-4000-8000-000000000003","modifiedAt":"1970-01-01T00:00:00.000Z","name":"article","stateRaw":"active"}],"works":[{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Twice Over","genericNotes":"","genreTags":[],"id":"D0000000-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"manual"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Plain Work","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"manual","typeName":"novel","workTypeID":"00000000-0000-0000-0000-0000000000A2"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"An Article","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"manual"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Actual Title","genericNotes":"The guide is not what he seems.","genericNotesExtractionFingerprint":"15b785793033dc26edf6396b3f0e1c27aa1ffaa61043ff49f907a970319a0499","genreTags":["fantasy"],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","lastParsedTitle":"Actual Title","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"parsed","typeName":"novel","workTypeID":"00000000-0000-0000-0000-0000000000A1"}]},"workCount":4}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-8-9-golden.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-8-9-golden.jsonnew file mode 100644index 0000000..9836439--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-8-9-golden.json@@ -0,0 +1 @@+{"appBuild":"golden","backupFormatVersion":8,"capabilityGate":"multi-site","checksum":"851980b9e9bddafcb0b7281999e12176cd29ae8548f9989c12d22d95b9d21c9e","databaseSchemaVersion":9,"entryCount":4,"exportedAt":"1970-01-12T13:46:40.000Z","payload":{"characters":[{"aliases":["Klar"],"createdAt":"1970-01-12T13:46:40.000Z","facts":[{"nameKey":"grover","quote":"promised to guide them home","source":{"entryID":"22222222-2222-2222-2222-222222222222","kind":"entry"},"statement":"Promised to guide them home."}],"id":"C4A2ACE0-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Grover","nameKey":"grover","note":"The guide.","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"aliases":[],"createdAt":"1970-01-12T13:46:40.000Z","facts":[],"id":"C4A2ACE0-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","name":"The Stranger","nameKey":"the stranger","note":""}],"distinctPairs":[{"higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","id":"88888888-0000-4000-8000-000000000001","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","recordedAt":"1970-01-12T13:46:40.000Z"}],"entries":[{"captureTitle":"TtH • Story • Actual Title","captureTitleSource":"host","chapterSequence":"94","characterExtractionFingerprint":"448c04a700521270a7f5215cd2cfbbe77818591b29899fa94ca100201738f368","citations":{"chapterSequence":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"},"chapterTitle":{"kind":"none"},"identity":{"composed":{"nameTitle":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"},"url":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"}}},"workAssignment":{"pattern":{"_0":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"}}}},"conservativeIdentityKey":"https://golden.example/read?chapter=94&x=1","entryIdentityKey":"v3|h14:golden.example|n12:Actual Title|s2:94","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"22222222-2222-2222-2222-222222222222","identityBasis":"urlRule","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"Grover promised to guide them home.","rating":"up","rawURL":"https://golden.example/read?chapter=94&x=1","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"captureTitle":"Plain Work","captureTitleSource":"manual","chapterTitle":"A Plain Chapter","citations":{"chapterTitle":{"kind":"manual"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://plain.example/read/7","entryIdentityKey":"https://plain.example/read/7","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"22222222-2222-2222-2222-222222222223","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://plain.example/read/7","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"canonicalURL":"https://articles.example/posts/hello","captureTitle":"An Article - Articles Example","captureTitleSource":"host","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://articles.example/posts/hello?utm_source=share","entryIdentityKey":"https://articles.example/posts/hello?utm_source=share","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"22222222-2222-2222-2222-222222222224","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://articles.example/posts/hello?utm_source=share","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"captureTitle":"Twice Over","captureTitleSource":"manual","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://dupe.example/read/1","entryIdentityKey":"https://dupe.example/read/1","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"D0000000-0000-4000-8000-000000000002","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://dupe.example/read/1","workID":"D0000000-0000-4000-8000-000000000001"}],"memberships":[{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"77777777-0000-4000-8000-000000000001","urlIdentity":"golden.example/story/actual-title","urlIdentityRuleID":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","urlIdentityState":"rule","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://golden.example/story/actual-title"},{"createdAt":"1970-01-12T13:46:41.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000002","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://plain.example/works/actual-title"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000003","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"77777777-0000-4000-8000-000000000004","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000005","urlIdentity":"plain.example/absent","urlIdentityState":"legacyUnverified","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"77777777-0000-4000-8000-000000000006","urlIdentityState":"none","workID":"D0000000-0000-4000-8000-000000000001"}],"sites":[{"displayName":"Articles","hostname":"articles.example","mode":"articles"},{"displayName":"Dupe","hostname":"dupe.example","mode":"untaught"},{"displayName":"Golden","hostname":"golden.example","junkSuffixRule":{"anchors":[{"offset":0,"origin":"end"}],"version":1},"mode":"taught"},{"displayName":"Plain","hostname":"plain.example","mode":"untaught"}],"suppressions":[{"actionAt":"1970-01-12T13:46:40.000Z","id":"5099E5ED-0000-4000-8000-000000000001","kindRaw":"candidate","nameKey":"the crowned one","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"actionAt":"1970-01-12T13:46:40.000Z","evidence":"promised to guide them home","id":"5099E5ED-0000-4000-8000-000000000002","kindRaw":"fact","nameKey":"grover","sourceEntryID":"22222222-2222-2222-2222-222222222222","sourceKindRaw":"entry","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimSuffix":" - Articles Example"},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCC2","isActive":false,"siteHostname":"articles.example","version":1},{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimPrefix":"TtH • Story • "},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC","isActive":true,"siteHostname":"golden.example","version":1}],"urlRules":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"sequence":{"locator":{"query":{"name":"chapter"}}}},"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","isCurrent":true,"origin":"readerTaught","siteHostname":"golden.example","version":1}],"workTypes":[{"canonicalID":"D0000001-0000-4000-8000-000000000001","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A1","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novel","stateRaw":"merged"},{"canonicalID":"00000000-0000-0000-0000-0000000000A1","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A2","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novella","stateRaw":"merged"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000001-0000-4000-8000-000000000001","modifiedAt":"1970-01-01T00:00:00.000Z","name":"novel","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000002-0000-4000-8000-000000000002","modifiedAt":"1970-01-01T00:00:00.000Z","name":"webtoon","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000003-0000-4000-8000-000000000003","modifiedAt":"1970-01-01T00:00:00.000Z","name":"article","stateRaw":"active"}],"works":[{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Twice Over","genericNotes":"","genreTags":[],"id":"D0000000-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"manual"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Plain Work","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"manual","typeName":"novel","workTypeID":"00000000-0000-0000-0000-0000000000A2"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"An Article","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"manual"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Actual Title","genericNotes":"The guide is not what he seems.","genericNotesExtractionFingerprint":"15b785793033dc26edf6396b3f0e1c27aa1ffaa61043ff49f907a970319a0499","genreTags":["fantasy"],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","lastParsedTitle":"Actual Title","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"parsed","typeName":"novel","workTypeID":"00000000-0000-0000-0000-0000000000A1"}]},"workCount":4}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swiftindex dfc6685..5c92182 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift@@ -300,14 +300,14 @@ struct FrozenLibraryPathTests { "AsterismSchemaV8", "AsterismSchemaV9", "AsterismV9MigrationPlan", "atOrAboveV5", "belowV5", "firstV5Major", ]- /// The archive format — 7/8, the one shape the app reads and writes, plus+ /// The archive format — 8/9, the one shape the app reads and writes, plus /// the 2/2 URL-rule origin the store still names. These name a /// serialization version, not a store schema, and they are accurate:- /// `multi-site-works` Q29 mints format 7 over schema 8, and every record- /// this generation carries is its own (Decision 2) rather than one an+ /// `rule-citation-by-uuid` Q9 mints format 8 over schema 9, and every+ /// record this generation carries is its own (Q14) rather than one an /// earlier generation froze. ///- /// Every earlier generation's **read and write path** is gone, 6/7+ /// Every earlier generation's **read and write path** is gone, 7/8 /// included, so every name that described one — the codecs, documents, /// payloads, exporters, snapshot protocols, reference and shape /// validators, per-generation planners, gates and materializers — is@@ -319,18 +319,18 @@ struct FrozenLibraryPathTests { /// single format, and a digit in their names would be a digit describing /// nothing. let namesTheArchiveFormat: Set<String> = [- "BackupV7Entry", "BackupV7Site", "BackupV7TitlePattern", "BackupV7URLRule",- "BackupV7Work", "BackupV7WorkType", "BackupV7Membership", "BackupV7DistinctPair",- "BackupV7Character", "BackupV7Codec",- "BackupV7Document", "BackupV7ExportError", "BackupV7Exporter", "BackupV7Metadata",- "BackupV7Payload", "BackupV7ReferenceValidator",- "BackupV7SnapshotProviding", "BackupV7Suppression",- "backupV7Snapshot",+ "BackupV8Entry", "BackupV8Site", "BackupV8TitlePattern", "BackupV8URLRule",+ "BackupV8Work", "BackupV8WorkType", "BackupV8Membership", "BackupV8DistinctPair",+ "BackupV8Character", "BackupV8Codec",+ "BackupV8Document", "BackupV8ExportError", "BackupV8Exporter", "BackupV8Metadata",+ "BackupV8Payload", "BackupV8ReferenceValidator",+ "BackupV8SnapshotProviding", "BackupV8Suppression",+ "backupV8Snapshot", "importedV2", "importedV2Path",- "mapV7EntryRecord", "mapV7SiteRecord", "mapV7TitlePatternRecord",- "mapV7URLRuleRecord", "mapV7WorkRecord",- "mapV7CharacterRecord", "mapV7SuppressionRecord",- "projectV7Payload",+ "mapV8EntryRecord", "mapV8SiteRecord", "mapV8TitlePatternRecord",+ "mapV8URLRuleRecord", "mapV8WorkRecord",+ "mapV8CharacterRecord", "mapV8SuppressionRecord",+ "projectV8Payload", ] /// The Entry identity-key generation, `EntryIdentityKeyV2Codec` / /// `V3Codec`. A v2 key and a v3 key are different encodings of the same
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swiftindex dd05411..65b0e52 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift@@ -349,7 +349,7 @@ struct GroupOrderingTests { entry.chapterTitle = "Chapter One" entry.editCitations { $0.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: UUID(), patternVersion: 1)+ kind: .pattern, patternID: UUID()) } // A derived chapter title is not reader-authored. #expect(GroupOrdering.authoredContent(of: entry).isBare)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityReuseWithoutNameTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityReuseWithoutNameTests.swiftindex d5fa6d2..a871026 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityReuseWithoutNameTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityReuseWithoutNameTests.swift@@ -20,7 +20,7 @@ struct IdentityReuseWithoutNameTests { titleProvenance: .parsed, identity: WorkIdentitySnapshot( value: ExactScalarString(identity), state: .rule,- ruleReference: try URLRuleReference(id: UUID(), version: 1)))+ ruleReference: URLRuleReference(id: UUID()))) } // MARK: - Capture matcher
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swiftindex 3e23f51..14cafc1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift@@ -278,7 +278,7 @@ extension LibraryRepository { cited.chapterTitle = "Chapter Two" cited.editCitations { $0.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: pattern.id, patternVersion: pattern.version)+ kind: .pattern, patternID: pattern.id) } // An articles Site's Entries are unattached on purpose (Req 2.1's@@ -339,14 +339,18 @@ enum LibraryGraphSerializer { "# Produced by LibraryGraphBaselineTests. Regenerating this file from post-change", "# code defeats the comparison it exists for: it is re-recorded only when the", "# schema itself changes shape, and the format line says which shape it holds.",- "# format 5 is schema V9 (drop-superseded-columns, T-2280): the columns",+ "# format 5 was schema V9 (drop-superseded-columns, T-2280): the columns", "# format 4 recorded as present-but-unwritten are gone from the store, so the", "# dump no longer has a field for them. A Work is its title, its notes and its", "# work-type identity; its site presence is its WorkSiteMembership, an Entry's", "# citations are its citationsData and a title rule is its definitionData. The", "# Site.works inverse went with Work.site, which is why inverse-site lists one", "# array fewer.",- "format 5",+ "# format 6 is the same store with one field removed from the citation blob:",+ "# rule-citation-by-uuid (T-2281) cites a rule by UUID alone, so no citation",+ "# carries a version. Re-recorded by deleting that key from the one blob that",+ "# held it, not by regenerating the file.",+ "format 6", "counts entries=\(entries.count) works=\(works.count) sites=\(sites.count) " + "titlePatterns=\(patterns.count) urlRulePatterns=\(rules.count) " + "workTypes=\(workTypes.count) memberships=\(memberships.count) "
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorNilSiteToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorNilSiteToleranceTests.swiftindex e88f224..c90b3b2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorNilSiteToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorNilSiteToleranceTests.swift@@ -20,7 +20,7 @@ import Testing /// that cannot be resolved is a tuple failure **only when the citing record /// points at a Site**. With no Site to resolve it within, there is nothing to /// replay and nothing to diagnose. Every other clause of each guard — a-/// nonblank identity, a complete `(id, version)` reference, the tuple table's+/// nonblank identity, a present rule id, the tuple table's /// own arms — is untouched, which is what the second half of each test pins. @Suite("Validator cited-rule sites tolerate a nil Site relationship", .serialized) struct LibraryValidatorNilSiteToleranceTests {@@ -85,8 +85,8 @@ struct LibraryValidatorNilSiteToleranceTests { func v3NameContributorToleratesNilSite() throws { let fixture = try ValidatorFixtures.wholeTitleSequence() unlink(fixture)- fixture.entry.editCitations { // version mismatch- $0.identity = $0.identity.citingNameTitleVersion(99)+ fixture.entry.editCitations { // cites a pattern no Site owns+ $0.identity = $0.identity.citingNameTitleID(UUID()) } let tolerated = try diagnostics(fixture)@@ -97,7 +97,7 @@ struct LibraryValidatorNilSiteToleranceTests { @Test("v3 name contributor: a populated entry.site still diagnoses an unresolvable pattern") func v3NameContributorStillFailsWithAPopulatedSite() throws { let fixture = try ValidatorFixtures.wholeTitleSequence()- fixture.entry.editCitations { $0.identity = $0.identity.citingNameTitleVersion(99) }+ fixture.entry.editCitations { $0.identity = $0.identity.citingNameTitleID(UUID()) } #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil) }@@ -131,7 +131,7 @@ struct LibraryValidatorNilSiteToleranceTests { func chapterProvenanceToleratesNilSite() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) }+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternID(UUID()) } let tolerated = try diagnostics(fixture) #expect(tolerated.quarantineMap().isEmpty)@@ -141,7 +141,7 @@ struct LibraryValidatorNilSiteToleranceTests { @Test("Chapter provenance: a populated entry.site still diagnoses an unresolvable pattern") func chapterProvenanceStillFailsWithAPopulatedSite() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()- fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) }+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternID(UUID()) } #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil) }@@ -281,7 +281,7 @@ struct LibraryValidatorNilSiteToleranceTests { func toleratedRecordStillRenders() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) }+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternID(UUID()) } let tolerated = try diagnostics(fixture) #expect(tolerated.quarantineMap().isEmpty)@@ -297,7 +297,7 @@ struct LibraryValidatorNilSiteToleranceTests { func strictValidationIsUnchanged() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) }+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternID(UUID()) } let diagnoses = try LibraryValidator.validateStrict(graph: fixture.graph) #expect(diagnoses[fixture.site.hostname] != nil,@@ -313,7 +313,7 @@ struct LibraryValidatorNilSiteToleranceTests { func entryTupleGateNeverTolerates() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) }+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternID(UUID()) } #expect(fixture.entry.site == nil) #expect(throws: LibraryValidationError.self) {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swiftindex 26fa987..533c943 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift@@ -130,18 +130,114 @@ struct LibraryValidatorTests { #expect(diagnoses[fixture.site.hostname] != nil) } - @Test("A non-current-greatest or imported-current URL rule is diagnosed")- func urlRuleVersionInvariants() throws {+ /// **The version invariants are retired** (T-2281 Req 3.1). A current URL+ /// rule below a retained one, and two rules sharing a version, are both+ /// states two teaches of one site produce and neither is a diagnosis: a+ /// citation names a UUID, so nothing reads the integer.+ @Test("A current URL rule below a retained one, at a shared version, validates")+ func urlRuleVersionsAreNotAnInvariant() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() let stale = try URLRulePattern( version: 5, isCurrent: false, createdAt: fixture.timestamp, origin: .readerTaught, definition: .work(locator: .query(name: ExactScalarString("series"))), site: fixture.site)- fixture.site.urlRules = [fixture.rule, stale] // current rule (v2) is not the greatest (v5)+ let twinVersion = try URLRulePattern(+ version: 5, isCurrent: false, createdAt: fixture.timestamp, origin: .readerTaught,+ definition: .work(locator: .query(name: ExactScalarString("story"))), site: fixture.site)+ // The current rule (v2) is below both retained rows, which themselves+ // share a version.+ fixture.site.urlRules = [fixture.rule, stale, twinVersion] let diagnoses = try LibraryValidator.validateStrict( graph: LibraryGraph( entries: [fixture.entry], works: [fixture.work], sites: [fixture.site],- titlePatterns: [fixture.titlePattern], urlRules: [fixture.rule, stale]))- #expect(diagnoses[fixture.site.hostname] != nil)+ titlePatterns: [fixture.titlePattern],+ urlRules: [fixture.rule, stale, twinVersion],+ memberships: [fixture.membership]))+ #expect(diagnoses[fixture.site.hostname] == nil)+ }++ /// The same for title patterns: two rows of unrelated rules at one version+ /// no longer collide (Req 3.1).+ @Test("Two unrelated title patterns sharing a version validate")+ func duplicateTitlePatternVersionsValidate() throws {+ let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()+ let retired = try TitlePattern(+ version: fixture.titlePattern.version, isActive: false,+ createdAt: fixture.timestamp, definition: .wholeTitle, site: fixture.site)+ fixture.site.patterns = [fixture.titlePattern, retired]+ let diagnoses = try LibraryValidator.validateStrict(+ graph: LibraryGraph(+ entries: [fixture.entry], works: [fixture.work], sites: [fixture.site],+ titlePatterns: [fixture.titlePattern, retired], urlRules: [fixture.rule],+ memberships: [fixture.membership]))+ #expect(diagnoses[fixture.site.hostname] == nil)+ }++ /// What survives the fused guard is **ownership**: a row whose own `site`+ /// does not point at the Site that lists it is still diagnosed (Req 3.1,+ /// last sentence). There is deliberately no test for it here, and the+ /// reason is worth recording.+ ///+ /// The ordinary split-ownership state — one row listed by Site A and owned+ /// by Site B — never reaches the ownership clause: the **membership** guard+ /// above it (`LibraryValidator.swift`, "title-pattern membership is+ /// incomplete or duplicated") compares each Site's listing against the rows+ /// that name it as owner, so it fires first. What is left for the ownership+ /// clause is the narrow case the membership sets cannot separate: a+ /// **same-UUID group split across two Sites**, where the id sets still match+ /// and only the per-row owner is wrong. That shape is unseedable in+ /// process — `Site.patterns` and `TitlePattern.site` are two ends of one+ /// `@Relationship`, so SwiftData reciprocates whichever end a test writes+ /// last: listing a row and then re-pointing it puts it back on the listing+ /// Site, and re-listing it re-owns it. Only a store that wrote the two ends+ /// separately can produce it. The clause itself is unchanged by T-2281; the+ /// Site-unique-version and positivity halves of the same `guard` are gone+ /// (Req 3.1, Q31).++ /// Q24: `version > 0` stays a **model** invariant on both rule rows even+ /// though no validator clause reads the column any more. Nothing produces a+ /// non-positive version, and `ArchiveRecordBuilders` constructs rows through+ /// these inits, so an archive cannot import one either.+ @Test("A non-positive rule-row version is still refused by the model")+ func nonPositiveRowVersionsStillThrow() throws {+ #expect(throws: ModelInvariantError.self) {+ try TitlePattern(+ version: 0, isActive: true, createdAt: Date(timeIntervalSince1970: 0),+ definition: .wholeTitle)+ }+ #expect(throws: ModelInvariantError.self) {+ try URLRulePattern(+ version: 0, isCurrent: true, createdAt: Date(timeIntervalSince1970: 0),+ origin: .readerTaught,+ definition: .sequence(locator: .query(name: ExactScalarString("chapter"))))+ }+ }++ /// Req 2.5: the validator's "these three cite the same rule" check compares+ /// UUIDs and nothing else. The rows the citations name carry whatever+ /// version they were minted at.+ @Test("A v2 identity whose Work and sequence cite the same rule UUID validates")+ func v2IdentityCitingOneRuleUUIDValidates() throws {+ let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()+ let citations = fixture.entry.citationValues+ #expect(citations.identity.urlRule?.id == fixture.rule.id)+ #expect(citations.urlWork?.id == fixture.rule.id)+ #expect(citations.chapterSequence?.id == fixture.rule.id)++ let diagnoses = try LibraryValidator.validateStrict(graph: fixture.graph)+ #expect(diagnoses[fixture.site.hostname] == nil)+ }++ /// Recent's Site-mode gate shares the retirement: a hostname whose patterns+ /// share a version is published, not hidden (Req 3.1).+ @Test("validatedRecentSiteMode returns the mode for a Site whose patterns share a version")+ func recentSiteModeToleratesDuplicateVersions() throws {+ let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()+ let retired = try TitlePattern(+ version: fixture.titlePattern.version, isActive: false,+ createdAt: fixture.timestamp, definition: .wholeTitle, site: fixture.site)+ fixture.site.patterns = [fixture.titlePattern, retired]++ #expect(LibraryRepository.validatedRecentSiteMode(fixture.site) == .taught) } // MARK: - Entry-state enumeration
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swiftindex a7a33e6..0e23e51 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift@@ -140,7 +140,7 @@ struct M4BulkChunkPerformanceTests { /// hostname, every one of them carrying the Site relationship whose assignment /// is the cost being measured. private enum M4ChunkFixture {- static func exportedFixturePayload() async throws -> BackupV7Payload {+ static func exportedFixturePayload() async throws -> BackupV8Payload { let root = FileManager.default.temporaryDirectory .appending( path: "asterism-m4-chunk-source-\(UUID().uuidString)", directoryHint: .isDirectory)@@ -154,7 +154,7 @@ private enum M4ChunkFixture { let repository = LibraryRepository.makeRepository( configuration, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy()) try await repository.seedM4PerformanceFixture()- let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() withExtendedLifetime(container) {} return payload }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swiftindex 5fe95b7..309a1e1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift@@ -315,7 +315,7 @@ struct M4DuplicateScalePerformanceTests { /// records what the projection alone costs so the claim is a reading rather /// than an argument. ///- /// The *projection* is timed, not `BackupV7Exporter.export`: the encode,+ /// The *projection* is timed, not `BackupV8Exporter.export`: the encode, /// the decode-validation and the file write dominate and none of them /// changed. @Test("Backup projection over a duplicate-free library (Q116, informational)")@@ -324,7 +324,7 @@ struct M4DuplicateScalePerformanceTests { let repository = try await store.openApp() let measured = try await measureDistributionAsync(iterations: 5) {- _ = try await repository.backupV7Snapshot()+ _ = try await repository.backupV8Snapshot() } reportPerformance("backup-projection-duplicate-free", measured) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swiftindex ca2fa4a..3414008 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift@@ -373,7 +373,7 @@ extension LibraryRepository { // seeds mean: a derived title, not a reader's. entry.editCitations { $0.chapterTitle = FieldProvenance.tolerant(- kind: provenance, patternID: nil, patternVersion: nil)+ kind: provenance, patternID: nil) } } else if seed.chapterTitle != nil { entry.editCitations { $0.chapterTitle = .manual }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swiftindex c1236da..13cf9d3 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift@@ -109,7 +109,7 @@ extension LibraryRepository { } } - /// Exports and decode-validates, which is exactly what `BackupV7Exporter`+ /// Exports and decode-validates, which is exactly what `BackupV8Exporter` /// does — and `BackupArchiveReferenceChecks` is where a membership whose /// hostname and cited identity rule describe **different sites** is refused, /// along with the rest of the identity tuple. Three separate membership bugs@@ -119,11 +119,11 @@ extension LibraryRepository { sourceLocation: SourceLocation = #_sourceLocation ) async throws { do {- let payload = try await backupV7Snapshot()- let encoded = try BackupV7Codec.encode(+ let payload = try await backupV8Snapshot()+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "test", exportedAt: M5Fixture.epoch))- _ = try BackupV7Codec.decode(encoded)+ metadata: BackupV8Metadata(appBuild: "test", exportedAt: M5Fixture.epoch))+ _ = try BackupV8Codec.decode(encoded) } catch { Issue.record( comment ?? "the archive is not legal: \(error)", sourceLocation: sourceLocation)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swiftindex 392d5af..72be2d0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift@@ -247,14 +247,13 @@ struct MembershipValidationTests { try fixture.entry.setCitations( EntryCitations( identity: .rawURL,- urlWork: CitedRule(id: fixture.rule.id, version: fixture.rule.version),- chapterSequence: CitedRule(id: fixture.rule.id, version: fixture.rule.version),+ urlWork: CitedRule(id: fixture.rule.id),+ chapterSequence: CitedRule(id: fixture.rule.id), chapterTitle: FieldProvenance.tolerant(- kind: .pattern, patternID: fixture.titlePattern.id,- patternVersion: fixture.titlePattern.version),+ kind: .pattern, patternID: fixture.titlePattern.id), workAssignment: .urlRule(- CitedRule(id: fixture.rule.id, version: fixture.rule.version)),- workURL: CitedRule(id: fixture.rule.id, version: fixture.rule.version),+ CitedRule(id: fixture.rule.id)),+ workURL: CitedRule(id: fixture.rule.id), workURLAssignmentKind: .identity)) #expect(
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swiftindex 2893f3d..921cc2f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift@@ -90,8 +90,8 @@ struct ModelContractTests { #expect(throws: ModelInvariantError.self) { try URLIdentityRule(version: 1, component: .queryItem, queryName: " ") } #expect(throws: ModelInvariantError.self) { try URLIdentityRule(version: 1, component: .pathSegment, queryName: "chapter") } #expect(throws: ModelInvariantError.self) { try JunkSuffixRule(version: 0, anchors: []) }- #expect(throws: ModelInvariantError.self) { try FieldProvenance(kind: .pattern, patternID: nil, patternVersion: 1) }- #expect(throws: ModelInvariantError.self) { try FieldProvenance(kind: .manual, patternID: UUID(), patternVersion: 1) }+ #expect(throws: ModelInvariantError.self) { try FieldProvenance(kind: .pattern, patternID: nil) }+ #expect(throws: ModelInvariantError.self) { try FieldProvenance(kind: .manual, patternID: UUID()) } #expect(throws: ModelInvariantError.self) { try PatternDefinition.phrase(prefix: "", separator: " ", suffix: "", order: .chapterThenWork).validated() }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swiftindex a75e972..057c38a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift@@ -162,51 +162,10 @@ struct MultiSiteReviewFixTests { // MARK: - C3 / Req 6.2: one corrupt blob cannot abandon a consolidation - @Test("A citation rewrite repairs an undecodable blob instead of throwing")- func citationRewriteSurvivesACorruptBlob() throws {- let store = try DuplicateStore()- let site = store.addSite(hostname: "a.example")- let ruleID = UUID()- let corrupt = store.addEntry(key: "one", capturedAt: 0, site: site)- let sound = store.addEntry(key: "two", capturedAt: 60, site: site)- for entry in [corrupt, sound] {- entry.editCitations { $0.chapterSequence = CitedRule(id: ruleID, version: 1) }- }- // Bytes no decoder will read — what a partially written blob leaves.- corrupt.citationsData = Data("not json".utf8)- try store.commit()-- var rewritten = 0- try store.read { context in- for entry in try context.fetch(FetchDescriptor<Entry>())- .sorted(by: { $0.rawURLString < $1.rawURLString })- where SiteReconciler.rewriteCitations(of: entry, [ruleID: 4]) {- rewritten += 1- }- try context.save()- }-- // Both rows moved, and the point is that *neither* aborted the walk: the- // corrupt row used to throw out of the enumeration and leave every- // *later* row citing the old version.- //- // The corrupt one moves by being **repaired** (Q24 of- // `drop-superseded-columns`): the edit lands over the default value, so- // the row stops being unreadable. It does not come back citing the rule- // at version 4 — there was nothing readable to rewrite — but it also no- // longer sits in the store as bytes no pass can touch.- #expect(rewritten == 2)- try store.read { context in- let rows = try context.fetch(FetchDescriptor<Entry>())- // Nothing is left undecodable.- #expect(rows.allSatisfy { (try? $0.citations) != nil })- // The sound row kept its citation and moved it to the new version;- // the repaired one holds the default, which cites nothing.- let versions = try rows.map { try $0.citations.chapterSequence?.version }- #expect(versions.compactMap { $0 } == [4])- #expect(versions.count { $0 == nil } == 1)- }- }+ // `citationRewriteSurvivesACorruptBlob` stood here: one undecodable blob+ // used to throw out of the citation-rewrite walk and leave every later row+ // citing a version its rule no longer held. There is no citation rewrite+ // since T-2281 — a citation is the rule.s UUID and a merge moves none. // MARK: - C5 / Q42, as V9's Q11 restates it @@ -249,21 +208,21 @@ struct MultiSiteReviewFixTests { @Test("An Entry citing another site's rule is refused before a file exists") func exportGateRefusesACrossSiteEntryCitation() throws { let ruleID = UUID()- let rule = BackupV7URLRule(+ let rule = BackupV8URLRule( id: ruleID, version: 1, isCurrent: true, createdAt: Self.epoch, origin: .readerTaught, definition: .work(locator: .query(name: ExactScalarString("identity"))), siteHostname: "b.example") let entry = Self.wireEntry( hostname: "a.example",- citations: EntryCitations(chapterSequence: CitedRule(id: ruleID, version: 1)))+ citations: EntryCitations(chapterSequence: CitedRule(id: ruleID))) - #expect(throws: BackupV7ExportError.self) {+ #expect(throws: BackupV8ExportError.self) { try LibraryRepository.requireCitationsResolve( entries: [entry], memberships: [], titlePatterns: [], urlRules: [rule]) } // Same rule, taught for the Entry's own site: legal.- let sameSite = BackupV7URLRule(+ let sameSite = BackupV8URLRule( id: ruleID, version: 1, isCurrent: true, createdAt: Self.epoch, origin: .readerTaught, definition: .work(locator: .query(name: ExactScalarString("identity"))),@@ -275,7 +234,7 @@ struct MultiSiteReviewFixTests { @Test("A chapter rule taught for another site is refused too") func exportGateRefusesACrossSitePatternCitation() throws { let patternID = UUID()- let pattern = BackupV7TitlePattern(+ let pattern = BackupV8TitlePattern( id: patternID, siteHostname: "b.example", version: 1, isActive: true, createdAt: Self.epoch, definition: StoredPatternDefinition(definition: .wholeTitle))@@ -283,9 +242,9 @@ struct MultiSiteReviewFixTests { hostname: "a.example", citations: EntryCitations( chapterTitle: try FieldProvenance(- kind: .pattern, patternID: patternID, patternVersion: 1)))+ kind: .pattern, patternID: patternID))) - #expect(throws: BackupV7ExportError.self) {+ #expect(throws: BackupV8ExportError.self) { try LibraryRepository.requireCitationsResolve( entries: [entry], memberships: [], titlePatterns: [pattern], urlRules: []) }@@ -293,9 +252,9 @@ struct MultiSiteReviewFixTests { private static func wireEntry( hostname: String, citations: EntryCitations- ) -> BackupV7Entry {+ ) -> BackupV8Entry { let url = "https://\(hostname)/one"- return BackupV7Entry(+ return BackupV8Entry( id: UUID(), captureTitle: "Chapter", captureTitleSource: .host, rawURL: url, canonicalURL: nil, hostname: hostname, entryIdentityKey: url, conservativeIdentityKey: url, identityBasis: .conservative, urlWorkIdentity: nil,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swiftindex 3d14375..5d12b71 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift@@ -135,7 +135,7 @@ struct RecentPresentationToleranceTests { let entry = store.insertEntry( id: entryID, hostname: "dup.example", title: "A Cited Work - Chapter 3", offset: 0) entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: losingPatternID, version: 1))+ $0.workAssignment = .pattern(CitedRule(id: losingPatternID)) } } // Pin the Entry to the row that owns its citation, undoing the seed's@@ -179,7 +179,7 @@ struct RecentPresentationToleranceTests { let entry = store.insertEntry( hostname: "taught.example", title: "A Cited Work - Chapter 3", offset: 0) entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: UUID(), version: 1))+ $0.workAssignment = .pattern(CitedRule(id: UUID())) } store.insertEntry( hostname: "taught.example", title: "Unrelated Capture", offset: 10)@@ -226,7 +226,7 @@ struct RecentPresentationToleranceTests { id: entryID, hostname: "taught.example", title: "A Cited Work - Chapter 3", offset: 0) entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: citedID, version: 1))+ $0.workAssignment = .pattern(CitedRule(id: citedID)) } } // Undo the seed's relationship pass for this one record: the shape a@@ -252,7 +252,7 @@ struct RecentPresentationToleranceTests { // whole point of resolving it in one place. let detail = try await repository.entryTeachingDetail(id: entryID) #expect(detail.unresolvedCandidateTitle == nil)- if case .patternUnsettled(_, _, let reason) = detail.assignmentSettlement {+ if case .patternUnsettled(_, let reason) = detail.assignmentSettlement { #expect(reason != "Cited title pattern does not resolve", "an absent relationship is not an unresolvable citation") }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileAfterSyncTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileAfterSyncTests.swiftindex 03f851e..596274c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileAfterSyncTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileAfterSyncTests.swift@@ -76,12 +76,16 @@ struct ReconcileAfterSyncTests { /// its own `TitlePattern` as version 1 active, and CloudKit unioned the /// records into one row carrying two v1-active patterns. ///+ /// Since T-2281 the shared version is not the damage — two *marked* rows are+ /// (Req 3.6, 3.7). The repair is the demotion alone, and both rows keep the+ /// version they were minted at.+ /// /// Nothing preloads `tupleDiagnoses` here, and that is the whole test. The /// arrival debounce reconciles *before* it refreshes, so the cached tuple set /// describes the library as it was before the patterns landed — empty. A pass /// that took its colliding hostnames from that cache saw nothing to do, and /// the collision sat there until the next launch ran a full validation.- @Test("A same-row version collision that just arrived is repaired without a relaunch")+ @Test("A same-row double-marked collision that just arrived is repaired without a relaunch") func anArrivedCollisionIsRepairedWithoutARelaunch() async throws { let env = try ReconcileEnvironment() let (_, repository) = try await LibraryRepository.openForApp(env.configuration)@@ -99,13 +103,41 @@ struct ReconcileAfterSyncTests { #expect(rows.rowCount == 1) #expect(rows.survivorPatternCount == 2) #expect(rows.survivorActiveCount == 1)- #expect(rows.survivorVersions == [1, 2])- // Req 1.4: the rule the Entry cites still resolves after the renumbering.+ // Both rows keep the version their device minted: the repair is the+ // demotion, and nothing renumbers (Req 3.4).+ #expect(rows.survivorVersions == [1, 1])+ // Req 1.4: the rule the Entry cites still resolves afterwards, which it+ // does because a citation names the UUID and the merge moved no id. #expect(try await repository.citationsResolveForTesting()) // And the screen the reader taps through to opens rather than throwing. _ = try await repository.entryTeachingDetail(id: entryID) } + /// The negative half of the same property (task 5.1). A shared version used+ /// to be one of the conditions that named a hostname as work; since T-2281+ /// nothing renumbers and only a **second marked row** is damage (Req 3.6,+ /// 3.7). One row holding two title patterns at one version and two URL rules+ /// at one version, with a single marked row of each kind, is therefore not+ /// work at all — and `reconcileWorkLists` being private, the assertion is+ /// made through the public pass: a hostname the work list never named is a+ /// hostname the outcome never reports.+ @Test("Rules that merely share a version are not work for the pass")+ func aSharedVersionIsNotWork() async throws {+ let env = try ReconcileEnvironment()+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)+ try await repository.seedSharedRuleVersions(hostname: Self.hostname)++ let outcome = try await repository.reconcileAfterSync()++ #expect(outcome.site.consolidatedHostnames.isEmpty)+ #expect(outcome.isEmpty)+ // And nothing was demoted or renumbered on the way past.+ let rows = try await repository.sitesForTesting(hostname: Self.hostname)+ #expect(rows.rowCount == 1)+ #expect(rows.survivorActiveCount == 1)+ #expect(rows.survivorVersions == [4, 4])+ }+ // MARK: - Req 2.2: a repaired hostname sheds its diagnosis in the same pass /// What the runbook's repaired device showed: the launch reconcile fixed the@@ -249,6 +281,49 @@ extension LibraryRepository { } } + /// One taught row whose rules share versions without colliding: two title+ /// patterns at version 4 with one active, and two URL rules at version 4+ /// with one current. Legal since T-2281 — the integer is advisory — and the+ /// shape `reconcileWorkLists` must not report as work.+ fileprivate func seedSharedRuleVersions(hostname: String) async throws {+ try await withLockedContext(+ mode: .exclusive, operation: "seeding rules that share a version"+ ) { context in+ let site = Site(hostname: hostname, displayName: hostname)+ site.mode = .taught+ context.insert(site)+ let createdAt = Date(timeIntervalSince1970: 1_800_000_000)++ for (rank, isActive) in [(1, true), (2, false)] {+ let pattern = try TitlePattern(+ id: UUID(uuidString: String(format: "00000000-0000-4000-8000-%012d", rank))!,+ version: 4, isActive: isActive, createdAt: createdAt,+ definition: .segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: []),+ site: site)+ context.insert(pattern)+ }++ let current = try URLRulePattern(+ id: UUID(uuidString: "00000000-0000-4000-8000-000000000003")!,+ version: 4, isCurrent: true, createdAt: createdAt, origin: .readerTaught,+ definition: .workAndSequence(+ work: URLFieldSelector(locator: .query(name: ExactScalarString("series"))),+ sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter")))),+ site: site)+ context.insert(current)+ let retained = try URLRulePattern(+ id: UUID(uuidString: "00000000-0000-4000-8000-000000000004")!,+ version: 4, isCurrent: false, createdAt: createdAt, origin: .readerTaught,+ definition: .work(locator: .query(name: ExactScalarString("story"))),+ site: site)+ context.insert(retained)++ try context.save()+ }+ }+ /// One Site row taught concurrently on two devices: two `TitlePattern`s both /// minted as version 1 and both active, plus an Entry citing one of them. /// This is the field shape verified in the pristine store copies from both@@ -285,7 +360,7 @@ extension LibraryRepository { // the rule the losing device minted, at the version that device gave // it, and the renumbering has to carry it. entry.editCitations {- $0.workAssignment = .pattern(CitedRule(id: minted[1].id, version: 1))+ $0.workAssignment = .pattern(CitedRule(id: minted[1].id)) } context.insert(entry) try context.save()@@ -325,16 +400,15 @@ extension LibraryRepository { diagnostics.tupleDiagnoses[hostname] != nil } - /// Every `(rule id, version)` an Entry cites names a rule the store holds at- /// that version (Req 1.4).+ /// Every rule UUID an Entry cites names a rule the store holds (Req 1.4). fileprivate func citationsResolveForTesting() async throws -> Bool { try await withLockedContext(mode: .shared, operation: "resolving citations") { context in- var versions: [UUID: Set<Int>] = [:]+ var known: Set<UUID> = [] for pattern in try context.fetch(FetchDescriptor<TitlePattern>()) {- versions[pattern.id, default: []].insert(pattern.version)+ known.insert(pattern.id) } for rule in try context.fetch(FetchDescriptor<URLRulePattern>()) {- versions[rule.id, default: []].insert(rule.version)+ known.insert(rule.id) } // Read from the blob, which is where a citation lives since V9 — // and every one of the seven, rather than the four the columns this@@ -342,7 +416,7 @@ extension LibraryRepository { for entry in try context.fetch(FetchDescriptor<Entry>()) { for citation in entry.citationValues.ruleCitations { guard let cited = citation.cited else { continue }- if versions[cited.id]?.contains(cited.version) != true { return false }+ if !known.contains(cited.id) { return false } } } return true
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swiftindex b90df2a..8022649 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift@@ -19,7 +19,7 @@ import Testing /// /// What that would actually re-enable is asserted here rather than assumed: /// capture would start applying an illegal Site's rules again-/// (`+ReparseCapture.swift:284`, `:396`), and `BackupV7Exporter.swift:41` would+/// (`+ReparseCapture.swift:284`, `:396`), and `BackupV8Exporter.swift:41` would /// stop gating. The four Req 3.4 write-path guards read /// `diagnostics.diagnoses` for `.duplicateSiteRows` (Q41), a class the scan does /// re-derive, so they are the weaker half of the assertion — pinned anyway,@@ -108,7 +108,7 @@ struct RefreshUnionInvariantTests { #expect(await repository.quarantineReason(hostname: tupleHost) != nil) for attempt in 0...2 { if attempt > 0 { try await repository.refreshDiagnostics() }- let payload = try await repository.backupV7Snapshot()+ let payload = try await repository.backupV8Snapshot() // One wire Site per hostname, including the duplicated one and the // rowless one (Q38, Q40). #expect(Set(payload.sites.map(\.hostname))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swiftindex 5fd6cab..73bb137 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift@@ -31,11 +31,10 @@ struct RepositoryTeachingTests { // Commit let result = try await fixture.repository.commitTeaching(contract)- guard case .committed(let patternID, let patternVersion) = result else {+ guard case .committed(let patternID) = result else { Issue.record("Expected .committed, got \(result)") return }- #expect(patternVersion == 1) #expect(save.saveCount == 1) // Exactly one save // Verify Site is now taught@@ -46,7 +45,6 @@ struct RepositoryTeachingTests { let entry1 = try await fixture.repository.entry(id: e1.id) #expect(entry1.chapterTitle == "Chapter 1") #expect(entry1.chapterTitleProvenance.kind == .pattern)- #expect(entry1.chapterTitleProvenance.patternVersion == 1) #expect(entry1.workID != nil) let entry2 = try await fixture.repository.entry(id: e2.id)@@ -412,7 +410,7 @@ struct RepositoryTeachingTests { ) let c1 = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: def1) let r1 = try await fixture.repository.commitTeaching(c1)- guard case .committed = r1 else { Issue.record("Expected committed"); return }+ guard case .committed(let initialPatternID) = r1 else { Issue.record("Expected committed"); return } let e3 = try await fixture.capture(title: "Ch3 :: New Work :: Site", rawURL: "https://example.com/3") save.resetCount()@@ -427,9 +425,15 @@ struct RepositoryTeachingTests { boundaryEntryID: e3.id ) let result = try await fixture.repository.commitTeaching(contract)- guard case .committed(_, let ver) = result else { Issue.record("Expected committed"); return }-- #expect(ver == 2)+ guard case .committed(let replacementPatternID) = result+ else { Issue.record("Expected committed"); return }++ // What the commit wrote, not what the contract asked for: a second+ // pattern row, and it — not the initial one — is the site's active rule.+ #expect(replacementPatternID != initialPatternID)+ let detail = try await fixture.repository.entryTeachingDetail(id: e3.id)+ #expect(detail.activePatternSummary?.id == replacementPatternID)+ #expect(detail.activePatternSummary?.isActive == true) #expect(save.saveCount == 1) let counts = try await fixture.repository.debugCounts() #expect(counts.titlePatterns == 2)@@ -447,23 +451,26 @@ struct RepositoryTeachingTests { ) let c1 = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: def1) let r1 = try await fixture.repository.commitTeaching(c1)- guard case .committed(let initialPatternID, _) = r1 else { Issue.record("Expected committed"); return }+ guard case .committed(let initialPatternID) = r1 else { Issue.record("Expected committed"); return } let e3 = try await fixture.capture(title: "Ch3 - Fiction | Site", rawURL: "https://example.com/3") let contract = try await fixture.repository.projectReplacement( hostname: "example.com", patternDefinition: def1, boundaryEntryID: e3.id,- historicalPatternID: initialPatternID,- historicalPatternVersion: 1+ historicalPatternID: initialPatternID ) let result = try await fixture.repository.commitTeaching(contract)- guard case .committed = result else { Issue.record("Expected committed"); return }+ guard case .committed(let replacementPatternID) = result+ else { Issue.record("Expected committed"); return } + // The corrected fields cite the pattern the commit just wrote, not+ // merely "something other than the one they cited before".+ #expect(replacementPatternID != initialPatternID) let entry1 = try await fixture.repository.entry(id: e1.id)- #expect(entry1.chapterTitleProvenance.patternVersion == 2)+ #expect(entry1.chapterTitleProvenance.patternID == replacementPatternID) let entry2 = try await fixture.repository.entry(id: e2.id)- #expect(entry2.chapterTitleProvenance.patternVersion == 2)+ #expect(entry2.chapterTitleProvenance.patternID == replacementPatternID) _ = e3 } @@ -704,14 +711,13 @@ struct RepositoryActionableRecentTests { patternDefinition: historicalDefinition ) let result = try await fixture.repository.commitTeaching(contract)- guard case .committed(let patternID, let patternVersion) = result else {+ guard case .committed(let patternID) = result else { Issue.record("Expected teaching commit") return } try await fixture.seedUnresolvedAssignment( entryID: entry.id,- patternID: patternID,- patternVersion: patternVersion+ patternID: patternID ) try await fixture.replaceActivePatternOnly( hostname: "example.com",@@ -751,7 +757,7 @@ struct RepositoryActionableRecentTests { patternDefinition: definition ) let outcome = try await fixture.repository.commitTeaching(contract)- guard case .committed(let patternID, let patternVersion) = outcome else {+ guard case .committed(let patternID) = outcome else { Issue.record("Expected teaching commit") return }@@ -761,8 +767,7 @@ struct RepositoryActionableRecentTests { ) try await fixture.seedUnresolvedAssignment( entryID: unparseable.id,- patternID: patternID,- patternVersion: patternVersion+ patternID: patternID ) let presentation = try await fixture.repository.recentPresentation(calendar: .current)@@ -1012,7 +1017,7 @@ struct RepositoryEntryTeachingDetailTests { ) let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition) let result = try await fixture.repository.commitTeaching(tc)- guard case .committed(let patternID, _) = result else {+ guard case .committed(let patternID) = result else { Issue.record("Expected committed"); return } @@ -1027,22 +1032,19 @@ struct RepositoryEntryTeachingDetailTests { hostname: "example.com", patternDefinition: definition2, boundaryEntryID: entry.id,- historicalPatternID: patternID,- historicalPatternVersion: 1+ historicalPatternID: patternID ) let result2 = try await fixture.repository.commitTeaching(tc2)- guard case .committed(_, let version2) = result2 else {+ guard case .committed = result2 else { Issue.record("Expected committed"); return }- #expect(version2 == 2)+ #expect(tc2.outcome.computedPatternVersion == 2) let detail = try await fixture.repository.entryTeachingDetail(id: entry.id) #expect(detail.activePatternSummary != nil) #expect(detail.activePatternSummary?.isActive == true)- #expect(detail.activePatternSummary?.version == 2) #expect(detail.historicalPatternSummaries.count == 1) #expect(detail.historicalPatternSummaries[0].isActive == false)- #expect(detail.historicalPatternSummaries[0].version == 1) } @Test("Manual protection reason is independent and exact")@@ -1082,7 +1084,7 @@ struct RepositoryEntryTeachingDetailTests { patternDefinition: historicalDefinition ) let outcome = try await fixture.repository.commitTeaching(contract)- guard case .committed(let historicalPatternID, let historicalVersion) = outcome else {+ guard case .committed(let historicalPatternID) = outcome else { Issue.record("Expected initial teaching commit") return }@@ -1099,10 +1101,8 @@ struct RepositoryEntryTeachingDetailTests { let detail = try await fixture.repository.entryTeachingDetail(id: entry.id) #expect(detail.entry.workID == nil) #expect(detail.entry.workAssignmentProvenance.patternID == historicalPatternID)- #expect(detail.entry.workAssignmentProvenance.patternVersion == historicalVersion)- if case .patternUnsettled(let patternID, let version, let reason) = detail.assignmentSettlement {+ if case .patternUnsettled(let patternID, let reason) = detail.assignmentSettlement { #expect(patternID == historicalPatternID)- #expect(version == historicalVersion) #expect(reason == "Pattern produced no assignment value") } else { Issue.record("Expected patternUnsettled assignment, got \(detail.assignmentSettlement)")@@ -1113,7 +1113,7 @@ struct RepositoryEntryTeachingDetailTests { /// The Entry detail half of the same reversal (Q13). Same reason: this branch /// becomes reachable from an unarrived relationship, and failing the screen /// for one Entry is what Req 3.4 forbids. The citation is disclosed as- /// unresolved instead, with its `(id, version)` kept as evidence (Req 4.2).+ /// unresolved instead, with its rule id kept as evidence (Req 4.2). @Test("Replay failure on a retained producing pattern is disclosed, not thrown") func replayFailureIsDisclosed() async throws { let fixture = try await TeachingFixture()@@ -1130,7 +1130,7 @@ struct RepositoryEntryTeachingDetailTests { patternDefinition: definition ) let outcome = try await fixture.repository.commitTeaching(contract)- guard case .committed(let patternID, let patternVersion) = outcome else {+ guard case .committed(let patternID) = outcome else { Issue.record("Expected initial teaching commit") return }@@ -1141,8 +1141,7 @@ struct RepositoryEntryTeachingDetailTests { ) try await fixture.seedUnresolvedAssignment( entryID: unparseable.id,- patternID: patternID,- patternVersion: patternVersion+ patternID: patternID ) let detail = try await fixture.repository.entryTeachingDetail(id: unparseable.id)@@ -1151,7 +1150,7 @@ struct RepositoryEntryTeachingDetailTests { #expect( detail.assignmentSettlement == .patternUnsettled(- patternID: patternID, version: patternVersion,+ patternID: patternID, reason: "Cited title pattern does not resolve")) // Everything the screen could resolve is still disclosed. #expect(detail.siteMode == .taught)@@ -1392,11 +1391,11 @@ struct RepositoryPhraseIntegrationTests { patternDefinition: phraseDefinition, boundaryEntryID: phraseEntry.id )- guard case .committed(_, let phraseVersion) = try await fixture.repository.commitTeaching(toPhrase) else {+ guard case .committed = try await fixture.repository.commitTeaching(toPhrase) else { Issue.record("Expected segment-to-phrase replacement") return }- #expect(phraseVersion == 2)+ #expect(toPhrase.outcome.computedPatternVersion == 2) #expect(try await fixture.repository.entry(id: phraseEntry.id).chapterTitle == "Chapter 2") let segmentEntry = try await fixture.capture(@@ -1408,11 +1407,11 @@ struct RepositoryPhraseIntegrationTests { patternDefinition: segmentDefinition, boundaryEntryID: segmentEntry.id )- guard case .committed(_, let segmentVersion) = try await fixture.repository.commitTeaching(toSegment) else {+ guard case .committed = try await fixture.repository.commitTeaching(toSegment) else { Issue.record("Expected phrase-to-segment replacement") return }- #expect(segmentVersion == 3)+ #expect(toSegment.outcome.computedPatternVersion == 3) #expect(try await fixture.repository.entry(id: segmentEntry.id).chapterTitle == "Chapter 3") #expect(try await fixture.repository.debugCounts().titlePatterns == 3) }@@ -1428,14 +1427,13 @@ struct RepositoryPhraseIntegrationTests { hostname: "example.com", patternDefinition: phraseDefinition )- guard case .committed(let patternID, let patternVersion) = try await fixture.repository.commitTeaching(teaching) else {+ guard case .committed(let patternID) = try await fixture.repository.commitTeaching(teaching) else { Issue.record("Expected phrase teaching commit") return } try await fixture.seedUnresolvedAssignment( entryID: entry.id,- patternID: patternID,- patternVersion: patternVersion+ patternID: patternID ) try await fixture.replaceActivePatternOnly( hostname: "example.com",@@ -1527,8 +1525,7 @@ private struct TeachingFixture { func seedUnresolvedAssignment( entryID: UUID,- patternID: UUID,- patternVersion: Int+ patternID: UUID ) async throws { let lease = try await CrossProcessLibraryLock.acquire( mode: .exclusive,@@ -1546,7 +1543,7 @@ private struct TeachingFixture { entry.work = nil entry.editCitations { $0.workAssignment = .pattern(- CitedRule(id: patternID, version: patternVersion))+ CitedRule(id: patternID)) } entry.intentionallyUnattached = false try context.save()
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReteachDiagnosisComparisonTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReteachDiagnosisComparisonTests.swiftindex 1359cca..832d695 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReteachDiagnosisComparisonTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReteachDiagnosisComparisonTests.swift@@ -83,24 +83,26 @@ struct ReteachDiagnosisComparisonTests { #expect(await repository.quarantineReason(hostname: host) == before) } + /// The introduced diagnosis used to be a **version collision**: an active v5+ /// rule beside a retired v6 one, re-taught, allocated version 6 again and+ /// made the Site's own tuple illegal. T-2281 retired that invariant, so the+ /// seed is the one its recalculation sibling below already uses — a+ /// membership's `workURLString` mutated out from under the contract by+ /// another writer. The composed commit compares the whole quarantine entry+ /// for the hostname, so a diagnosis appearing in a field the basis does not+ /// observe still rolls the teach back. @Test("Composed teaching rolls back when it would introduce a new diagnosis") func composedTeachingRollsBackNewDiagnosis() async throws { let fixture = try ReteachFixture()- // A legal taught Site holding an active v5 rule and a retired v6 rule.- // Re-teaching allocates version 6, which collides with the retired rule- // and makes the Site's own tuple illegal — a diagnosis this hostname did- // not previously carry.+ let workID = UUID() try fixture.seed { context in- let site = Site(hostname: self.host)- site.mode = .taught- context.insert(site)- context.insert(try TitlePattern(- version: 5, isActive: true, createdAt: Date(timeIntervalSince1970: 1),- definition: .wholeTitle, site: site))- context.insert(try TitlePattern(- version: 6, isActive: false, createdAt: Date(timeIntervalSince1970: 2),- definition: try ReteachFixture.wcSegment(), site: site))- ReteachFixture.insertEntry(context, hostname: self.host, title: "Chapter 7 - Real Work", seconds: 10)+ _ = try ReteachFixture.taughtSite(context, hostname: self.host)+ ReteachFixture.insertEntry(+ context, hostname: self.host, title: "Chapter 7 - Real Work", seconds: 10)+ let work = Work.create(+ in: context, id: workID, title: "Unrelated Anthology", hostname: self.host,+ timestamp: Date(timeIntervalSince1970: 1))+ work.membershipValues.first?.workURLString = "https://reteach.example/anthology" } let repository = try fixture.diagnosedRepository() #expect(await repository.quarantineReason(hostname: host) == nil)@@ -110,18 +112,27 @@ struct ReteachDiagnosisComparisonTests { request: ComposedTeachingRequest( titleDefinition: try ReteachFixture.wcSegment(), urlDefinition: nil, acknowledgeUnsettled: true))++ // The extension writes the same store between the projection and the+ // commit, in a field the basis does not observe — so the contract does+ // not go stale and the diagnosis comparison is what refuses.+ try fixture.seed { context in+ let work = try context.fetch(FetchDescriptor<Work>()).first { $0.id == workID }+ work?.membershipValues.first?.workURLString = "not a url"+ }+ let outcome = try await repository.commitComposedTeaching(contract) guard case .invalidated(let reason) = outcome else { Issue.record("expected invalidated, got \(outcome)"); return } // Req 3.3: the refusal names what it would have introduced.- #expect(reason.contains("Site-unique versions"), "reason did not name the diagnosis: \(reason)")+ #expect(reason.contains("absolute HTTP"), "reason did not name the diagnosis: \(reason)") - // Rolled back: no third pattern, and the Site is untouched.+ // Rolled back: the Site kept the rule it was taught with, and no new+ // pattern landed. let context = fixture.freshContext() let patterns = try context.fetch(FetchDescriptor<TitlePattern>())- #expect(patterns.count == 2)- #expect(Set(patterns.map(\.version)) == [5, 6])+ #expect(patterns.count == 1) } // MARK: - Recalculation
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RetiredUnreadableRuleTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RetiredUnreadableRuleTests.swiftindex b758a04..faff70d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RetiredUnreadableRuleTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RetiredUnreadableRuleTests.swift@@ -44,7 +44,7 @@ struct RetiredUnreadableRuleTests { let fixture = try UnreadableRuleFixture() try fixture.seedSiteHoldingFourRules(corruptCurrent: false) let retiredID = try corruptRetiredRule(fixture, version: 2)- try fixture.citeRuleFromEntry(ruleID: retiredID, ruleVersion: 2)+ try fixture.citeRuleFromEntry(ruleID: retiredID) let diagnostics = try LibraryValidator.validate(context: fixture.freshContext()) #expect(diagnostics.quarantineMap()[UnreadableRuleFixture.hostname] == nil)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleOrderingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleOrderingTests.swiftnew file mode 100644index 0000000..cf10f46--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleOrderingTests.swift@@ -0,0 +1,216 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// T-2281 Req 4.4, Q10: wherever a Site's rules are put in order — the Sites+/// screen's pattern list, the Entry detail's historical rules, the teaching+/// basis and the URL-identity evidence basis — the key is `(createdAt, id)`+/// ascending. `version` is an advisory integer since this feature (Q4) and is+/// no longer a sort key anywhere.+///+/// The three-pattern fixture — `seedThreePatterns`, behind the Sites screen,+/// the Entry detail history and the teaching basis — seeds rows whose `version`+/// order **contradicts** their `createdAt` order, so a sort that still read the+/// integer produces a different sequence rather than the same one by luck. The+/// other fixtures are about the id tiebreak among rows of the same age, where+/// `version` has nothing to say either way.+@Suite("Rule orderings are (createdAt, id) ascending", .serialized)+struct RuleOrderingTests {++ // Three patterns. Ordered by `createdAt`: alpha, beta, gamma. Ordered by+ // `version`: beta (1), gamma (2), alpha (3). Gamma is the active one, so+ // the historical pair is [alpha, beta] by date and [beta, alpha] by version.+ private static let alpha = (version: 3, offset: TimeInterval(0))+ private static let beta = (version: 1, offset: TimeInterval(60))+ private static let gamma = (version: 2, offset: TimeInterval(120))++ // MARK: - The Sites screen (`+Sites.swift`)++ @Test("SiteSnapshot.patternIDs lists the Site's rules oldest first")+ func sitesScreenOrdersByCreatedAt() async throws {+ let fixture = try OrderingFixture()+ let ids = try fixture.seedThreePatterns()++ let sites = try await fixture.repository().sites()+ let row = try #require(sites.first { $0.hostname == OrderingFixture.hostname })+ #expect(row.patternIDs == [ids.alpha, ids.beta, ids.gamma])+ }++ // MARK: - Entry detail's historical rules (`+EntryDetail.swift`)++ @Test("Entry detail lists the historical rules oldest first")+ func entryDetailOrdersHistoryByCreatedAt() async throws {+ let fixture = try OrderingFixture()+ let ids = try fixture.seedThreePatterns()+ let entryID = try fixture.seedEntry()++ let detail = try await fixture.repository().entryTeachingDetail(id: entryID)+ #expect(detail.activePatternSummary?.id == ids.gamma)+ #expect(detail.historicalPatternSummaries.map(\.id) == [ids.alpha, ids.beta])+ }++ // MARK: - The teaching basis (`+Contracts.swift`)++ @Test("TeachingBasis.patterns is sorted by (createdAt, id)")+ func teachingBasisOrdersByCreatedAt() async throws {+ let fixture = try OrderingFixture()+ let ids = try fixture.seedThreePatterns()++ // Any projection will do: all of them build the same basis, and the+ // articles one has the fewest preconditions on a taught Site.+ let contract = try await fixture.repository()+ .projectArticles(hostname: OrderingFixture.hostname, junkSuffixRule: nil)+ #expect(contract.basis.patterns.map(\.id) == [ids.alpha, ids.beta, ids.gamma])+ }++ /// Equal `createdAt` is the state a bulk import or a migrated row leaves,+ /// so the second key has to be exercised too.+ @Test("Rules of the same age order by id")+ func equalCreatedAtOrdersByID() async throws {+ let fixture = try OrderingFixture()+ let ids = try fixture.seedTwoPatternsOfTheSameAge()++ let sites = try await fixture.repository().sites()+ let row = try #require(sites.first { $0.hostname == OrderingFixture.hostname })+ #expect(row.patternIDs == [ids.low, ids.high])+ }++ // MARK: - The URL-identity evidence basis (`URLIdentityPlanner.swift`)++ /// A value type, so this one is stated directly: the basis sorts what it is+ /// handed. `URLRuleBasisEntry` carries no `version` field at all any more,+ /// so the refusal the basis used to raise over rules sharing one (Req 3.1)+ /// has nothing left to read — the invariant went with the field.+ @Test("URLSiteEvidenceBasis sorts its rules by (createdAt, id)")+ func urlEvidenceBasisOrdersByCreatedAt() throws {+ let epoch = Date(timeIntervalSince1970: 1_800_000_000)+ func rule(_ id: String, offset: TimeInterval, query: String) throws -> URLRuleBasisEntry {+ try URLRuleBasisEntry(+ id: UUID(uuidString: id)!, createdAt: epoch.addingTimeInterval(offset),+ isCurrent: false, origin: .readerTaught,+ definition: .work(locator: .query(name: ExactScalarString(query))))+ }+ let oldest = try rule("FFFFFFFF-0000-0000-0000-000000000001", offset: 0, query: "a")+ let middle = try rule("00000000-0000-0000-0000-000000000002", offset: 60, query: "b")+ let newest = try rule("00000000-0000-0000-0000-000000000003", offset: 120, query: "c")++ let basis = try URLSiteEvidenceBasis(+ hostname: ExactScalarString(OrderingFixture.hostname),+ rules: [newest, oldest, middle], entries: [], works: [])+ #expect(basis.rules.map(\.id) == [oldest.id, middle.id, newest.id])+ }++ @Test("Two rules of the same age order by id")+ func urlEvidenceBasisTiebreaksOnID() throws {+ let epoch = Date(timeIntervalSince1970: 1_800_000_000)+ func rule(_ id: String, query: String) throws -> URLRuleBasisEntry {+ try URLRuleBasisEntry(+ id: UUID(uuidString: id)!, createdAt: epoch, isCurrent: false,+ origin: .readerTaught,+ definition: .work(locator: .query(name: ExactScalarString(query))))+ }+ let low = try rule("00000000-0000-0000-0000-0000000000AA", query: "a")+ let high = try rule("FFFFFFFF-0000-0000-0000-0000000000FF", query: "b")++ let basis = try URLSiteEvidenceBasis(+ hostname: ExactScalarString(OrderingFixture.hostname),+ rules: [high, low], entries: [], works: [])+ #expect(basis.rules.map(\.id) == [low.id, high.id])+ }+}++/// A store holding one taught Site whose rules' `version` order contradicts+/// their `createdAt` order.+private final class OrderingFixture {+ static let hostname = "ordering.example"+ static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++ let directory: URL+ let configuration: LibraryConfiguration+ let container: ModelContainer++ init() throws {+ directory = FileManager.default.temporaryDirectory+ .appending(path: "AsterismRuleOrdering-\(UUID())", directoryHint: .isDirectory)+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+ configuration = LibraryConfiguration(rootDirectory: directory)+ try FileManager.default.createDirectory(+ at: configuration.storeURL.deletingLastPathComponent(),+ withIntermediateDirectories: true)+ container = try LibraryRepository.openContainer(at: configuration.storeURL)+ }++ deinit {+ try? FileManager.default.removeItem(at: directory)+ }++ func repository() -> LibraryRepository {+ LibraryRepository.makeRepository(+ configuration, container, .multiSite,+ FixedRepositoryClock(Self.epoch), ModelContextSaveStrategy())+ }++ /// Inserted in `version` order, which is neither the `createdAt` order nor+ /// the id order — so an unsorted read is distinguishable from both.+ func seedThreePatterns() throws -> (alpha: UUID, beta: UUID, gamma: UUID) {+ let context = ModelContext(container)+ let site = Site(hostname: Self.hostname)+ site.mode = .taught+ context.insert(site)++ func pattern(version: Int, offset: TimeInterval, isActive: Bool) throws -> UUID {+ let row = try TitlePattern(+ version: version, isActive: isActive,+ createdAt: Self.epoch.addingTimeInterval(offset),+ definition: .wholeTitle, site: site)+ context.insert(row)+ return row.id+ }+ let beta = try pattern(version: 1, offset: 60, isActive: false)+ let gamma = try pattern(version: 2, offset: 120, isActive: true)+ let alpha = try pattern(version: 3, offset: 0, isActive: false)+ try context.save()+ try SiteRelationshipPopulationPass.run(context: context)+ return (alpha, beta, gamma)+ }++ /// Two patterns stamped with the same `createdAt`, so only the id can+ /// separate them.+ func seedTwoPatternsOfTheSameAge() throws -> (low: UUID, high: UUID) {+ let context = ModelContext(container)+ let site = Site(hostname: Self.hostname)+ site.mode = .taught+ context.insert(site)++ let high = try TitlePattern(+ id: UUID(uuidString: "FFFFFFFF-0000-0000-0000-0000000000FF")!,+ version: 1, isActive: true, createdAt: Self.epoch,+ definition: .wholeTitle, site: site)+ let low = try TitlePattern(+ id: UUID(uuidString: "00000000-0000-0000-0000-0000000000AA")!,+ version: 2, isActive: false, createdAt: Self.epoch,+ definition: .wholeTitle, site: site)+ context.insert(high)+ context.insert(low)+ try context.save()+ try SiteRelationshipPopulationPass.run(context: context)+ return (low.id, high.id)+ }++ @discardableResult+ func seedEntry() throws -> UUID {+ let context = ModelContext(container)+ let site = try #require(try context.fetch(FetchDescriptor<Site>()).first)+ let rawURL = "https://\(Self.hostname)/fiction/a-story"+ let entry = Entry(+ captureTitle: "A Story", captureTitleSource: .host, rawURLString: rawURL,+ hostname: Self.hostname, entryIdentityKey: rawURL, timestamp: Self.epoch)+ entry.conservativeIdentityKey = rawURL+ entry.site = site+ context.insert(entry)+ try context.save()+ return entry.id+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swiftnew file mode 100644index 0000000..d0f1cb9--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift@@ -0,0 +1,330 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// T-2281 Req 4.1/4.2, Decision 1: "current" is the marked row, and where more+/// than one row is marked it is the marked row with the newest `createdAt`,+/// then the lowest UUID. No ordering by `version` participates.+///+/// The tiebreak lives in exactly one place per rule kind (Q11) because Req 4.2+/// is only testable that way: with twenty call sites reading `isActive` /+/// `isCurrent` for themselves, "every reader picks the same row" is a claim+/// about twenty independent expressions. The source scan at the bottom of this+/// suite is what keeps it to one.+@Suite("Rule selection: current is the marked row (Decision 1)", .serialized)+struct RuleSelectionTests {++ private static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++ /// A stand-in for a rule row. `RuleSelection.current` is generic over+ /// `MarkableRule` and reads nothing else, so the pick can be stated without+ /// a model, a definition blob or a container.+ private struct Row: MarkableRule {+ let id: UUID+ let createdAt: Date+ let isMarked: Bool+ }++ private static func row(+ _ id: String, marked: Bool, at offset: TimeInterval+ ) -> Row {+ Row(+ id: UUID(uuidString: id)!, createdAt: epoch.addingTimeInterval(offset),+ isMarked: marked)+ }++ // Two ids whose string order is the opposite of the order they are written+ // in below, so a pick that returned "the first one it saw" is visible.+ private static let lowID = "00000000-0000-0000-0000-0000000000AA"+ private static let highID = "FFFFFFFF-0000-0000-0000-0000000000FF"++ // MARK: - Req 4.1 — the pick++ @Test("No marked row means no current rule")+ func noMarkedRow() {+ let rows = [+ Self.row(Self.highID, marked: false, at: 100),+ Self.row(Self.lowID, marked: false, at: 200),+ ]+ #expect(RuleSelection.current(rows) == nil)+ #expect(RuleSelection.current([Row]()) == nil)+ }++ @Test("One marked row is the current rule whatever its age")+ func oneMarkedRow() {+ let marked = Self.row(Self.lowID, marked: true, at: 0)+ let rows = [Self.row(Self.highID, marked: false, at: 500), marked]+ #expect(RuleSelection.current(rows)?.id == marked.id)+ }++ @Test("Two marked rows: the newest createdAt wins")+ func newestMarkedRowWins() {+ let older = Self.row(Self.lowID, marked: true, at: 0)+ let newer = Self.row(Self.highID, marked: true, at: 10)+ #expect(RuleSelection.current([older, newer])?.id == newer.id)+ // Order of the input must not decide it.+ #expect(RuleSelection.current([newer, older])?.id == newer.id)+ }++ @Test("Two marked rows of the same age: the lowest uuidString wins")+ func equalCreatedAtBreaksOnUUID() {+ let low = Self.row(Self.lowID, marked: true, at: 42)+ let high = Self.row(Self.highID, marked: true, at: 42)+ #expect(RuleSelection.current([high, low])?.id == low.id)+ #expect(RuleSelection.current([low, high])?.id == low.id)+ }++ /// The flag is the filter, `createdAt` only the tiebreak among rows that+ /// pass it. A newer *unmarked* row is history, not the current rule.+ @Test("A newer unmarked row never wins")+ func unmarkedNewerRowNeverWins() {+ let marked = Self.row(Self.lowID, marked: true, at: 0)+ let unmarked = Self.row(Self.highID, marked: false, at: 1_000)+ #expect(RuleSelection.current([marked, unmarked])?.id == marked.id)+ }++ // MARK: - Req 4.2 — the two Site accessors++ /// A container rather than a hand-built graph: a to-many relationship comes+ /// back in whatever order the store hands it over, which is the thing the+ /// accessors have to be independent of. An array assigned in the test would+ /// prove only that the test's own order was returned.+ ///+ /// The repeated reads below each go through a **fresh** `ModelContext` over+ /// the same container, so every one of them faults the relationship out of+ /// the store again. Re-reading the context the rows were inserted into+ /// hands back the same registered objects in the same order every time,+ /// which is a property of that context, not of the accessor.+ private func container() throws -> ModelContainer {+ let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ return try ModelContainer(+ for: schema,+ configurations: [ModelConfiguration(+ schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)])+ }++ @Test("activePattern returns the newest marked pattern, the same row on every read")+ func activePatternPicksTheNewestMarkedRow() throws {+ let container = try container()+ let context = ModelContext(container)+ let site = Site(hostname: "two-marked.example")+ site.mode = .taught+ context.insert(site)++ // Versions run the other way from `createdAt`, so a pick that still+ // ordered by version would name the older row.+ let older = try TitlePattern(+ version: 9, isActive: true, createdAt: Self.epoch,+ definition: .wholeTitle, site: site)+ let newer = try TitlePattern(+ version: 1, isActive: true, createdAt: Self.epoch.addingTimeInterval(60),+ definition: .wholeTitle, site: site)+ context.insert(older)+ context.insert(newer)+ try context.save()++ let newerID = newer.id+ var picks: [UUID?] = []+ for _ in 0..<5 {+ let fresh = ModelContext(container)+ let stored = try #require(try fresh.fetch(FetchDescriptor<Site>()).first)+ picks.append(stored.activePattern?.id)+ }+ #expect(+ picks.allSatisfy { $0 == newerID },+ "expected \(newerID) on every read, got \(picks)")+ }++ @Test("currentURLRule returns the newest marked rule, the same row on every read")+ func currentURLRulePicksTheNewestMarkedRow() throws {+ let container = try container()+ let context = ModelContext(container)+ let site = Site(hostname: "two-current.example")+ site.mode = .taught+ context.insert(site)++ let older = try URLRulePattern(+ version: 9, isCurrent: true, createdAt: Self.epoch, origin: .readerTaught,+ definition: .work(locator: .query(name: ExactScalarString("series"))), site: site)+ let newer = try URLRulePattern(+ version: 1, isCurrent: true, createdAt: Self.epoch.addingTimeInterval(60),+ origin: .readerTaught,+ definition: .work(locator: .query(name: ExactScalarString("story"))), site: site)+ context.insert(older)+ context.insert(newer)+ try context.save()++ let newerID = newer.id+ var picks: [UUID?] = []+ for _ in 0..<5 {+ let fresh = ModelContext(container)+ let stored = try #require(try fresh.fetch(FetchDescriptor<Site>()).first)+ picks.append(stored.currentURLRule?.id)+ }+ #expect(+ picks.allSatisfy { $0 == newerID },+ "expected \(newerID) on every read, got \(picks)")+ }++ @Test("A Site with no marked row has no current rule of either kind")+ func noMarkedRowOnTheSite() throws {+ let container = try container()+ let context = ModelContext(container)+ let site = Site(hostname: "retired.example")+ context.insert(site)+ context.insert(+ try TitlePattern(+ version: 3, isActive: false, createdAt: Self.epoch,+ definition: .wholeTitle, site: site))+ context.insert(+ try URLRulePattern(+ version: 3, isCurrent: false, createdAt: Self.epoch, origin: .readerTaught,+ definition: .work(locator: .query(name: ExactScalarString("series"))), site: site))+ try context.save()++ let stored = try #require(try context.fetch(FetchDescriptor<Site>()).first)+ #expect(stored.activePattern == nil)+ #expect(stored.currentURLRule == nil)+ }++ // MARK: - Req 4.2 — one place per rule kind, checked by reading the source++ /// The expression that picks a rule out of a Site's rows: a `first` — taking+ /// a `where:` argument or a trailing closure — whose predicate reads+ /// `isActive` or `isCurrent`, in any spelling. Only `RuleSelection` may+ /// contain one; every other reader goes through the accessors (Q11).+ ///+ /// Matched against the file's whole text rather than line by line, so a call+ /// the formatter wrapped is still visible to it, and with a nested call in+ /// the predicate rather than only a bare flag read. The window between the+ /// `first` and the flag is bounded so the scan cannot pair a `first` with an+ /// `isActive` several statements further down.+ ///+ /// Counting/demoting sites are deliberately outside the pattern — they read+ /// the flag over the whole collection (`filter`, `count(where:)`,+ /// `contains(where:)`) rather than selecting one row, and Q12 keeps them+ /// exactly as they are.+ ///+ /// Computed rather than stored: `Regex` is not `Sendable`, so a static+ /// constant would not compile under strict concurrency.+ private static var selectingRead: Regex<Substring> {+ /first\s*(?:\(\s*where:|\{)[\s\S]{0,120}?is(?:Active|Current)/+ }++ private static let repositoryRoot: URL = URL(filePath: #filePath)+ .deletingLastPathComponent() // AsterismCoreTests+ .deletingLastPathComponent() // Tests+ .deletingLastPathComponent() // AsterismCore+ .deletingLastPathComponent() // Packages+ .deletingLastPathComponent() // the repository++ private static let productionRoots = [+ "Packages/AsterismCore/Sources", // AsterismCore and AsterismIntelligence+ "Asterism/Asterism",+ "Asterism/AsterismShareExtension",+ ]++ private func swiftFiles(under relativePath: String) throws -> [URL] {+ let root = Self.repositoryRoot.appending(path: relativePath)+ var found: [URL] = []+ let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)+ while let url = enumerator?.nextObject() as? URL {+ if url.pathExtension == "swift" { found.append(url) }+ }+ return found.sorted { $0.path < $1.path }+ }++ /// One production site that selects a rule by reading a marked flag: where+ /// it is, as a repository-relative `path:line`, and the text of the line the+ /// match starts on — which is what the one tolerated site is keyed on.+ private struct SelectingRead {+ let location: String+ let line: String+ }++ /// Every selecting read outside `RuleSelection.swift`.+ private func selectingReads() throws -> [SelectingRead] {+ var found: [SelectingRead] = []+ for file in try Self.productionRoots.flatMap({ try swiftFiles(under: $0) })+ where file.lastPathComponent != "RuleSelection.swift" {+ let text = try String(contentsOf: file, encoding: .utf8)+ let relativePath = file.path.replacingOccurrences(+ of: Self.repositoryRoot.path + "/", with: "")+ for match in text.matches(of: Self.selectingRead) {+ let before = text[text.startIndex..<match.range.lowerBound]+ let lineNumber = before.lazy.filter { $0 == "\n" }.count + 1+ let lineStart = before.lastIndex(of: "\n").map(text.index(after:))+ ?? text.startIndex+ let lineEnd = text[lineStart...].firstIndex(of: "\n") ?? text.endIndex+ found.append(+ SelectingRead(+ location: "\(relativePath):\(lineNumber)",+ line: text[lineStart..<lineEnd]+ .trimmingCharacters(in: .whitespaces)))+ }+ }+ return found+ }++ @Test("Only RuleSelection picks a rule by reading a marked flag (Req 4.2, Q11)")+ func onlyRuleSelectionSelectsByFlag() throws {+ // Anti-vacuity: the spellings a selecting read is written in — including+ // the ones a line-by-line scan of `first(where:[^)]*)` could not see, a+ // call wrapped over several lines, a trailing closure, and a predicate+ // with a nested call in it.+ let mustMatch = [+ "site.patternValues.first(where: \\.isActive)",+ "site.urlRuleValues.first(where: { $0.isCurrent })",+ "evidence.rules.first(where: { rule in rule.isCurrent })",+ "site.patternValues.first { $0.isActive }",+ "site.urlRuleValues.first(where: { r in eligible(r) && r.isCurrent })",+ """+ let active = site.patternValues.first(+ where: { pattern in+ pattern.isActive+ })+ """,+ ]+ for spelling in mustMatch {+ #expect(+ spelling.firstMatch(of: Self.selectingRead) != nil,+ "the scan no longer recognises a selecting read written as \(spelling)")+ }+ // The counting forms that must stay outside the pattern (Q12).+ let mustNotMatch = [+ "let active = patterns.filter { $0.isActive }",+ "let marked = site.patternValues.count(where: { $0.isActive })",+ "guard site.urlRuleValues.contains(where: \\.isCurrent) else { return }",+ ]+ for spelling in mustNotMatch {+ #expect(+ spelling.firstMatch(of: Self.selectingRead) == nil,+ "the scan must not claim a counting read selects a rule (Q12): \(spelling)")+ }++ // A root that has moved enumerates as empty and takes the scan quietly+ // vacuous with it, so every one of them has to hand back files.+ for root in Self.productionRoots {+ #expect(+ try !swiftFiles(under: root).isEmpty,+ "scan root \(root) holds no Swift files — has it been renamed?")+ }++ let offenders = try selectingReads()++ // No carve-out. `DuplicateReconciler.urlRuleVersionIsFree` was the one+ // tolerated site — it asked whether a version integer was free rather+ // than which rule was current — and task 6.2 deleted it with the rest of+ // the version machinery, taking its `withKnownIssue` block with it (Q29).+ #expect(+ offenders.isEmpty,+ """+ these production sites pick a rule by reading a marked flag: \+ \(offenders.map(\.location)). Req 4.2 routes every one of them through \+ `Site.activePattern` / `Site.currentURLRule`, so the Decision 1 tiebreak \+ lives in one place per rule kind.+ """)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swiftindex a137626..a186571 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift@@ -36,24 +36,28 @@ struct RuleSuggestionCandidatesTests { context.insert(entry) } + @discardableResult private func insertTitlePattern( _ context: ModelContext, site: Site, version: Int, isActive: Bool- ) throws {+ ) throws -> UUID { let pattern = try TitlePattern( version: version, isActive: isActive, createdAt: Date(timeIntervalSince1970: 1_000), definition: .wholeTitle, site: site) context.insert(pattern)+ return pattern.id } + @discardableResult private func insertURLRule( _ context: ModelContext, site: Site, version: Int, isCurrent: Bool- ) throws {+ ) throws -> UUID { let rule = try URLRulePattern( version: version, isCurrent: isCurrent, createdAt: Date(timeIntervalSince1970: 1_000), origin: .readerTaught, definition: .sequence(locator: .query(name: ExactScalarString("chapter"))), site: site) context.insert(rule)+ return rule.id } private func candidate(@@ -126,20 +130,25 @@ struct RuleSuggestionCandidatesTests { #expect(try candidate(rows, "articles.example").siteMode == .articles) } - @Test("Rule versions come from the active title pattern and the current URL rule")- func ruleVersionsComeFromTheLiveRules() async throws {+ @Test("Rule ids come from the active title pattern and the current URL rule")+ func ruleIDsComeFromTheLiveRules() async throws { let fixture = try ComposedRepoFixture()+ var activePatternID = UUID()+ var currentRuleID = UUID()+ var titleOnlyPatternID = UUID() try fixture.seed { context in let taught = insertSite(context, hostname: "taught.example", mode: .taught) // Superseded records must not be read: only the active/current ones. try insertTitlePattern(context, site: taught, version: 1, isActive: false)- try insertTitlePattern(context, site: taught, version: 4, isActive: true)+ activePatternID = try insertTitlePattern(+ context, site: taught, version: 4, isActive: true) try insertURLRule(context, site: taught, version: 1, isCurrent: false)- try insertURLRule(context, site: taught, version: 2, isCurrent: true)+ currentRuleID = try insertURLRule(context, site: taught, version: 2, isCurrent: true) // A title rule and no URL rule at all. let titleOnly = insertSite(context, hostname: "title.example", mode: .taught)- try insertTitlePattern(context, site: titleOnly, version: 7, isActive: true)+ titleOnlyPatternID = try insertTitlePattern(+ context, site: titleOnly, version: 7, isActive: true) insertSite(context, hostname: "untaught.example", mode: .untaught) }@@ -147,16 +156,16 @@ struct RuleSuggestionCandidatesTests { let rows = try await fixture.repository.ruleSuggestionCandidates(hostnames: nil) let taught = try candidate(rows, "taught.example")- #expect(taught.titleRuleVersion == 4)- #expect(taught.urlRuleVersion == 2)+ #expect(taught.titleRuleID == activePatternID)+ #expect(taught.urlRuleID == currentRuleID) let titleOnly = try candidate(rows, "title.example")- #expect(titleOnly.titleRuleVersion == 7)- #expect(titleOnly.urlRuleVersion == nil)+ #expect(titleOnly.titleRuleID == titleOnlyPatternID)+ #expect(titleOnly.urlRuleID == nil) let untaught = try candidate(rows, "untaught.example")- #expect(untaught.titleRuleVersion == nil)- #expect(untaught.urlRuleVersion == nil)+ #expect(untaught.titleRuleID == nil)+ #expect(untaught.urlRuleID == nil) } @Test("Entry count and latest capture describe the hostname's captures")@@ -236,12 +245,14 @@ struct RuleSuggestionCandidatesTests { @Test("Two Site rows for one hostname yield the resolution winner, once") func duplicateSiteRowsResolveToTheWinner() async throws { let fixture = try ComposedRepoFixture()+ var winningPatternID = UUID() try fixture.seed { context in // Step 1 of `SiteResolutionOrder`: the row with an active title // pattern wins over the untaught duplicate. insertSite(context, hostname: "dup.example", mode: .untaught) let taught = insertSite(context, hostname: "dup.example", mode: .taught)- try insertTitlePattern(context, site: taught, version: 5, isActive: true)+ winningPatternID = try insertTitlePattern(+ context, site: taught, version: 5, isActive: true) insertEntry( context, hostname: "dup.example", capturedAt: Date(timeIntervalSince1970: 1_800_000_100), suffix: "1")@@ -252,7 +263,7 @@ struct RuleSuggestionCandidatesTests { #expect(rows.count == 1) let row = try candidate(rows, "dup.example") #expect(row.siteMode == .taught)- #expect(row.titleRuleVersion == 5)+ #expect(row.titleRuleID == winningPatternID) // The count is per hostname, so a duplicate row does not double it. #expect(row.entryCount == 1) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swiftindex e9ef7a3..09b42d0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift@@ -58,11 +58,11 @@ struct SiteReconcilerTests { // the reconciler would manufacture the quarantine it exists to prevent. #expect(loser.mode == .untaught) #expect(survivor.mode == .taught)- // Decision 7: Site-unique versions, current URL rule greatest.- #expect(survivor.patternValues.map(\.version).sorted() == [1, 2, 3])- #expect(survivor.urlRuleValues.map(\.version).sorted() == [1, 2])- let currentRules = survivor.urlRuleValues.filter(\.isCurrent)- #expect(currentRules.first?.version == survivor.urlRuleValues.map(\.version).max())+ // Req 3.4: every re-parented row keeps the version it was minted at.+ // Decision 7 renumbered them to `[1, 2, 3]` / `[1, 2]` so the retired+ // Site-unique and greatest-version invariants would hold.+ #expect(survivor.patternValues.map(\.version).sorted() == [1, 1, 2])+ #expect(survivor.urlRuleValues.map(\.version).sorted() == [1, 1]) // T-2295: a pass that moved every rule onto the survivor settles the // hostname. The stripped row is still there, and nothing is diagnosed — // this used to assert the opposite, which is the banner that never@@ -125,10 +125,9 @@ struct SiteReconcilerTests { let store = try ReconcilerStore() let winner = store.addSite(displayName: "winner", mode: .taught) let loser = store.addSite(displayName: "loser", mode: .taught)- // The survivor's active rule is ordered *last* by the renumbering so the- // greatest-version invariant holds, so the very rule an Entry on the- // winning row already cites moves from v1 to v3. Without the rewrite,- // reconciliation would break provenance replay on the row it kept.+ // The merge re-parents the losers' rules onto the winner and demotes all+ // but one marked row. Nothing renumbers and nothing rewrites a citation+ // (Req 3.4): the Entry cites a UUID, and the row keeps it. let cited = try store.addPattern(to: winner, version: 1, active: true, rank: 1) try store.addPattern(to: loser, version: 1, active: true, rank: 5) try store.addPattern(to: loser, version: 2, active: false, rank: 6)@@ -136,19 +135,21 @@ struct SiteReconcilerTests { entry.chapterTitle = "Chapter 1" entry.editCitations { $0.chapterTitle = FieldProvenance.tolerant(- kind: .pattern, patternID: cited.id, patternVersion: 1)+ kind: .pattern, patternID: cited.id) } try store.commit()+ // The blob as it stands before the pass. A citation is the rule's UUID+ // (T-2281), so the merge has nothing to rewrite — the bytes themselves+ // must come through untouched, not merely still name the same rule.+ let citationsBefore = try #require(entry.citationsData) try store.reconcile() + #expect(entry.citationsData == citationsBefore) let chapter = entry.citationValues.chapterTitle- #expect(chapter.patternVersion == cited.version)- #expect(chapter.patternVersion != 1)+ #expect(chapter.patternID == cited.id) let owned = try #require(entry.site).patternValues- let resolved = owned.filter {- $0.id == chapter.patternID && $0.version == chapter.patternVersion- }+ let resolved = owned.filter { $0.id == chapter.patternID } #expect(resolved.count == 1) // And the whole library still validates — an unresolved citation on a // pattern-provenance chapter is exactly what `.siteTuple` reports.@@ -246,28 +247,56 @@ struct SiteReconcilerTests { #expect(store.sites(hostname: "arriving.example").isEmpty) } - // MARK: - Decision 7: the same-row collision+ // MARK: - Req 3.7: the double-marked row, repaired in place - @Test("Two versions minted concurrently on one row are repaired, not quarantined")- func sameRowCollisionIsRepaired() throws {+ /// Decision 7's version collision is not damage any more, but the other half+ /// of what two concurrent teaches leave is: **two marked rows**. The pass+ /// still names the hostname and repairs it in place, keeping the row+ /// Decision 1 calls current and leaving both versions as found.+ @Test("Two marked rows on one Site are repaired in place, versions untouched")+ func doubleMarkedRowIsRepairedInPlace() throws { let store = try ReconcilerStore() let site = store.addSite(displayName: "only", mode: .taught) try store.addPattern(to: site, version: 1, active: false, rank: 1)- try store.addPattern(to: site, version: 2, active: true, rank: 2)- try store.addPattern(to: site, version: 2, active: true, rank: 3)+ try store.addPattern(to: site, version: 2, active: true, rank: 2, offset: 10)+ let newest = try store.addPattern(to: site, version: 2, active: true, rank: 3, offset: 20) try store.commit()- // A version collision is the reconciler's to repair, so it arrives as a- // tuple diagnosis rather than as a duplicate-row one.+ // Two active title rules make the Site's own tuple illegal, so it+ // arrives as a tuple diagnosis rather than as a duplicate-row one. #expect(try store.diagnose().tupleDiagnoses[ReconcilerStore.hostname] != nil) let outcome = try store.reconcile(colliding: [ReconcilerStore.hostname]) #expect(outcome.consolidatedHostnames == [ReconcilerStore.hostname])- #expect(site.patternValues.map(\.version).sorted() == [1, 2, 3])+ #expect(site.patternValues.map(\.version).sorted() == [1, 2, 2]) #expect(site.patternValues.count(where: \.isActive) == 1)+ #expect(site.patternValues.first(where: \.isActive) === newest) #expect(try store.diagnose().tupleDiagnoses.isEmpty) } + /// The mirror: a hostname whose rules merely *share a version* is not a+ /// hostname to repair (Req 3.6). Nothing here names it, and a pass told to+ /// look at it anyway writes nothing.+ @Test("A single row whose rules share a version is left entirely alone")+ func duplicateVersionsAreNotRepairWork() throws {+ let store = try ReconcilerStore()+ let site = store.addSite(displayName: "only", mode: .taught)+ try store.addPattern(to: site, version: 1, active: true, rank: 1)+ try store.addPattern(to: site, version: 1, active: false, rank: 2)+ try store.addRule(to: site, version: 4, current: true, rank: 1)+ try store.addRule(to: site, version: 9, current: false, rank: 2)+ try store.commit()+ #expect(try store.diagnose().tupleDiagnoses.isEmpty)+ store.saveRecorder.resetCounts()++ let outcome = try store.reconcile(colliding: [ReconcilerStore.hostname])++ #expect(outcome.isEmpty)+ #expect(store.saveRecorder.attemptCount == 0)+ #expect(site.patternValues.map(\.version).sorted() == [1, 1])+ #expect(site.urlRuleValues.map(\.version).sorted() == [4, 9])+ }+ // MARK: - Idempotence (Req 2.4: nothing here reads a clock) @Test(@@ -277,8 +306,13 @@ struct SiteReconcilerTests { let store = try ReconcilerStore() try store.seed(SiteUnionRowSetGenerator.rowSet(seed: seed)) try store.commit()+ let seededVersions = try store.allRuleVersions() _ = try store.reconcile(colliding: [ReconcilerStore.hostname])++ // Req 3.4: a pass re-parents and demotes, and renumbers nothing — over+ // every generated row set, collisions included.+ #expect(try store.allRuleVersions() == seededVersions, "a pass renumbered a rule row") store.saveRecorder.resetCounts() let second = try store.reconcile(colliding: [ReconcilerStore.hostname]) @@ -351,14 +385,15 @@ struct SiteReconcilerTests { #expect(settled.isEmpty, "a further pass did not converge") #expect(try deviceB.diagnose().tupleDiagnoses.isEmpty) - // Both devices land on the same shape: one active title rule, one current- // URL rule, contiguous Site-unique versions.+ // Both devices land on the same shape: one active title rule, one+ // current URL rule, and the versions each row arrived with — the+ // renumbering that made them contiguous is gone (Req 3.4). for store in [deviceA, deviceB] { let survivor = try #require(store.sites().first) #expect(survivor.patternValues.count(where: \.isActive) == 1) #expect(survivor.urlRuleValues.count(where: \.isCurrent) == 1)- #expect(survivor.patternValues.map(\.version).sorted() == [1, 2])- #expect(survivor.urlRuleValues.map(\.version).sorted() == [1, 2])+ #expect(survivor.patternValues.map(\.version).sorted() == [1, 1])+ #expect(survivor.urlRuleValues.map(\.version).sorted() == [1, 1]) } } }@@ -442,11 +477,15 @@ private final class ReconcilerStore { } /// `rank` orders the rule's UUID, so which row wins step 3 or 4 of- /// `SiteResolutionOrder` is stated rather than drawn.+ /// `SiteResolutionOrder` is stated rather than drawn. `offset` dates the row+ /// past the epoch, for the cases that turn on Decision 1's recency tiebreak. @discardableResult- func addPattern(to site: Site, version: Int, active: Bool, rank: Int) throws -> TitlePattern {+ func addPattern(+ to site: Site, version: Int, active: Bool, rank: Int, offset: TimeInterval = 0+ ) throws -> TitlePattern { let pattern = try TitlePattern(- id: Self.rankedID(rank), version: version, isActive: active, createdAt: Self.epoch,+ id: Self.rankedID(rank), version: version, isActive: active,+ createdAt: Self.epoch.addingTimeInterval(offset), definition: .segment( work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1), ignored: []), site: site)@@ -455,9 +494,12 @@ private final class ReconcilerStore { } @discardableResult- func addRule(to site: Site, version: Int, current: Bool, rank: Int) throws -> URLRulePattern {+ func addRule(+ to site: Site, version: Int, current: Bool, rank: Int, offset: TimeInterval = 0+ ) throws -> URLRulePattern { let rule = try URLRulePattern(- id: Self.rankedID(rank), version: version, isCurrent: current, createdAt: Self.epoch,+ id: Self.rankedID(rank), version: version, isCurrent: current,+ createdAt: Self.epoch.addingTimeInterval(offset), origin: .readerTaught, definition: .work(locator: .query(name: ExactScalarString("identity"))), site: site)@@ -567,6 +609,19 @@ private final class ReconcilerStore { try context.fetch(FetchDescriptor<Entry>()).sorted { $0.rawURLString < $1.rawURLString } } + /// Every rule row's `(id, version)`, for the pass that must leave them all+ /// alone (Req 3.4).+ func allRuleVersions() throws -> [String: Int] {+ var versions: [String: Int] = [:]+ for pattern in try context.fetch(FetchDescriptor<TitlePattern>()) {+ versions["title-\(pattern.persistentModelID)"] = pattern.version+ }+ for rule in try context.fetch(FetchDescriptor<URLRulePattern>()) {+ versions["url-\(rule.persistentModelID)"] = rule.version+ }+ return versions+ }+ /// A UUID whose string order follows `rank`, so the identity-derived steps of /// `SiteResolutionOrder` are deterministic in the fixture. private static func rankedID(_ rank: Int) -> UUID {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swiftindex da30b4e..7ed4248 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift@@ -10,11 +10,11 @@ import Testing /// /// The property-based cases are the load-bearing ones. Req 1.5 says two devices /// reconciling the same rows select the same survivor and depend only on synced-/// content, and Decision 7 says the renumbering both compute is the same — both-/// are statements about *every* row set, not about the handful anyone thinks to-/// name. So the generated sets are projected twice from shuffled inputs and the-/// two answers compared, and the invariants the validator will later enforce are-/// asserted over each.+/// content — a statement about *every* row set, not about the handful anyone+/// thinks to name. So the generated sets are projected twice from shuffled+/// inputs and the two answers compared, and the invariants the validator will+/// later enforce are asserted over each. (Decision 7's renumbering was the other+/// property here; T-2281 deleted it — see `theUnionKeepsEveryRuleAndMarksOne`.) @Suite("Site union projection", .serialized) struct SiteUnionProjectionTests { @@ -37,7 +37,6 @@ struct SiteUnionProjectionTests { #expect(first.survivor === second.survivor) #expect(first.mode == second.mode) #expect(first.consolidates == second.consolidates)- #expect(first.versionRewrites == second.versionRewrites) #expect( first.patterns.map(\.pattern.id).sorted(by: uuidOrder) == second.patterns.map(\.pattern.id).sorted(by: uuidOrder))@@ -45,67 +44,36 @@ struct SiteUnionProjectionTests { #expect(currentID(first) == currentID(second)) } - // MARK: - Property: the union satisfies the tuple invariants (Decision 7)+ // MARK: - Property: the union keeps every rule, its version, and one mark + /// Decision 7's renumbering is **gone** (T-2281 Req 3.4, 3.5), and with it+ /// the whole notion of a projected version: `ProjectedTitlePattern` and+ /// `ProjectedURLRule` carry the row and a flag, so "the union leaves the+ /// version as found" is a fact about the type rather than an assertion. What+ /// the union still owes is that it keeps every rule row both Site rows held+ /// and marks at most one of each kind. There is no rewrite map and no+ /// citation to move: a citation names a UUID, which a merge does not change. @Test(- "Renumbered versions are Site-unique and the kept current rule holds the greatest",+ "The union keeps every rule and marks at most one of each kind", arguments: SiteUnionRowSetGenerator.seeds)- func renumberingSatisfiesTheTupleInvariants(seed: UInt64) throws {+ func theUnionKeepsEveryRuleAndMarksOne(seed: UInt64) throws { let store = try ProjectionStore() let rows = try store.makeRows(SiteUnionRowSetGenerator.rowSet(seed: seed))+ let seededPatternVersions = rows.flatMap(\.patternValues).map(\.version).sorted()+ let seededRuleVersions = rows.flatMap(\.urlRuleValues).map(\.version).sorted() let projected = SiteUnionProjection.project(hostname: ProjectionStore.hostname, rows: rows) - let patternVersions = projected.patterns.map(\.version)- #expect(Set(patternVersions).count == patternVersions.count, "title versions collide")- #expect(patternVersions.allSatisfy { $0 > 0 }, "title versions must be positive")-- let ruleVersions = projected.urlRules.map(\.version)- #expect(Set(ruleVersions).count == ruleVersions.count, "URL rule versions collide")- #expect(ruleVersions.allSatisfy { $0 > 0 }, "URL rule versions must be positive")+ #expect(projected.patterns.map(\.pattern.version).sorted() == seededPatternVersions)+ #expect(projected.urlRules.map(\.rule.version).sorted() == seededRuleVersions) #expect(projected.patterns.count(where: \.isActive) <= 1) #expect(projected.urlRules.count(where: \.isCurrent) <= 1)- if let current = projected.urlRules.first(where: \.isCurrent) {- #expect(- current.version == ruleVersions.max(),- "the current URL rule must hold the greatest retained version")- } // The union keeps every rule both rows held — additive-only means no // teaching is dropped on the way (Decision 6). #expect(projected.patterns.count == rows.flatMap(\.patternValues).count) #expect(projected.urlRules.count == rows.flatMap(\.urlRuleValues).count) } - // MARK: - Property: every rewritten citation resolves (Req 1.4)-- @Test(- "Every rule the union holds is in the rewrite map at the version it lands on",- arguments: SiteUnionRowSetGenerator.seeds)- func rewrittenCitationsResolve(seed: UInt64) throws {- let store = try ProjectionStore()- let rows = try store.makeRows(SiteUnionRowSetGenerator.rowSet(seed: seed))- let projected = SiteUnionProjection.project(hostname: ProjectionStore.hostname, rows: rows)-- // A citing record holds `(id, version)`. Rewriting is `version =- // rewrites[id]`, so the map has to name every rule and name the version- // the rule actually lands on — otherwise provenance replay resolves- // before the merge and fails after it.- for entry in projected.patterns {- #expect(projected.versionRewrites[entry.pattern.id] == entry.version)- let resolved = projected.patterns.first {- $0.pattern.id == entry.pattern.id && $0.version == entry.version- }- #expect(resolved != nil, "rewritten title citation does not resolve")- }- for rule in projected.urlRules {- #expect(projected.versionRewrites[rule.rule.id] == rule.version)- let resolved = projected.urlRules.first {- $0.rule.id == rule.rule.id && $0.version == rule.version- }- #expect(resolved != nil, "rewritten URL citation does not resolve")- }- }- // MARK: - Decision 5: untaught twins coexist @Test("A row set in which no row owns any rule is left alone")@@ -170,76 +138,106 @@ struct SiteUnionProjectionTests { // MARK: - The ordinary library is not disturbed - @Test("A single legal row keeps its versions and reports no work")- func singleLegalRowIsUntouched() throws {+ /// A single row holding one marked rule of each kind: nothing to repair, and+ /// every version left as found — including the shapes the retired invariants+ /// refused (a duplicate title version, a current URL rule below a retained+ /// one).+ @Test("A single row with one marked rule of each kind keeps its versions and reports no work")+ func singleMarkedRowIsUntouched() throws { let store = try ProjectionStore()- // Versions are legal without being contiguous, and the active title rule- // need not hold the greatest — only the current URL rule must. let rows = try store.makeRows([ SiteUnionRowSpec(- patterns: [(version: 2, active: true), (version: 5, active: false),+ patterns: [(version: 2, active: true), (version: 2, active: false), (version: 7, active: false)],- rules: [(version: 3, current: false), (version: 9, current: true)],+ rules: [(version: 9, current: false), (version: 3, current: true)], mode: .taught) ]) let projected = SiteUnionProjection.project(hostname: ProjectionStore.hostname, rows: rows) #expect(projected.consolidates == false)- #expect(projected.patterns.map(\.version).sorted() == [2, 5, 7])- #expect(projected.urlRules.map(\.version).sorted() == [3, 9])- #expect(projected.patterns.first { $0.isActive }?.version == 2)+ #expect(projected.patterns.map(\.pattern.version).sorted() == [2, 2, 7])+ #expect(projected.urlRules.map(\.rule.version).sorted() == [3, 9])+ #expect(projected.patterns.first { $0.isActive }?.pattern.version == 2)+ #expect(projected.urlRules.first { $0.isCurrent }?.rule.version == 3) } - // MARK: - Decision 7: the same-row collision+ // MARK: - Req 3.7: the double-marked row is the repair that survives - @Test("Two versions minted concurrently on one row are renumbered, not quarantined")- func sameRowVersionCollisionIsRepaired() throws {+ /// The version collision Decision 7 renumbered is no longer damage. What two+ /// concurrent teaches still leave is **two marked rows**, and the union+ /// keeps the one Decision 1 calls current — the newest `createdAt` — and+ /// demotes the other, without touching either version.+ @Test("Two marked rows on one Site are repaired by demotion, keeping the newest")+ func doubleMarkedRowIsRepairedByDemotion() throws { let store = try ProjectionStore()- // Two devices teaching one row both mint v(max+1): the pair collides,- // and both rules claim to be active.- let rows = try store.makeRows([- SiteUnionRowSpec(- patterns: [(version: 1, active: false), (version: 2, active: true),- (version: 2, active: true)],+ let rows = try store.makeDatedRows([+ DatedRowSpec(+ patterns: [+ DatedRuleSpec(version: 1, marked: false, offset: 0),+ DatedRuleSpec(version: 2, marked: true, offset: 10),+ DatedRuleSpec(version: 2, marked: true, offset: 20),+ ], mode: .taught) ]) let projected = SiteUnionProjection.project(hostname: ProjectionStore.hostname, rows: rows) #expect(projected.consolidates)- #expect(projected.patterns.map(\.version).sorted() == [1, 2, 3])+ #expect(projected.patterns.map(\.pattern.version).sorted() == [1, 2, 2], "a row was renumbered") #expect(projected.patterns.count(where: \.isActive) == 1)- #expect(projected.patterns.first { $0.isActive }?.version == 3)+ let kept = try #require(projected.patterns.first { $0.isActive })+ #expect(+ kept.pattern.createdAt == ProjectionStore.epoch.addingTimeInterval(20),+ "the union kept a marked row that is not the newest") } // MARK: - Req 1.3: the union keeps one active and one current - @Test("Two taught rows union to one active title rule and one current URL rule")+ /// Which marked row survives a two-row merge is Decision 1's question, not+ /// the survivor row's: the newest teach wins whichever Site row it sits on.+ /// The loser row's rule is dated last here, so a pick that still preferred+ /// the survivor's own row would be visible.+ @Test("Two taught rows union to one active title rule, kept by recency not by row") func unionDemotesTheLosers() throws { let store = try ProjectionStore()- let rows = try store.makeRows([- SiteUnionRowSpec(- patterns: [(version: 1, active: true)],- rules: [(version: 1, current: true)], mode: .taught),- SiteUnionRowSpec(- patterns: [(version: 1, active: true), (version: 2, active: false)],- rules: [(version: 1, current: true)], mode: .taught),+ // Step 3 of `SiteResolutionOrder` is the lowest owned `TitlePattern.id`,+ // so pinning the ids makes row 0 the survivor and row 1 the loser.+ let rows = try store.makeDatedRows([+ DatedRowSpec(+ patterns: [+ DatedRuleSpec(version: 1, marked: true, offset: 0, id: Self.rankedID(1))+ ],+ rules: [DatedRuleSpec(version: 1, marked: true, offset: 0, id: Self.rankedID(1))],+ mode: .taught),+ DatedRowSpec(+ patterns: [+ DatedRuleSpec(version: 1, marked: true, offset: 100, id: Self.rankedID(2)),+ DatedRuleSpec(version: 2, marked: false, offset: 50, id: Self.rankedID(3)),+ ],+ rules: [DatedRuleSpec(version: 1, marked: true, offset: 100, id: Self.rankedID(2))],+ mode: .taught), ]) let projected = SiteUnionProjection.project(hostname: ProjectionStore.hostname, rows: rows) + #expect(projected.survivor === rows[0]) #expect(projected.consolidates) #expect(projected.patterns.count == 3) #expect(projected.patterns.count(where: \.isActive) == 1) #expect(projected.urlRules.count == 2) #expect(projected.urlRules.count(where: \.isCurrent) == 1)- #expect(projected.patterns.map(\.version).sorted() == [1, 2, 3])- #expect(projected.urlRules.map(\.version).sorted() == [1, 2])- // The survivor's own active rule is the one that stays active; the other- // row's becomes inactive history (Req 1.3).- let survivor = try #require(projected.survivor)- #expect(projected.patterns.first(where: \.isActive)?.pattern.site === survivor)+ // Versions are left as found on every row (Req 3.4).+ #expect(projected.patterns.map(\.pattern.version).sorted() == [1, 1, 2])+ #expect(projected.urlRules.map(\.rule.version).sorted() == [1, 1])+ // The newest marked rule stays marked, and here it is the *loser* row's.+ let activePattern = projected.patterns.first(where: \.isActive)+ let kept = try #require(activePattern)+ #expect(kept.pattern.createdAt == ProjectionStore.epoch.addingTimeInterval(100))+ #expect(kept.pattern.site === rows[1])+ let currentRule = projected.urlRules.first(where: \.isCurrent)+ let keptRule = try #require(currentRule)+ #expect(keptRule.rule.createdAt == ProjectionStore.epoch.addingTimeInterval(100)) } // MARK: - Helpers@@ -255,6 +253,12 @@ struct SiteUnionProjectionTests { private func uuidOrder(_ lhs: UUID, _ rhs: UUID) -> Bool { lhs.uuidString < rhs.uuidString }++ /// A UUID whose ordinal is stated, for the cases that pin+ /// `SiteResolutionOrder`'s step 3.+ private static func rankedID(_ rank: Int) -> UUID {+ UUID(uuidString: String(format: "00000000-0000-4000-8000-%012d", rank))!+ } } // MARK: - Generated row sets@@ -265,6 +269,26 @@ struct SiteUnionRowSpec { var mode: SiteMode = .untaught } +/// The dated form, for the cases that turn on Decision 1's `createdAt`+/// tiebreak. `SiteUnionRowSpec` dates every rule at the epoch, which was fine+/// while the pick was by version and owning row and is exactly what the+/// tiebreak needs varied.+struct DatedRuleSpec {+ var version: Int+ var marked: Bool+ /// Seconds after `ProjectionStore.epoch`.+ var offset: TimeInterval+ /// Pinned where a test needs `SiteResolutionOrder`'s step 3 to be stated+ /// rather than drawn.+ var id: UUID = UUID()+}++struct DatedRowSpec {+ var patterns: [DatedRuleSpec] = []+ var rules: [DatedRuleSpec] = []+ var mode: SiteMode = .untaught+}+ /// Deterministic row-set generation. Random inputs with an unrecorded seed would /// make a failure unreproducible, which is worse than not generating at all. enum SiteUnionRowSetGenerator {@@ -362,6 +386,37 @@ private final class ProjectionStore { return rows } + func makeDatedRows(_ specs: [DatedRowSpec]) throws -> [Site] {+ var rows: [Site] = []+ for (index, spec) in specs.enumerated() {+ let site = Site(hostname: Self.hostname, displayName: "row-\(index)")+ site.mode = spec.mode+ context.insert(site)+ for pattern in spec.patterns {+ let created = try TitlePattern(+ id: pattern.id, version: pattern.version, isActive: pattern.marked,+ createdAt: Self.epoch.addingTimeInterval(pattern.offset),+ definition: .segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: []),+ site: site)+ context.insert(created)+ }+ for rule in spec.rules {+ let created = try URLRulePattern(+ id: rule.id, version: rule.version, isCurrent: rule.marked,+ createdAt: Self.epoch.addingTimeInterval(rule.offset),+ origin: .readerTaught,+ definition: .work(locator: .query(name: ExactScalarString("identity"))),+ site: site)+ context.insert(created)+ }+ rows.append(site)+ }+ try context.save()+ return rows+ }+ func allSites() throws -> [Site] { try context.fetch(FetchDescriptor<Site>()) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swiftindex 37dd26d..4ab92ea 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift@@ -569,7 +569,7 @@ struct ActionabilityTests { chapterTitle: nil, chapterProvenance: try FieldProvenance(kind: .none), workID: UUID(),- assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: UUID()), intentionallyUnattached: false ) #expect(isActionable == true)@@ -579,7 +579,7 @@ struct ActionabilityTests { func absentWorkActionable() throws { let isActionable = ActionabilityEvaluator.isActionable( chapterTitle: "Chapter",- chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID()), workID: nil, assignmentProvenance: try FieldProvenance(kind: .none), intentionallyUnattached: false@@ -604,7 +604,7 @@ struct ActionabilityTests { func intentionallyUnattachedSettled() throws { let isActionable = ActionabilityEvaluator.isActionable( chapterTitle: "Chapter",- chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID()), workID: nil, assignmentProvenance: try FieldProvenance(kind: .none), intentionallyUnattached: true@@ -616,9 +616,9 @@ struct ActionabilityTests { func fullySettled() throws { let isActionable = ActionabilityEvaluator.isActionable( chapterTitle: "Chapter",- chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID()), workID: UUID(),- assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: UUID()), intentionallyUnattached: false ) #expect(isActionable == false)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleProjectionPlannerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleProjectionPlannerTests.swiftindex ad349fe..be15aed 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleProjectionPlannerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleProjectionPlannerTests.swift@@ -240,9 +240,9 @@ struct TitleProjectionPlannerTests { let entry = makeEntry( captureTitle: "No Delimiters Here", chapterTitle: "Existing Chapter",- chapterProvenance: try FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1),+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: patternID), workID: existingWorkID,- assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1)+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: patternID) ) let plan = try TitleProjectionPlanner.planInitialTeaching(@@ -271,9 +271,9 @@ struct TitleProjectionPlannerTests { let entry = makeEntry( captureTitle: "No Delimiters Here", chapterTitle: "Old Chapter",- chapterProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1),+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID), workID: existingWorkID,- assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1)+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID) ) let plan = try TitleProjectionPlanner.planReplacement(@@ -511,8 +511,7 @@ struct TitleProjectionPlannerTests { chapterTitle: "Settled Chapter", chapterProvenance: try FieldProvenance( kind: .pattern,- patternID: settledPatternID,- patternVersion: 1+ patternID: settledPatternID ), firstCapturedAt: Date(timeIntervalSince1970: 1_000) )@@ -554,8 +553,7 @@ struct TitleProjectionPlannerTests { workID: settledWorkID, assignmentProvenance: try FieldProvenance( kind: .pattern,- patternID: settledPatternID,- patternVersion: 1+ patternID: settledPatternID ), firstCapturedAt: Date(timeIntervalSince1970: 1_000) )@@ -600,7 +598,7 @@ struct TitleProjectionPlannerTests { let entry = makeEntry( captureTitle: "Chapter 1 - Fiction Name | Royal Road", chapterTitle: "Old Chapter",- chapterProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1),+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID), workID: settledWorkID, assignmentProvenance: try FieldProvenance(kind: .manual), firstCapturedAt: Date(timeIntervalSince1970: 1_000)@@ -618,8 +616,7 @@ struct TitleProjectionPlannerTests { existingWorks: [], patternVersion: 2, boundaryEntryID: boundaryEntry.id,- historicalPatternID: oldPatternID,- historicalPatternVersion: 1+ historicalPatternID: oldPatternID ) // Find the projection for our entry.@@ -647,7 +644,7 @@ struct TitleProjectionPlannerTests { chapterTitle: "My Manual Chapter", chapterProvenance: try FieldProvenance(kind: .manual), workID: nil,- assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1),+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID), firstCapturedAt: Date(timeIntervalSince1970: 1_000) ) @@ -662,8 +659,7 @@ struct TitleProjectionPlannerTests { existingWorks: [], patternVersion: 2, boundaryEntryID: boundaryEntry.id,- historicalPatternID: oldPatternID,- historicalPatternVersion: 1+ historicalPatternID: oldPatternID ) let proj = plan.entryProjections.first(where: { $0.entryID == entry.id })!@@ -690,9 +686,9 @@ struct TitleProjectionPlannerTests { let entry = makeEntry( captureTitle: "Chapter 1 - Fiction Name | Royal Road", chapterTitle: "Old Chapter",- chapterProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1),+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID), workID: settledWorkID,- assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: differentPatternID, patternVersion: 1),+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: differentPatternID), firstCapturedAt: Date(timeIntervalSince1970: 1_000) ) @@ -707,8 +703,7 @@ struct TitleProjectionPlannerTests { existingWorks: [], patternVersion: 2, boundaryEntryID: boundaryEntry.id,- historicalPatternID: oldPatternID,- historicalPatternVersion: 1+ historicalPatternID: oldPatternID ) let proj = plan.entryProjections.first(where: { $0.entryID == entry.id })!@@ -762,9 +757,9 @@ struct TitleProjectionPlannerTests { let entry = makeEntry( captureTitle: "Chapter 1 - Fiction Name | Royal Road", chapterTitle: "Old Chapter",- chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID()), workID: existingWorkID,- assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1)+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: UUID()) ) let plan = try TitleProjectionPlanner.planInitialTeaching(
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleRuleFormsTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleRuleFormsTests.swiftindex 81370d7..971826d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleRuleFormsTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleRuleFormsTests.swift@@ -390,7 +390,7 @@ struct TitleProjectionPlannerNewFormsTests { func wholeTitleReplacementClearsChapter() throws { let entryID = UUID() let priorChapter = try FieldProvenance(- kind: .pattern, patternID: UUID(), patternVersion: 1)+ kind: .pattern, patternID: UUID()) let entry = makeEntry( id: entryID, captureTitle: "Solo Story Title", chapterTitle: "Old Chapter", chapterProvenance: priorChapter)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swiftindex 8505a39..2ecaf46 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swift@@ -164,7 +164,7 @@ struct URLIdentityPlannerTests { let outsideRule = try URLRuleBasisEntry( id: UUID(),- version: fixture.rule.version,+ createdAt: fixture.rule.createdAt, isCurrent: true, definition: fixture.rule.definition )@@ -182,9 +182,8 @@ struct URLIdentityPlannerTests { @Suite("Identity-first Work matching and prospective batching") struct IdentityFirstWorkMatchingTests { private var rule: URLRuleReference {- try! URLRuleReference(- id: UUID(uuidString: "00000000-0000-0000-0000-000000000901")!,- version: 1+ URLRuleReference(+ id: UUID(uuidString: "00000000-0000-0000-0000-000000000901")! ) } @@ -405,7 +404,7 @@ private struct EvidenceFixture { init() throws { rule = try URLRuleBasisEntry( id: UUID(uuidString: "00000000-0000-0000-0000-000000000201")!,- version: 3,+ createdAt: Date(timeIntervalSince1970: 1_800_000_000), isCurrent: true, definition: .workAndSequence( work: URLFieldSelector(locator: .query(name: ExactScalarString("series"))),
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swiftindex 29c178e..9221104 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swift@@ -270,9 +270,9 @@ struct URLOptionalSequenceDerivationTests { // Req 3.6: per-field provenance names the rule and version that derived // the identity, on both the extraction and the assignment it drove. let rule = try library.currentURLRule()- #expect(entry.citationValues.urlWork == CitedRule(id: rule.id, version: rule.version))+ #expect(entry.citationValues.urlWork == CitedRule(id: rule.id)) #expect(entry.workAssignmentProvenance == .urlRule)- #expect(entry.citationValues.workURL == CitedRule(id: rule.id, version: rule.version))+ #expect(entry.citationValues.workURL == CitedRule(id: rule.id)) #expect(entry.workURLAssignmentKind == .identity) } @@ -497,7 +497,7 @@ struct URLOptionalSequenceTeachingCommitTests { // Req 4.2's provenance half: the chapter-1 entry cites the rule. let rule = try library.currentURLRule()- #expect(after.citationValues.urlWork == CitedRule(id: rule.id, version: rule.version))+ #expect(after.citationValues.urlWork == CitedRule(id: rule.id)) // Req 6.1: the declaration is a property of the one current rule. It // mints the next version, and no second current rule appears.
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swiftindex 0ae9d3a..3559ca1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift@@ -890,7 +890,7 @@ struct URLOptionalSequenceReconcilerTests { @Suite("Optional chapter sequence — archive") struct URLOptionalSequenceArchiveTests { - private static func combinedRule(of payload: BackupV7Payload) throws -> URLTwoFieldTemplate? {+ private static func combinedRule(of payload: BackupV8Payload) throws -> URLTwoFieldTemplate? { guard case .combined(_, let template) = try #require(payload.urlRules.first).definition else { return nil }@@ -903,12 +903,12 @@ struct URLOptionalSequenceArchiveTests { /// so this asserts the asymmetric `Codable` from the reading side. @Test("A pre-feature archive decodes, and its combined rule is still required") func preFeatureArchiveDecodes() throws {- let document = BackupV7Fixtures.sequencePresenceOmittedDocument()+ let document = BackupV8Fixtures.sequencePresenceOmittedDocument() #expect(!String(decoding: document, as: UTF8.self).contains("sequencePresence")) - let decoded = try BackupV7Codec.decode(document)+ let decoded = try BackupV8Codec.decode(document) - #expect(decoded.payload == BackupV7Fixtures.combinedRulePayload(presence: .required))+ #expect(decoded.payload == BackupV8Fixtures.combinedRulePayload(presence: .required)) #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .required) } @@ -917,17 +917,17 @@ struct URLOptionalSequenceArchiveTests { /// this feature would produce — which is what keeps it importable there. @Test("An archive with no declared-optional rule encodes the pre-feature bytes") func requiredArchiveIsByteIdenticalToPreFeature() throws {- let encoded = try BackupV7Codec.encode(- payload: BackupV7Fixtures.combinedRulePayload(presence: .required),- metadata: BackupV7Metadata(- appBuild: "pre-feature", exportedAt: BackupV7Fixtures.created))+ let encoded = try BackupV8Codec.encode(+ payload: BackupV8Fixtures.combinedRulePayload(presence: .required),+ metadata: BackupV8Metadata(+ appBuild: "pre-feature", exportedAt: BackupV8Fixtures.created)) let json = String(decoding: encoded, as: UTF8.self) #expect(!json.contains("sequencePresence")) #expect(- json.contains(BackupV7Fixtures.sequencePresenceOmittedPayloadJSON),+ json.contains(BackupV8Fixtures.sequencePresenceOmittedPayloadJSON), "the exported payload is no longer the pre-feature payload")- #expect(encoded == BackupV7Fixtures.sequencePresenceOmittedDocument())+ #expect(encoded == BackupV8Fixtures.sequencePresenceOmittedDocument()) } /// Req 5.4: a declared-optional rule survives export and import unchanged. The@@ -935,13 +935,13 @@ struct URLOptionalSequenceArchiveTests { /// `URLRulePattern` a reader would end up with, not merely a decoded value. @Test("A declared-optional rule round-trips through export and import") func optionalRuleRoundTripsThroughTheArchive() throws {- let payload = BackupV7Fixtures.combinedRulePayload(presence: .optional)- let encoded = try BackupV7Codec.encode(+ let payload = BackupV8Fixtures.combinedRulePayload(presence: .optional)+ let encoded = try BackupV8Codec.encode( payload: payload,- metadata: BackupV7Metadata(appBuild: "with-feature", exportedAt: BackupV7Fixtures.created))+ metadata: BackupV8Metadata(appBuild: "with-feature", exportedAt: BackupV8Fixtures.created)) #expect(String(decoding: encoded, as: UTF8.self).contains(#""sequencePresence":"optional""#)) - let decoded = try BackupV7Codec.decode(encoded)+ let decoded = try BackupV8Codec.decode(encoded) #expect(decoded.payload == payload) #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .optional)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/UnreadableURLRuleTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/UnreadableURLRuleTests.swiftindex 589f542..2822b29 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/UnreadableURLRuleTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/UnreadableURLRuleTests.swift@@ -137,11 +137,11 @@ struct UnreadableURLRuleTests { #expect(contract.outcome.urlVersion == .available(5)) let outcome = try await repository.commitComposedTeaching(contract)- guard case .committed(_, _, let urlRuleID, let urlRuleVersion) = outcome else {+ guard case .committed(_, let urlRuleIDOpt) = outcome else { Issue.record("expected a committed teaching, got \(outcome)") return }- #expect(urlRuleVersion == 5)+ let urlRuleID = try #require(urlRuleIDOpt) #expect(urlRuleID != unreadableID) // The two clauses the retained `version` exists to protect: versions@@ -238,7 +238,7 @@ struct UnreadableURLRuleTests { func recalculationMutatesNoWorkIdentity() async throws { let fixture = try UnreadableRuleFixture() let unreadableID = try fixture.seedSiteHoldingFourRules()- let workID = try fixture.attachWorkWithIdentity(ruleID: unreadableID, ruleVersion: 4)+ let workID = try fixture.attachWorkWithIdentity(ruleID: unreadableID) let repository = try fixture.diagnosedRepository() let contract = try await repository.previewRecalculation(@@ -263,7 +263,7 @@ struct UnreadableURLRuleTests { let fixture = try UnreadableRuleFixture() let unreadableID = try fixture.seedSiteHoldingFourRules() let (sourceID, targetID) = try fixture.attachTwoWorksWithIdentities(- ruleID: unreadableID, ruleVersion: 4)+ ruleID: unreadableID) let repository = try fixture.diagnosedRepository() let contract = try await repository.projectMerge(@@ -288,7 +288,7 @@ struct UnreadableURLRuleTests { let retainedID = try fixture.seedSiteHoldingFourRules( corruptCurrent: false, markCurrent: false) let (sourceID, targetID) = try fixture.attachTwoWorksWithIdentities(- ruleID: retainedID, ruleVersion: 4)+ ruleID: retainedID) let repository = try fixture.diagnosedRepository() let contract = try await repository.projectMerge(@@ -313,7 +313,7 @@ struct UnreadableURLRuleTests { func diagnosisNamesTheRule() throws { let fixture = try UnreadableRuleFixture() let unreadableID = try fixture.seedSiteHoldingFourRules()- try fixture.citeRuleFromEntry(ruleID: unreadableID, ruleVersion: 4)+ try fixture.citeRuleFromEntry(ruleID: unreadableID) let diagnostics = try LibraryValidator.validate(context: fixture.freshContext()) let error = try #require(diagnostics.quarantineMap()[UnreadableRuleFixture.hostname])@@ -477,19 +477,19 @@ struct UnreadableRuleFixture { /// Makes the seeded Entry cite the rule for its Work extraction, so the /// per-Entry replay has something to fail on.- func citeRuleFromEntry(ruleID: UUID, ruleVersion: Int) throws {+ func citeRuleFromEntry(ruleID: UUID) throws { let context = ModelContext(container) let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first) entry.urlWorkIdentity = Self.identityValue entry.editCitations {- $0.urlWork = CitedRule(id: ruleID, version: ruleVersion)+ $0.urlWork = CitedRule(id: ruleID) } try context.save() } /// Attaches the seeded Entry to a Work carrying a rule-derived URL identity. @discardableResult- func attachWorkWithIdentity(ruleID: UUID, ruleVersion: Int) throws -> UUID {+ func attachWorkWithIdentity(ruleID: UUID) throws -> UUID { let context = ModelContext(container) let site = try #require(try context.fetch(FetchDescriptor<Site>()).first) let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)@@ -499,7 +499,7 @@ struct UnreadableRuleFixture { let work = Work.createCarrying( in: context, title: "A Story", hostname: Self.hostname, site: site, identity: WorkURLIdentityMint(- value: Self.identityValue, ruleID: ruleID, ruleVersion: ruleVersion),+ value: Self.identityValue, ruleID: ruleID), timestamp: Self.epoch) work.lastParsedTitle = "A Story" entry.work = work@@ -510,7 +510,7 @@ struct UnreadableRuleFixture { /// Two Works on the hostname, each holding one Entry and a retained /// identity — the shape Merge needs to have anything to decide about. func attachTwoWorksWithIdentities(- ruleID: UUID, ruleVersion: Int+ ruleID: UUID ) throws -> (source: UUID, target: UUID) { let context = ModelContext(container) let site = try #require(try context.fetch(FetchDescriptor<Site>()).first)@@ -519,7 +519,7 @@ struct UnreadableRuleFixture { let work = Work.createCarrying( in: context, title: title, hostname: Self.hostname, site: site, identity: WorkURLIdentityMint(- value: identity, ruleID: ruleID, ruleVersion: ruleVersion),+ value: identity, ruleID: ruleID), timestamp: Self.epoch) work.lastParsedTitle = title
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreFixture.swiftindex 701e60d..f759c92 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreFixture.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreFixture.swift@@ -221,11 +221,11 @@ enum V8RecordedStoreFixture { } private static var citedURLRule: CitedRule {- CitedRule(id: urlRuleID, version: urlRuleVersion)+ CitedRule(id: urlRuleID) } private static var citedPattern: CitedRule {- CitedRule(id: patternID, version: patternVersion)+ CitedRule(id: patternID) } /// Entry A's blob: the v2 identity arm and the URL-rule work assignment,@@ -236,7 +236,7 @@ enum V8RecordedStoreFixture { urlWork: citedURLRule, chapterSequence: citedURLRule, chapterTitle: FieldProvenance.tolerant(- kind: .pattern, patternID: patternID, patternVersion: patternVersion),+ kind: .pattern, patternID: patternID), workAssignment: .urlRule(citedURLRule), workURL: citedURLRule, workURLAssignmentKind: .identity)@@ -249,7 +249,7 @@ enum V8RecordedStoreFixture { identity: .composed(url: citedURLRule, nameTitle: citedPattern), chapterSequence: citedURLRule, chapterTitle: FieldProvenance.tolerant(- kind: .pattern, patternID: patternID, patternVersion: patternVersion),+ kind: .pattern, patternID: patternID), workAssignment: .pattern(citedPattern)) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ValidatorFixtures.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ValidatorFixtures.swiftindex 32797a5..2419453 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ValidatorFixtures.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ValidatorFixtures.swift@@ -97,15 +97,14 @@ enum ValidatorFixtures { entry.urlWorkIdentity = "42" entry.chapterSequence = "7" entry.chapterTitle = "Chapter 7"- let citedRule = CitedRule(id: rule.id, version: rule.version)+ let citedRule = CitedRule(id: rule.id) try entry.setCitations( EntryCitations( identity: .rule(url: citedRule, nameTitle: nil), urlWork: citedRule, chapterSequence: citedRule, chapterTitle: FieldProvenance.tolerant(- kind: .pattern, patternID: titlePattern.id,- patternVersion: titlePattern.version),+ kind: .pattern, patternID: titlePattern.id), workAssignment: .urlRule(citedRule), workURL: citedRule, workURLAssignmentKind: .identity))@@ -151,8 +150,8 @@ enum ValidatorFixtures { entry.identityBasis = .urlRule entry.conservativeIdentityKey = rawURL entry.chapterSequence = "7"- let citedRule = CitedRule(id: rule.id, version: rule.version)- let citedPattern = CitedRule(id: titlePattern.id, version: titlePattern.version)+ let citedRule = CitedRule(id: rule.id)+ let citedPattern = CitedRule(id: titlePattern.id) try entry.setCitations( EntryCitations( identity: .composed(url: citedRule, nameTitle: citedPattern),@@ -187,7 +186,7 @@ enum ValidatorFixtures { entry.conservativeIdentityKey = rawURL entry.editCitations { $0.workAssignment = .pattern(- CitedRule(id: titlePattern.id, version: titlePattern.version))+ CitedRule(id: titlePattern.id)) } work.entries = [entry] entry.site = site
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swiftindex f5ed1c6..bed46b0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift@@ -220,7 +220,7 @@ struct WorkMergePlannerTests { private func queryRule() throws -> URLRuleBasisEntry { try URLRuleBasisEntry( id: UUID(uuidString: "00000000-0000-0000-0000-000000000099")!,- version: 1,+ createdAt: Date(timeIntervalSince1970: 1_800_000_000), isCurrent: true, origin: .readerTaught, definition: .work(locator: .query(name: ExactScalarString("series")))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLContractTests.swiftindex 254e502..722953d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkURLContractTests.swift@@ -212,7 +212,7 @@ struct WorkURLContractTests { ) throws -> WorkURLBasis { let ruleEntry = try URLRuleBasisEntry( id: uuid(900),- version: 1,+ createdAt: Date(timeIntervalSince1970: 1_800_000_000), isCurrent: true, definition: rule )
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swiftindex b4b6ecf..1274e7b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift@@ -276,7 +276,7 @@ struct WriteSiteRelationshipTests { let context = ModelContext(container) try LibraryRepository.materializeArchive(- BackupImportPayload(BackupV7Fixtures.minimalTaughtPayload()), into: context)+ BackupImportPayload(BackupV8Fixtures.minimalTaughtPayload()), into: context) let site = try #require(try context.fetch(FetchDescriptor<Site>()).first) let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)@@ -410,10 +410,10 @@ struct WriteSiteRelationshipTests { } private func importPlan() throws -> BackupImportPlan {- let payload = BackupImportPayload(BackupV7Fixtures.minimalTaughtPayload())+ let payload = BackupImportPayload(BackupV8Fixtures.minimalTaughtPayload()) return BackupImportPlan( metadata: BackupImportMetadata(- formatVersion: 7, schemaVersion: 8, appBuild: "test",+ formatVersion: 8, schemaVersion: 9, appBuild: "test", exportedAt: Self.ts, capabilityGate: "multi-site", entryCount: payload.entries.count, workCount: payload.works.count), payload: payload,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLDiagnosisTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLDiagnosisTests.swiftindex 7d87bb5..b1ce77c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLDiagnosisTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLDiagnosisTests.swift@@ -15,7 +15,7 @@ struct WrongHostWorkURLDiagnosisTests { private func seed() throws -> (UnreadableRuleFixture, LibraryDiagnosis) { let fixture = try UnreadableRuleFixture() let ruleID = try fixture.seedSiteHoldingFourRules(corruptCurrent: false)- let workID = try fixture.attachWorkWithIdentity(ruleID: ruleID, ruleVersion: 4)+ let workID = try fixture.attachWorkWithIdentity(ruleID: ruleID) do { let context = fixture.freshContext() let work = try #require(try context.fetch(FetchDescriptor<Work>()).first { $0.id == workID })
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swiftindex e2bc79e..0bf9bf0 100644--- a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift@@ -17,26 +17,32 @@ struct RuleSuggestionLedgerTests { url: nil) } - static func fingerprint(entryCount: Int = 3, titleRuleVersion: Int? = nil) -> CorpusFingerprint {+ static func fingerprint(entryCount: Int = 3, titleRuleID: UUID? = nil) -> CorpusFingerprint { CorpusFingerprint(siteMode: .untaught, entryCount: entryCount, latestCaptureAt: Date(timeIntervalSince1970: 1_700_000_000),- titleRuleVersion: titleRuleVersion, urlRuleVersion: nil)+ titleRuleID: titleRuleID, urlRuleID: nil) } /// A ledger mid-sweep with one attempt running for `hostname`.- static func running(_ hostname: String, origin: Origin = .background) -> RuleSuggestionLedger {+ static func running(_ hostname: String, origin: Origin = .background,+ fingerprint print: CorpusFingerprint = fingerprint())+ -> RuleSuggestionLedger+ { var ledger = RuleSuggestionLedger() ledger.beginSweep() let outcome = ledger.start(hostname: hostname, origin: origin,- fingerprint: fingerprint(), environment: foreground)+ fingerprint: print, environment: foreground) #expect(outcome == .start) return ledger } /// A ledger that has settled one attempt for `hostname`. static func settled(_ hostname: String, _ settlement: AttemptSettlement,- modelPhase: Duration = .seconds(1)) -> RuleSuggestionLedger {- var ledger = running(hostname)+ modelPhase: Duration = .seconds(1),+ fingerprint print: CorpusFingerprint = fingerprint())+ -> RuleSuggestionLedger+ {+ var ledger = running(hostname, fingerprint: print) ledger.settle(hostname: hostname, settlement, modelPhase: modelPhase) return ledger }@@ -604,14 +610,31 @@ struct RuleSuggestionLedgerTests { #expect(!ledger.isAttempted("a.example")) } - @Test("A newly stored rule version invalidates the hostname")- func ruleVersionMismatchInvalidates() {- var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))+ @Test("A newly taught rule — a new rule UUID — invalidates the hostname (Req 6.2)")+ func ruleIDMismatchInvalidates() {+ let taught = UUID()+ var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")),+ fingerprint: Self.fingerprint(titleRuleID: taught)) let invalidated = ledger.reconcile(- against: ["a.example": Self.fingerprint(titleRuleVersion: 1)])+ against: ["a.example": Self.fingerprint(titleRuleID: UUID())]) #expect(invalidated == ["a.example"])+ #expect(ledger.held(for: "a.example") == nil)+ }++ @Test("A rule recalculated in place — the same UUID — leaves the hostname valid (Req 6.2)")+ func sameRuleIDKeepsState() {+ let recalculated = UUID()+ var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")),+ fingerprint: Self.fingerprint(titleRuleID: recalculated))++ let invalidated = ledger.reconcile(+ against: ["a.example": Self.fingerprint(titleRuleID: recalculated)])++ #expect(invalidated.isEmpty)+ #expect(ledger.held(for: "a.example") != nil)+ #expect(ledger.isAttempted("a.example")) } @Test("An unchanged candidate row invalidates nothing")
diff --git a/docs/agent-notes/rule-wire-format.md b/docs/agent-notes/rule-wire-format.mdindex 39c45f7..8676495 100644--- a/docs/agent-notes/rule-wire-format.md+++ b/docs/agent-notes/rule-wire-format.md@@ -6,9 +6,11 @@ They reach persistence twice, by different routes: - **The store**: JSON in an opaque column — `URLRulePattern.definitionData`, and `TitlePattern`'s decoded columns. Mirrored to CloudKit as bytes, so no schema change is involved and no schema version protects it.-- **The archive**: the *typed* value inside `BackupV4URLRule.definition` /- `BackupV4TitlePattern.definition` (historical type names, live 6/7 wire- substrate), re-encoded and checksummed by `BackupV6Codec`.+- **The archive**: the *typed* value inside `BackupV8URLRule.definition` /+ `BackupV8TitlePattern.definition` — the live 8/9 wire substrate — re-encoded+ and checksummed by `BackupV8Codec`. The record types are renamed with each+ generation, so a note naming `BackupV4*`/`BackupV6Codec` is describing a build+ three generations back. So a change to one of these types has two independent compatibility stories, and the failure mode depends on *what kind* of change it is. This is not obvious from either call
diff --git a/docs/agent-notes/schema-migration.md b/docs/agent-notes/schema-migration.mdindex a0378d6..c1c1adf 100644--- a/docs/agent-notes/schema-migration.md+++ b/docs/agent-notes/schema-migration.md@@ -47,7 +47,12 @@ background for the *next* schema bump and describes states that no longer exist. the default value by `Entry.init` (Q13). **A nil blob means one thing**: a row a V8 build wrote that this build has not rewritten. It reads as the `.rawURL` default and is *reported*, never quarantined (Q6, Q25, Q32) —- `entriesWithoutCitationBlobCount` is the counter.+ `entriesWithoutCitationBlobCount` is the counter. Since T-2281 a citation+ inside that blob is the cited rule's **UUID alone**; a blob carrying the old+ `version` key decodes fine and the key is ignored, and nothing rewrites it+ (`rule-citation-by-uuid` Q3), so old-shape bytes survive on device+ indefinitely. There is no transitional encoding, so a device on a build+ before T-2281 cannot read what this one writes (Q8). - A TitlePattern's definition is `TitlePattern.definitionData`, with no column fallback; a nil blob is `unreadableTitlePattern` and orders under its own constant-prefixed key (Q11).@@ -151,26 +156,32 @@ background for the *next* schema bump and describes states that no longer exist. comment where it stood). "Work-only" is derived, not stored: `Site.isWorkOnlyTitleRule` is true when the active pattern is `.wholeTitle`. - **Capability gate is `.multiSite`** (`AsterismCapabilities.current`,- `multi-site-works` Q29). `BackupV7Codec` stamps the literal `"multi-site"`+ `multi-site-works` Q29). `BackupV8Codec` stamps the literal `"multi-site"` rather than reading `current`, so the archive's gate is independent of the- runtime's. No `supports…` answer changed with the gate — every one is m4's —- so the case exists to name the archive generation and the store shape it- belongs to. `BackupV6Codec` is gone.-- **Backup writes and reads 7/8 only** (the `data-model-cleanups` Decision 2+ runtime's. 8/9 kept the literal (`rule-citation-by-uuid` Q19): the gate names a+ store shape and a rule-form set, and neither changed. No `supports…` answer+ changed with the gate — every one is m4's — so the case exists to name the+ store shape it belongs to, and the *generation* is named by its format/schema+ numbers, which is what the importer gates on.+- **Backup writes and reads 8/9 only** (the `data-model-cleanups` Decision 2 argument, made again: single-user population, fully migrated).- `BackupV7Exporter` is the only exporter and `BackupImporter.plan` accepts only- `supportedVersions` — `(BackupV7Document.formatVersion,- BackupV7Document.schemaVersion)`, i.e. `(7, 8)` — with any other pair refused+ `BackupV8Exporter` is the only exporter and `BackupImporter.plan` accepts only+ `supportedVersions` — `(BackupV8Document.formatVersion,+ BackupV8Document.schemaVersion)`, i.e. `(8, 9)` — with any other pair refused by version check, naming the detected pair, not by decode failure. The archive- format number is not the schema number: 7/8 is format 7 over schema 8. Every- older import path — 2/2, 3/3, 4/4, 5/6, 6/7 — is **deleted**; recovering an- older archive means checking out a build that still carries its importer. The- historically-named V4/V5 *record types* (`BackupV4Entry`, `BackupV5Work`, …)- that used to be the payload's wire substrate are deleted too: the payload is- `BackupV7Payload` over `BackupV7Work`, `BackupV7Entry`, `BackupV7Membership`,- `BackupV7DistinctPair` and the rest, all named for the format that carries- them. `LegacyV2DateFormatter` and `DuplicateJSONKeyValidator` live on in- `BackupJSONCodecSupport.swift`; the live codec uses both.+ format number is not the schema number: 8/9 is format 8 over schema 9, and+ since `rule-citation-by-uuid` Q9 the schema number names the *store* schema+ the archive was taken from. Every older import path — 2/2, 3/3, 4/4, 5/6, 6/7,+ 7/8 — is **deleted**; recovering an older archive means checking out a build+ that still carries its importer. **A 7/8 archive exported before this build is+ unreadable by it** (Q8): the restorable archive is one exported *after*+ upgrading. The historically-named V4/V5 *record types* (`BackupV4Entry`,+ `BackupV5Work`, …) that used to be the payload's wire substrate are deleted+ too: the payload is `BackupV8Payload` over `BackupV8Work`, `BackupV8Entry`,+ `BackupV8Membership`, `BackupV8DistinctPair` and the rest, all named for the+ format that carries them. `LegacyV2DateFormatter` and+ `DuplicateJSONKeyValidator` live on in `BackupJSONCodecSupport.swift`; the live+ codec uses both. - **`AsterismSchemaV2` is gone, and so is the second file layout.** It was never the four-model schema T-2113 described — `Schema` cascades through `Site.urlRules`, so it always resolved to the same five entities (Q20). What@@ -203,7 +214,7 @@ the suite that measures the conversion (`V8RecordedStoreTests` over | Classify the new state | `BootstrapState` (`LibraryRepository+BootstrapState.swift`) is an ordered match the compiler checks for exhaustiveness; a new marker generation needs a case there and an action beside it, not a guard inside the open | | Add the upgrade path | A marker-lagging branch that runs the data pass, validates, and publishes the new marker *after* the work it certifies — never before. **`drop-superseded-columns` is the live worked example**: `BootstrapState.markerLagging` plus the `"8"` arm in `act(on:)`, with `MarkerGenerationNineTests` pinning the sequence, the failure that must leave the marker put, and both halves of the extension's fork. A stage with no data pass still needs the digit, and validation is what certifies it | | Keep the extension out | The extension opens only the current marker version. It must never migrate: it holds a shared lock, and two invocations can run concurrently. This matters most for a stage that **removes** — a concurrent share-sheet open mid-conversion is destructive rather than merely early |-| Extend the archive, if the schema is reader data | A new table the reader owns needs an archive generation too — `multi-site-works` is the live worked example, 6/7 → 7/8 with `BackupV7Exporter`/`BackupV7Codec` replacing the V6 pair outright — or a backup silently stops round-tripping it. A stage that only *removes* changes no wire shape: V9 re-recorded no golden, and `BackupGoldenExportTests` imports the pre-V9 archive into a V9 library to prove it |+| Extend the archive, if the schema is reader data | A new table the reader owns needs an archive generation too — `rule-citation-by-uuid` is the freshest worked example, 7/8 → 8/9 with `BackupV8Exporter`/`BackupV8Codec` replacing the V7 set outright (Q14), and `multi-site-works` did the same at 6/7 → 7/8 — or a backup silently stops round-tripping it. A stage that only *removes* store columns changes no wire shape: V9 re-recorded no golden. But a change to a record the archive *carries* does, whatever the store schema does — 8/9 is a codec change with no schema stage behind it, which is why Q9 pins the schema number to the store the archive was taken from. Re-record the golden through `BackupGoldenExportTests`' `ASTERISM_RECORD_GOLDEN=1` mode (Q22) rather than by hand | `specs/relational-references/` is the full worked spec for a relational bump.
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex c7723a5..9fd4a51 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -549,7 +549,7 @@ Four things the spec settled that this paragraph did not anticipate, each of whi - **A row is not a record.** Rows sharing an application UUID are one logical record — an *identity group* — and every write the app mediates addresses the whole group in one commit, every presentation shows it once, and the archive exports it once. That reaches roughly sixteen call sites behind two fetch helpers, and it is most of the work. - **Deletion is two-pass.** Nothing is deleted in the pass that first observes a set, or in one that observes it changing: a set has to be unchanged since an earlier pass of the same session, and the deleting commit re-verifies in a fresh context and aborts otherwise. In-memory, per-session, no new stored or synced schema. - **A group whose rows disagree about something the reader wrote is *torn*, and torn is the one state the archive cannot hold.** Backup export refuses for torn groups and for nothing else about duplicates; a non-torn split group exports as one record. That replaced M4b's refusal over every repeated UUID.-- **Rule records converge, they never collapse.** Rule rows carry nothing reader-authored, so a duplicated rule UUID is repaired by making its rows identical in place and re-pointing citations at the surviving version. Nothing is deleted, and the store-level validator was relaxed to accept a converged group as one rule — without it, a converged group kept its hostname quarantined and took teaching off the capture path.+- **Rule records converge, they never collapse.** Rule rows carry nothing reader-authored, so a duplicated rule UUID is repaired by making its rows identical in place — definition copied down, marked flag demoted within each Site, versions left as found. Citations name the rule by UUID alone (T-2281), so nothing needs re-pointing. Nothing is deleted, and the store-level validator was relaxed to accept a converged group as one rule — without it, a converged group kept its hostname quarantined and took teaching off the capture path. Deliberately last, and it paid: the reconciler was written against the states M4a and M4b had already made observable rather than against a guess about which kinds occur.
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 691260f..8ee5eed 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -30,6 +30,7 @@ | [Multi-Site Works](#multi-site-works) | 2026-08-25 | Done — all tasks implemented and reviewed; `make test-core`, `make test-quick` and `make build` green. The `"7"`→`"8"` migration was confirmed on the real library 2026-08-27. Two open Before-Release items in `prerequisites.md`: the `test-performance-chunks` sweep has not been re-run, and `WorkDetailView`'s three-membership site line has never been eyeballed at the accessibility text sizes (TODO in `siteIdentityRow`). Req 8.6 is not fully met (Q89 amended — three full-tier labels plus the three `diagnosis-refresh` arms stay out of band, all known issues inside regression ceilings) | T-2230. A Work holds a set of site memberships (`WorkSiteMembership`: hostname, URL identity, rule UUID, Work URL) so the same story read on two sites is one Work: cross-site merge with a searchable picker, membership-aware duplicate scan with a dismissable "Not the same work" record, one interleaved chapter spine. Schema V8 is additive with a marker-gated population pass; the superseded columns survive unread until V9 (Decision 3). Rides the TitlePattern definition blob, the Entry citation blob and the `typeRaw` retirement; archive format 7/8 replaces 6/7 outright (Decision 2). Prerequisite `data-model-cleanups` merged. | | [Post-V8 Convergence](#post-v8-convergence) | 2026-08-27 | Done — research; no code | Research outcome of T-2272. Routes the four post-V8 convergence items into three sequenced feature tickets — T-2280 (schema V9: column drop, V5+V6 stage retirement, facade removal, closes the three full-tier performance known issues), T-2281 (cite rules by UUID only, no schema stage, archive 8/9; supersedes T-2055), T-2282 (read-time regime for same-UUID groups; supersedes duplicate-reconciliation Decision 4) — and answers the import gate diff in place: the SwiftData gate stays, six check families have no codec equivalent (Q1). | | [Drop Superseded Columns](#drop-superseded-columns) | 2026-08-27 | Done — the V9 device install and the pre-V9 archive export remain the owner's (`specs/drop-superseded-columns/prerequisites.md`) | Smolspec (T-2280), spec A of Post-V8 Convergence. Schema V9 drops the 38 names V8 retained unread — 36 columns plus the `Work.site`/`Site.works` relationship pair (Work's site/identity/URL columns and `typeRaw`, `Site.works`/`Site.urlIdentityRule`, `Entry.identityKeyVersion` and the citation/provenance columns, TitlePattern's definition columns), deletes `LegacyColumns`, `V8PopulationPass` and the migration chain below V8 (plan `[V8, V9]`, marker `"9"`), inverts the citation write path onto the blob, gates `MembershipReconciler.heal` on the diagnosis, and re-measures the two full-tier known issues. Archive format unchanged. |+| [Rule Citation by UUID](#rule-citation-by-uuid) | 2026-08-29 | Done — all 10 tasks complete 2026-08-29; `make test-core`, `make test-quick` and `make build` green with no new warnings, the Req 7.1 grep gate clean, and two `make test-performance-m4` runs inside every ceiling with the Req 5.4 capture-projection arms unmoved (verification-run.md). Branch not yet merged | Full spec (T-2281), spec B of Post-V8 Convergence; supersedes T-2055. `CitedRule` becomes `{id}` — the version integer leaves every citation, and with it the Site-unique/greatest-version invariants, `SiteUnionProjection` renumbering, both reconcilers' citation-rewrite walks and the export rewrite map. "Current" is the marked row with a `(createdAt desc, id)` tiebreak behind two `Site` accessors (Decision 1, Q11); the row `version` column stays advisory. No schema stage; archive 7/8 → 8/9 with the `BackupV7*` → `BackupV8*` rename (Q14). Single device, so no rollout gate (Q8). | --- @@ -508,3 +509,19 @@ Smolspec (T-2280), spec A of the Post-V8 Convergence research. Schema V9 drops t - [prerequisites.md](drop-superseded-columns/prerequisites.md) - [verification-run.md](drop-superseded-columns/verification-run.md) - [implementation.md](drop-superseded-columns/implementation.md)++## Rule Citation by UUID++Full spec (T-2281), spec B of the Post-V8 Convergence research; supersedes T-2055. Citations pin the rule row's UUID only; the version invariants and their repair machinery are deleted rather than unified.++- **`CitedRule {id}`, `FieldProvenance {kind, patternID}`, `URLRuleReference {id}`** — a blob codec change read tolerantly over old-shape blobs (Q3, Q17); resolution is by UUID within the citing record's Site at five sites, same-UUID groups via the existing group ordering (Q6).+- **Deleted**: `assignVersions`/`versionRewrites`, `SiteReconciler.rewriteCitations`, `DuplicateReconciler.alignVersions` and friends, the export rewrite map, `RuleTally`'s version arms, the validator's and archive check's version rules; the "Historical rule vN" ordinal goes with them (Q7). Row `version` stays, advisory; the model-init guards keep it positive while the read-side positivity checks are gone (Q4, Q24, Q31).+- **"Current"** = marked row, tiebreak newest `createdAt` then lowest UUID, shared by `Site.activePattern`/`currentURLRule` and the union's demotion repair (Decision 1, Q11, Q18); readers that refuse a double-marked Site keep refusing (Q12).+- **Archive 8/9**: `BackupV8*` replaces `BackupV7*` outright (Q14), gate unchanged (Q19), golden re-recorded through a new record mode (Q22). The bump and the field drop land in one commit.++- [requirements.md](rule-citation-by-uuid/requirements.md)+- [design.md](rule-citation-by-uuid/design.md)+- [tasks.md](rule-citation-by-uuid/tasks.md)+- [decision_log.md](rule-citation-by-uuid/decision_log.md)+- [prerequisites.md](rule-citation-by-uuid/prerequisites.md)+- [verification-run.md](rule-citation-by-uuid/verification-run.md)
diff --git a/specs/retire-migration-chain/library-graph-baseline.txt b/specs/retire-migration-chain/library-graph-baseline.txtindex 1a07319..4bea437 100644--- a/specs/retire-migration-chain/library-graph-baseline.txt+++ b/specs/retire-migration-chain/library-graph-baseline.txt@@ -2,14 +2,18 @@ # Produced by LibraryGraphBaselineTests. Regenerating this file from post-change # code defeats the comparison it exists for: it is re-recorded only when the # schema itself changes shape, and the format line says which shape it holds.-# format 5 is schema V9 (drop-superseded-columns, T-2280): the columns+# format 5 was schema V9 (drop-superseded-columns, T-2280): the columns # format 4 recorded as present-but-unwritten are gone from the store, so the # dump no longer has a field for them. A Work is its title, its notes and its # work-type identity; its site presence is its WorkSiteMembership, an Entry's # citations are its citationsData and a title rule is its definitionData. The # Site.works inverse went with Work.site, which is why inverse-site lists one # array fewer.-format 5+# format 6 is the same store with one field removed from the citation blob:+# rule-citation-by-uuid (T-2281) cites a rule by UUID alone, so no citation+# carries a version. Re-recorded by deleting that key from the one blob that+# held it, not by regenerating the file.+format 6 counts entries=5 works=1 sites=3 titlePatterns=1 urlRulePatterns=1 workTypes=3 memberships=1 distinctPairs=0 site hostname="alpha.test" displayName="Alpha Reader" modeRaw="untaught" junkSuffixRule=nil site hostname="beta.test" displayName="Beta Serials" modeRaw="taught" junkSuffixRule=nil@@ -22,7 +26,7 @@ workType id=D0000003-0000-4000-8000-000000000003 name="article" nameModifiedAt=0 work id=A3000000-0000-4000-8000-000000000003 displayTitle="Beta Serial" lastParsedTitle="Beta Serial" genericNotes="notes on the serial" workTypeID=A4000000-0000-4000-8000-000000000004 genreTags=["action","drama"] titleProvenanceRaw="parsed" createdAt=1800000000.000 modifiedAt=1800000000.000 entry id=B1000000-0000-4000-8000-000000000011 site="alpha.test" work=nil hostname="alpha.test" captureTitle="An Alpha Capture" captureTitleSourceRaw="host" rawURLString="https://alpha.test/read/1" canonicalURLString=nil entryIdentityKey="https://alpha.test/read/1" conservativeIdentityKey="https://alpha.test/read/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"none\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"none\":{}}}" entry id=B2000000-0000-4000-8000-000000000012 site="beta.test" work=A3000000-0000-4000-8000-000000000003 hostname="beta.test" captureTitle="The Beta Serial :: Chapter One" captureTitleSourceRaw="host" rawURLString="https://beta.test/read/1" canonicalURLString=nil entryIdentityKey="https://beta.test/read/1" conservativeIdentityKey="https://beta.test/read/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle="Chapter One" note="a reader's note" ratingRaw="up" firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"manual\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"manual\":{}}}"-entry id=B3000000-0000-4000-8000-000000000013 site="beta.test" work=A3000000-0000-4000-8000-000000000003 hostname="beta.test" captureTitle="The Beta Serial :: Chapter Two" captureTitleSourceRaw="host" rawURLString="https://beta.test/read/2" canonicalURLString=nil entryIdentityKey="https://beta.test/read/2" conservativeIdentityKey="https://beta.test/read/2" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle="Chapter Two" note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"pattern\",\"patternID\":\"A1000000-0000-4000-8000-000000000001\",\"patternVersion\":1},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"manual\":{}}}"+entry id=B3000000-0000-4000-8000-000000000013 site="beta.test" work=A3000000-0000-4000-8000-000000000003 hostname="beta.test" captureTitle="The Beta Serial :: Chapter Two" captureTitleSourceRaw="host" rawURLString="https://beta.test/read/2" canonicalURLString=nil entryIdentityKey="https://beta.test/read/2" conservativeIdentityKey="https://beta.test/read/2" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle="Chapter Two" note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"pattern\",\"patternID\":\"A1000000-0000-4000-8000-000000000001\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"manual\":{}}}" entry id=B4000000-0000-4000-8000-000000000014 site="gamma.test" work=nil hostname="gamma.test" captureTitle="A Gamma Article" captureTitleSourceRaw="host" rawURLString="https://gamma.test/posts/1" canonicalURLString=nil entryIdentityKey="https://gamma.test/posts/1" conservativeIdentityKey="https://gamma.test/posts/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=true citationsData="{\"chapterTitle\":{\"kind\":\"none\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"none\":{}}}" entry id=B5000000-0000-4000-8000-000000000015 site=nil work=nil hostname="orphan.test" captureTitle="An Orphaned Capture" captureTitleSourceRaw="host" rawURLString="https://orphan.test/read/1" canonicalURLString=nil entryIdentityKey="https://orphan.test/read/1" conservativeIdentityKey="https://orphan.test/read/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"none\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"none\":{}}}" workSiteMembership id=A5000000-0000-4000-8000-000000000005 hostname="beta.test" createdAt=1800000000.000 urlIdentity=nil urlIdentityStateRaw="none" urlIdentityRuleID=nil workURLString=nil workID=A3000000-0000-4000-8000-000000000003 work=A3000000-0000-4000-8000-000000000003 site="beta.test"
diff --git a/specs/rule-citation-by-uuid/decision_log.md b/specs/rule-citation-by-uuid/decision_log.mdnew file mode 100644index 0000000..266b7ce--- /dev/null+++ b/specs/rule-citation-by-uuid/decision_log.md@@ -0,0 +1,79 @@+# Decision Log: Rule Citation by UUID++Transit: T-2281. Spec B of `specs/post-v8-convergence/` (research Item 1, Q3, Q4, Decision 1). Supersedes T-2055.++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-29 | Full spec workflow, not smolspec | The change alters a persisted format — `EntryCitations`, serialised into both the CloudKit-mirrored `citationsData` blob and the archive (generation 8/9, one generation accepted) — which a revert does not undo once a build has written data |+| Q2 | 2026-08-29 | Spec folder is `rule-citation-by-uuid` | The name the post-v8-convergence research table and `specs/OVERVIEW.md` already use for T-2281 |+| Q3 | 2026-08-29 | Existing citation blobs carrying `version` are read tolerantly and the field ignored; no rewrite pass | Every Entry on device and in CloudKit holds the old shape. A start-up pass is a full-library write for no reader-visible gain. `editCitations` is value-guarded and both bulk citation walks are deleted, so an Entry never edited keeps old bytes indefinitely — harmless, since the decoder ignores the field |+| Q4 | 2026-08-29 | The rule rows' `version` column stays, advisory, still minted `max + 1` with its overflow refusals, with no validator rule | Dropping it is a schema stage, which post-v8-convergence Q3 keeps out of this spec. Keeping it enforced would keep the renumbering machinery alive, which is the thing being deleted. The minting and its three refusals (`+OutcomeComputation`, `+ComposedTeaching` ×2) stay because the column stays |+| Q5 | 2026-08-29 | The rule-suggestion corpus fingerprint keys on the current rule UUIDs instead of their versions | Equivalent behaviour: re-teaching mints a new UUID and a new version together, recalculation reuses both, so the change token flips in the same cases. The ledger is in-memory only, so this is not a third format change. Removes the last consumer that reads `version` as meaningful, and stops a site merge's renumbering from spuriously invalidating a hostname |+| Q6 | 2026-08-29 | Where a cited UUID resolves to several rows in one Site, resolution uses the existing rule-row group ordering (`GroupOrdering.representativePattern` / `representativeURLRule`) | Same-UUID groups are T-2282's subject; this spec must only be deterministic, not decide the winner. That ordering still carries `version` as its third key; harmless today because the validator requires same-UUID groups to be converged (identical definitions), and T-2282 owns removing the key when it removes write-time convergence |+| Q7 | 2026-08-29 | The "Historical rule vN" ordinal and badge are removed rather than re-sourced | No view reads `PatternRuleSummary.version`, `FieldSettlement`'s version or `badge(isCurrent:version:)` — the surface is API and tests only. A read-time ordinal from `(createdAt, id)` is per-row not per-rule, shifts when a later-synced row has an earlier `createdAt`, and `createdAt` defaults to epoch on rows that never set it. Diverges from the ticket text, which assumed the badge was cheap to keep |+| Q8 | 2026-08-29 | No rollout gate or transitional encoding for a device on the previous build | One device is in use. A lagging device would fail to decode version-free blobs, quarantine the hostname, and overwrite them with defaults via `editCitations` — a real hazard, recorded here so a second device is only added once it runs this build. The restorable archive is one exported **after** upgrading (8/9); a pre-upgrade 7/8 archive is refused by the new build |+| Q9 | 2026-08-29 | The archive's `databaseSchemaVersion` names the store schema the archive was taken from; 8/9 means format 8 over V9 | T-2280 shipped V9 without an archive change, leaving 7/8 over a V9 store. This generation re-couples the number to the store and states the rule so the next generation does not guess |+| Q10 | 2026-08-29 | Every ordering of a Site's rules — presented (Sites screen, Entry-detail historical rules) and internal (URL-identity planner, pattern basis) — sorts by `(createdAt, id)` | They sorted by `(version, id)`. The internal ones only need determinism, but leaving `version` as a key there keeps one more reader of an advisory integer; one key everywhere is simpler |+| Q11 | 2026-08-29 | Two accessors, `Site.activePattern` and a new current-URL-rule accessor, are the only places that read the marked flags to pick a rule | Around twenty non-test call sites read `isActive`/`isCurrent` directly today; Req 4.2 is only testable if the tiebreak lives in one place per rule kind. Grep-checkable |+| Q12 | 2026-08-29 | Readers that refuse or hide a double-marked Site keep doing so; the Decision 1 tiebreak governs the repair and the accessors, not the refusals | Req 3.3 keeps double-marked as a diagnosed state and Req 3.7 repairs it on the next reconcile or import. Relaxing `activeCount != 1` refusals (`+RecentPresentation` ~:315, `+Contracts` ~:93, `+ReparseCapture`) would turn a quarantined state into a silently-tiebroken one |+| Q13 | 2026-08-29 | The reparse screen's "Pattern vN (preserved)" / "Pending pattern vN" labels lose the number; `PendingPatternProvenance.patternVersion` (the version minted for the new row) stays | The preserved label reads `FieldProvenance.patternVersion`, a citation, which goes. The pending one is the row version that Q4 keeps minting |+| Q14 | 2026-08-29 | Archive types and files are renamed `BackupV7*` → `BackupV8*`, old set replaced outright | The 6/7→7/8 bump (`21ef6ed`) did the same and multi-site-works design records it as policy; `FrozenLibraryPathTests` pins the names. Keeping V7 names with bumped constants was the smaller diff but leaves the names one generation behind |+| Q15 | 2026-08-29 | `ComposedTeachingCommitOutcome.committed` drops `titleRuleVersion`/`urlRuleVersion` | They echo the minted row version, which nothing consumes as meaningful; callers needing it read the row by id |+| Q16 | 2026-08-29 | `URLRuleReference` stays as a distinct `{id}` type rather than merging into `CitedRule` | Same shape, but it is the planner's and validator's name for the concept and threads through `ProjectionContract`; merging is churn with no behaviour change |+| Q17 | 2026-08-29 | Req 1.3's old-shape fixture is a JSON literal captured from the current encoder before the change | `V8RecordedStoreFixture` encodes through the live `CitedRule`, so after the change it emits version-free bytes and a tolerance test on it proves nothing |+| Q18 | 2026-08-29 | `SiteUnionProjection.keptRule` drops the owning-Site rank and uses `RuleSelection.current` | One predicate for read and repair (Decision 1). Owning rank existed so the survivor Site's row won; under the new rule the newest teach wins whichever row it sits on |+| Q19 | 2026-08-29 | The 8/9 codec keeps stamping the capability gate `"multi-site"` | `AsterismCapabilities.Gate.multiSite` exists to name a store shape and a rule-form set; 8/9 changes neither. The generation is named by its format/schema numbers, which is what the importer gates on |+| Q20 | 2026-08-29 | `TeachingCommitOutcome.committed` drops `patternVersion` alongside Q15 | Same argument as Q15; leaving one outcome echoing the integer while the other does not is an inconsistency without a reason |+| Q21 | 2026-08-29 | The prospective `URLRuleBasisEntry` in `ComposedTeachingProjection` carries `createdAt = .distantFuture` | It stands for a rule not yet inserted, so it is the newest by construction; the value only feeds the basis sort |+| Q22 | 2026-08-29 | `BackupGoldenExportTests` gains an `ASTERISM_RECORD_GOLDEN=1` record mode | Every generation bump re-records the golden by hand from the byte test's failure; a flag makes the procedure repeatable and keeps the bytes coming from the same code path the test compares |+| Q23 | 2026-08-29 | `BackupGoldenExportTests`' "7/8 archive restores into V9" case is deleted, not re-recorded | Its evidence was bytes a V8 build produced (drop-superseded-columns Q36). Req 5.2 makes those unreadable; a re-recorded 8/9 archive would prove a different, already-covered thing |+| Q24 | 2026-08-29 | `version > 0` stays a model invariant (`TitlePattern`/`URLRulePattern` init guards) and Reqs 3.1/3.2 cover only Site-uniqueness and greatest-version | `ArchiveRecordBuilders` constructs rows through those inits, so an archive with a non-positive version can never import; deleting the guards would be a persisted-shape change for a state nothing produces. The `+Contracts`/`+ReparseCapture` positivity refusals stay for the same reason |+| Q25 | 2026-08-29 | Task 1.2 dropped `version` from the citation types outright; the "temporary id&&version resolution" it asked LibraryValidator and BackupArchiveReferenceChecks to keep until task 4 was unsatisfiable and was not kept | Once `CitedRule`/`URLRuleReference` are `{id}` there is no version to compare. Resolution is id-only from phase 1; Reqs 2.1/2.2/2.5 get their tests in 4.1 and Req 2.6 (group representative) lands in 4.2. Four version-only tests scheduled for deletion in 4.1/5.1/6.1/7.1 went in phase 1 for the same reason (`CitationResolutionParityTests` ×2, `DuplicateReconcilerTests.citationsFollowTheConvergedVersion`, `BackupGroupProjectionTests.droppedRuleRowVersionIsRewritten`, `MultiSiteReviewFixTests.citationRewriteSurvivesACorruptBlob`) |+| Q26 | 2026-08-29 | `SiteReconciler.rewriteCitations` is a no-op from phase 1 until 5.2/6.2 delete its callers; its incidental repair of an undecodable `citationsData` blob (drop-superseded-columns Q24) is not preserved | Q3 already deletes both bulk citation walks, and that repair only ever ran inside one of them. An undecodable blob now stays as it is until the Entry is next edited through the value-guarded `editCitations`; the tolerant decoder (Req 2.4) keeps the reader working. `DuplicateReconciler` short-circuits its Entry walk in the meantime rather than scanning the table to do nothing |+| Q27 | 2026-08-29 | `backup-7-8-golden.json` was re-recorded in phase 1 (citation `version` keys and checksum only), not left for 7.2 | The version-free encoder cannot reproduce the old bytes, so the byte test could not stay green. The intermediate 7/8-with-version-free-citations archive is the state the design forbids; it is tolerable only because the branch is squashed and no mid-branch build is installed (see Q8). 7.2 still deletes this golden and records 8/9 through the `ASTERISM_RECORD_GOLDEN` mode, which phase 1 deliberately did not add |+| Q28 | 2026-08-29 | `specs/retire-migration-chain/library-graph-baseline.txt` bumped to `format 6` by deleting the single `patternVersion` key, not by regenerating | The file's header forbids regeneration; the serializer's `format` line and comment were bumped in the same commit. Added to task 10's grep gate so the deletion inventory covers it |+| Q29 | 2026-08-29 | The Req 4.2 flag-read scan matches whole-file text (wrapped `first(\n where:`, trailing-closure and nested-paren forms) and includes `Asterism/AsterismShareExtension`; the one tolerated offender (`DuplicateReconciler`'s `others.first(where: \.isCurrent)`, deleted in 6.2) is keyed on the line's text inside a `withKnownIssue`, not on the file name | Per-line matching missed the repo's own multi-line call style; a file-level carve-out would let a new selecting read into that file pass. `withKnownIssue` fails once the site is gone, so 6.2 is forced to remove it |+| Q30 | 2026-08-29 | Entry detail's historical list excludes only the row the current-rule accessor picked, not every marked row | Behaviour-preserving today: a double-marked Site is refused wholesale by `validatedRecentSiteMode` (`activeCount != 1`, kept per Q12), so neither row is disclosed. The filter is written against the accessor so that if that refusal is ever relaxed the tiebreak loser lands in the history list rather than vanishing |+| Q31 | 2026-08-29 | The read-side positivity checks on a rule row's `version` (LibraryValidator, BackupArchiveReferenceChecks, `validatedRecentSiteMode`) are dropped; the model-init guards and the minting refusals stay (Q24) | Task 5 deleted the tally/union arm that repaired a non-positive version, so a `version: 0` row arriving over CloudKit would have quarantined its hostname with no pass able to clear it. The column is advisory now (Decision 1); a row nothing can mint locally is tolerated on read rather than refused |+| Q32 | 2026-08-29 | `SiteUnionProjection.keptRule` (newest `createdAt`, per `RuleSelection.current`) and `DuplicateReconciler.demoteWithinSites` (first in `GroupOrdering` representative order) pick different survivors for a same-UUID double-marked group, and this is left as is | They cannot oscillate — `RuleSelection.current` only ever demotes among marked rows — and a same-UUID group is the duplicate reconciler's domain. Recorded so T-2282 does not rediscover it |+| Q33 | 2026-08-29 | `BackupArchiveProjection`'s rewrite map, its Entry-mapper parameter and the `pattern.version`/`rule.version` record writes landed in 5.2, not 7.2 | Deleting `versionRewrites` and the projected `version` fields forced it. 7.2 is the `BackupV7*` → `BackupV8*` rename, the 8/9 bump, the golden record mode and the re-record |+| Q34 | 2026-08-29 | `exportImportExportIsByteIdentical` also asserts `strictDiagnoses().isEmpty` over the restored library; the `strictDiagnoses()` helper stays | Q23's "already-covered" was wrong: byte-identity of a re-export proves record fidelity, not validity, and the import gate is the tolerant validator. With the deleted 7/8 case gone this is the only strict validation of a library built from an archive, which is the claim this feature is about |+| Q35 | 2026-08-29 | A nil `titleRuleID`/`urlRuleID` on a suggestion candidate keeps the site auto-eligible; task 8.1's original text ("nil id blocks auto-eligibility") was inverted and has been corrected | Req 6.3 says a missing id means "not fully taught, as today", and the pre-branch predicate was `titleRuleVersion == nil \|\| urlRuleVersion == nil` ⇒ eligible. The task text would have made the sweep fire only on fully-taught sites, i.e. never |+| Q36 | 2026-08-29 | `PendingPatternProvenance.patternVersion` is left in place, though after Q13 it is written at four sites and read by no production code | Inside the Non-Goals, so out of scope here; the Non-Goal's stated reason ("that is the row version") is inexact — the minted row version reaches `TitlePattern` through `ProjectionPlan.patternVersion`. Flagged for T-2282 to delete |+| Q37 | 2026-08-30 | The teaching and re-parse refusals on a non-positive row `version` (`+Contracts` ~:74-79, `+ReparseCapture` ~:567) are dropped, extending Q31; Q24's model-init guards still stand | Q24 kept them because nothing produces the state; Q31 then removed the only pass that could repair it. Tolerating it on read but hard-refusing it on teach would leave a hostname that nothing can heal and nothing can re-teach. Minting `max + 1` over a 0 still yields ≥ 1, so `TitleProjectionPlanner.validatePatternVersion`, which guards the minted version, is unaffected |++## Decision 1: "Current" tiebreak is newest `createdAt`, then lowest UUID, shared by read and repair++**Date**: 2026-08-29+**Status**: accepted++### Context++Today "current holds the greatest version" is an invariant, so a Site with two marked title patterns is diagnosed and repaired by `SiteUnionProjection.keptRule`, which keeps the marked row by owning-Site rank then lowest UUID. `Site.activePattern` is `first(where: \.isActive)` over an unordered to-many. Once the version invariant goes, two marked rows are still a valid-looking state that two concurrent teaches or an import into a non-empty library can produce, and the read-time pick must be deterministic because the rule-suggestion fingerprint keys on it.++### Decision++Current is the marked row; where more than one row is marked, the one with the newest `createdAt`, then the lowest UUID. One predicate serves every reader of the current rule and the demotion repair the site union performs.++### Rationale++The Req 4 story is "the one I most recently taught"; `createdAt` is the only column that says that. A shared predicate means the repair keeps the row the reader was already seeing, so the demotion is invisible.++### Alternatives Considered++- **Keep `keptRule`'s existing pick (owning-Site rank, then lowest UUID)**: no change to the repair — Rejected: arbitrary with respect to which teach the reader meant, and the read-time pick would have to adopt a rule that has nothing to do with recency.+- **Greatest `version`**: the current rule, made a tiebreak — Rejected: keeps the integer meaningful, which is what this spec removes.++### Consequences++**Positive:**+- One definition of current across `activePattern`, the URL-rule reader, the suggestion fingerprint and the site union.+- The double-marked repair survives the deletion of `repairsInPlace`'s version arm with its trigger intact.++**Negative:**+- `keptRule`'s behaviour changes; `SiteUnionProjectionTests` that assert the old pick are rewritten.+- `createdAt` defaults to epoch on a row that never set it, so such a row loses the tiebreak to any dated row. Acceptable: teaching always sets it.++---
diff --git a/specs/rule-citation-by-uuid/design.md b/specs/rule-citation-by-uuid/design.mdnew file mode 100644index 0000000..3e424b7--- /dev/null+++ b/specs/rule-citation-by-uuid/design.md@@ -0,0 +1,199 @@+# Design: Rule Citation by UUID++Transit: T-2281. Requirements in [requirements.md](requirements.md); decisions in [decision_log.md](decision_log.md). Paths are relative to `Packages/AsterismCore/Sources/AsterismCore/` unless prefixed; line numbers are against the branch point and will drift.++## Overview++`CitedRule` loses its `version` field; everything that existed to keep `(id, version)` pairs consistent — Site-unique version invariants, renumbering on site union and duplicate consolidation, the citation-rewrite walks, the export-time rewrite map — is deleted. The rule rows keep their `version` column as an advisory integer. The archive moves to generation 8/9 because `EntryCitations` is the archive's Entry-citation record.++## Architecture++### What changes, by layer++| Layer | Today | After |+|---|---|---|+| Citation value | `CitedRule {id, version}`; `FieldProvenance {kind, patternID, patternVersion}`; `URLRuleReference {id, version}` (throws on `version <= 0`) | `CitedRule {id}`; `FieldProvenance {kind, patternID}` (`.pattern` ⇒ `patternID != nil`); `URLRuleReference {id}`, non-throwing (Q16) |+| Blob codec | synthesized `Codable` via `JSONBlob` (plain `JSONDecoder`) | unchanged mechanism; the synthesized decoder ignores the old `version` key (Req 1.3) |+| Resolution | five sites match `id && version` | all five match `id` only, scoped to the citing record's Site/hostname (Req 2) |+| Rule-row invariants | Site-unique versions, current holds greatest — validator, archive check, `validatedRecentSiteMode`, `+Contracts`, `URLIdentityPlanner`, `RuleTally` | deleted everywhere; ownership, "at most one marked per kind" and the model-init positivity guards stay (Req 3, Q24) |+| Site union | `assignVersions`, `versionRewrites`, `repairsInPlace` = version drift ∥ flag drift, `keptRule` by owning-Site rank | version half deleted; `repairsInPlace` = flag drift; `keptRule` by Decision 1 |+| Reconcilers | `SiteReconciler.repin` rewrites citations; `DuplicateReconciler.convergeRules` aligns versions and rewrites | `repin` re-parents only; `convergeRules` = definition copy-down + `demoteWithinSites` |+| Export | rewrite map, `projected.version` on rule records; import renumbers | rule records carry the row's `version`; no map; import inserts as recorded (Req 5.3 holds because `assignVersions` is gone) |+| "Current" | `Site.activePattern = first(where: \.isActive)`; URL rule read ad hoc | two accessors carrying the Decision 1 tiebreak (Q11) |+| Suggestion ledger | `CorpusFingerprint.titleRuleVersion/urlRuleVersion` | `titleRuleID/urlRuleID: UUID?` |+| Display | `PatternRuleSummary.version`, `FieldSettlement.*(version:)`, `badge(isCurrent:version:)`, Reparse labels "Pattern vN" | version removed from all four (Q7, Q13) |+| Archive | `BackupV7*`, format 7 / schema 8, gate `"multi-site"` | `BackupV8*`, format 8 / schema 9, gate unchanged (Q9, Q14, Q19) |++### Deletion inventory++Deleted outright, with their tests:++| Symbol | File |+|---|---|+| `CitedRule.version`, `init?(id:version:)`, `EntryCitations.rewritingVersions`, `Entry.RuleCitation.version` | `EntryCitations.swift` |+| `FieldProvenance.patternVersion`; `tolerant(kind:patternID:patternVersion:)` → `tolerant(kind:patternID:)` | `ValueObjects.swift`, `EntryCitations.swift` |+| `URLRuleReference.version` and its throwing init; `URLRuleBasisEntry.version` (dead once its guard, sort key and reference use go; its three construction sites stop passing it); the Site-unique-version throw in `URLSiteEvidenceBasis.init` (`URLIdentityPlanner.swift ~:146`); `Error.ruleNotInBasis` prints the UUID only | `URLIdentityTypes.swift`, `URLIdentityPlanner.swift` |+| `SiteUnionProjection.assignVersions`, `countSurviving`, `owningRank`, `ProjectedSite.versionRewrites`, `ProjectedTitlePattern.version`, `ProjectedURLRule.version`, the version arms of `repairsInPlace`, `keptRule`'s `ordered:` parameter | `SiteUnionProjection.swift` |+| `SiteReconciler.rewriteCitations`, the `rewrites` parameter of `repin`, the version writes in `applyUnion`; `SiteReconciliationOutcome.renumberedRules` renamed `reparentedRules` (its doc says "owner or version"; only owner remains) | `SiteReconciler.swift` |+| `DuplicateReconciler.alignVersions`, `patternVersionIsFree`, `urlRuleVersionIsFree`, `rewriteCitations`, the `rewrites` map in `convergeRules`, `rewrittenVersion` in both group results; `DuplicateReconciliationOutcome.rewrittenCitations` and its arms in `isEmpty`/`wroteNothing` | `DuplicateReconciler.swift` |+| `BackupArchiveProjection.rewrites` and its merge/apply; rule-record mappers read `pattern.version`/`rule.version` | `BackupArchiveProjection.swift` |+| `RuleTally.versions`, `greatest`, `markedVersion`, `markedIsNotGreatest`, `count`; `record(version:marked:)` → `record(marked:)`; `collides` becomes `markedCount > 1` | `LibraryRepository.swift` |+| Validator: the Site-unique clause of the fused ownership guards (`~:657-692`; the ownership clause stays, and the two message strings "… require positive Site-unique versions and ownership" become "… require ownership"), current-holds-greatest (`~:723-732`; the at-most-one-current guard at `~:719-722` stays), `claimVersion`, `groupVersion`, `resolves(citedPattern:version:)`/`citedPattern(id:version:)` become id-only, `reference(from:)`'s `version > 0` guard, `patternVersion` arguments at `~:1139,1144`. *As built: the read-side `version > 0` clauses went too (Q31), and `reference(from:)` was inlined once it became an identity conversion* | `LibraryValidator.swift` |+| Archive check: version sets and greatest clause in `validateSiteTuple`, `rule.version == cited.version` in `resolvesSameSite`, `namePattern.version == nameTitle.version` | `BackupArchiveReferenceChecks.swift` |+| `validatedRecentSiteMode`'s Site-unique clause (`~:307`; *as built its `version > 0` clause went too, Q31*); `+Contracts.swift` Site-unique refusal (`~:117-123`; *as built the `version > 0` refusals at `~:74-79` and `+ReparseCapture.swift ~:568` went too, Q37 — nothing repairs that state any more, so a hard refusal in teaching would block the hostname forever*) | `LibraryRepository+RecentPresentation.swift`, `+Contracts.swift` |+| `historicalPatternVersion` on `projectReplacement`, `TeachingContractRequest.replacement`, `computeHistoricalTargeting`; call sites `+Contracts.swift ~:212,235` | `LibraryProviding.swift`, `ProjectionContract.swift`, `LibraryRepository+OutcomeComputation.swift`, `TitleProjectionPlanner.swift`, `LibraryRepository+Contracts.swift` |+| `ComposedTeachingCommitOutcome.committed`'s `titleRuleVersion`/`urlRuleVersion`; `TeachingCommitOutcome.committed`'s `patternVersion` (Q15) | `LibraryRepository+ComposedTeaching.swift`, `ProjectionContract.swift`, `LibraryRepository+Contracts.swift ~:410` |+| `WorkURLIdentityMint.ruleVersion` (written at six sites, read nowhere) | `Models.swift ~:1055`, `+ReparseCapture.swift`, `+WorkMerge.swift`, `+ComposedTeaching.swift` |+| `applyProjection(patternVersion:)` and `snapshot`'s `CitedRule(id:version:)`/`tolerant(patternVersion:)` calls; `CitedRule(id:version:)` at `+ComposedTeaching.swift ~:716,717,814,821`; `M4PerformanceFixture.swift ~:318` and its `existingVersions` | `LibraryRepository.swift ~:1378,1809`, `LibraryRepository+ComposedTeaching.swift`, `M4PerformanceFixture.swift` |+| `PatternRuleSummary.version`, `FieldSettlement.patternSettled/patternUnsettled` `version:` (`+EntryDetail.swift ~:190,313,317`), `badge(isCurrent:version:)` → `badge(isCurrent:)` | `RecentPresentation.swift`, `LibraryRepository+EntryDetail.swift`, `Asterism/Asterism/ViewModels/ConflictRecentPresentation.swift` |+| `CorpusFingerprint.titleRuleVersion/urlRuleVersion`, `RuleSuggestionCandidate` twins → `titleRuleID/urlRuleID` | `AsterismIntelligence/RuleSuggestionTypes.swift`, `LibraryRepository+RuleSuggestion.swift`, `Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift` |+| Reparse labels "Pattern v… (preserved)" / "Pending pattern v…" → "Pattern (preserved)" / "Pending pattern" | `Asterism/Asterism/Views/ReparseView.swift ~:257,262` |++Doc comments that state the deleted invariant as live rationale are rewritten with their code — at least `ComposedTeachingProjection.swift ~:68-75,551-554` (the reason `ComposedURLRuleState.unreadable` carries a version), `Models.swift ~:271`, `LibraryRepository.swift ~:652`, `+ConfirmImport.swift ~:356-360`, `SiteUnionProjection.swift ~:118-125`.++What stays, deliberately: `TitlePattern.version`/`URLRulePattern.version`, their `version > 0` init guards (`Models.swift ~:513,638`, Q24) and their minting (`computedPatternVersion = max + 1`, `ComposedTeachingProjection`'s `.unchanged/.available/.overflow`, `validatePatternVersion`), `PendingPatternProvenance.patternVersion`, `GroupOrdering.representativeComponents`' `.int(version)` key (Q6), `GroupOrdering.isConvergedGroup`, `demoteWithinSites`, `RuleMembership.reduced`, `URLIdentityPlanner.swift ~:149`'s "at most one current" throw, and every `activeCount != 1` refusal (Q12).++### Current-rule accessors (Req 4, Q11)++```swift+// RuleSelection.swift (new, AsterismCore)+protocol MarkableRule { var id: UUID { get }; var createdAt: Date { get }; var isMarked: Bool { get } }+extension TitlePattern: MarkableRule { var isMarked: Bool { isActive } }+extension URLRulePattern: MarkableRule { var isMarked: Bool { isCurrent } }+extension URLRuleBasisEntry: MarkableRule { var isMarked: Bool { isCurrent } } // value type; no AnyObject constraint++enum RuleSelection {+ /// The marked row with the greatest `createdAt`; ties broken by lowest `id.uuidString`. Nil when none is marked.+ static func current<Rule: MarkableRule>(_ rows: [Rule]) -> Rule?+}++// Models.swift+extension Site {+ public var activePattern: TitlePattern? { RuleSelection.current(patternValues) }+ public var currentURLRule: URLRulePattern? { RuleSelection.current(urlRuleValues) }+}+```++`SiteUnionProjection.keptRule` is re-constrained from `Rule: AnyObject` to `Rule: MarkableRule` and becomes `RuleSelection.current(rows)` with no closures. (`DuplicateScan.RuleRow` is an unrelated existing struct; the protocol name avoids it.) `RuleSelection.current` and `GroupOrdering.representativePattern` are different picks — the representative orders `createdAt` ascending — and stay different: the representative answers "which row of a same-UUID group stands for it", the selection answers "which of several marked rows is current" (Req 2.6 vs 4.1).++Flag-read sites and what happens to each:++| Site | Action |+|---|---|+| `+Capture.swift:209,229` | accessors |+| `+ReparseCapture.swift:355,361,619,632` | accessors |+| `+ReparseCapture.swift:26,99,558` (`filter { $0.isActive }` + `count == 1` guard + `[0]`) | unchanged — refusal sites (Q12) |+| `+ComposedTeaching.swift:130,148,319,322,506,522` | accessors; `:163,174` demote-all loops unchanged |+| `+Contracts.swift:70,93,301` | count/demote — unchanged |+| `+EntryDetail.swift:78,266` | `activePattern`, `currentURLRule != nil` |++*As built, the Entry-detail historical list excludes the picked row by identity (`!==`) rather than filtering on `!isActive` (Q30), and `activePattern` is read once per detail load and threaded through.*+| `+RuleSuggestion.swift:78-79` | accessors, reading `.id` |+| `+URLIdentity.swift:25` | `URLRuleBasisEntry` conforms to `MarkableRule`, so the pick goes through `RuleSelection.current`; the throw when none is current stays. The basis already refuses two current rules, so the tiebreak never fires here |+| `+WorkMerge.swift:595` | count — unchanged |+| `+WorkMerge.swift:679-685` | the `rules.first { $0.id == id }` existence lookup **stays** (its nil result derives `.legacyUnverified`); only the `version:` argument to `URLRuleReference` goes |+| `+Articles.swift:84,91`, `IdentityResolution.swift:179,194`, `URLIdentityPlanner.swift:149` | count/demote — unchanged |++### Orderings (Req 4.4, Q10)++`(createdAt, id.uuidString)` ascending replaces `(version, id.uuidString)` at `+Sites.swift:47`, `+EntryDetail.swift:83`, `+Contracts.swift:86` (`PatternBasisEntry` already carries `createdAt`; `ProjectionContract.swift:75` doc updated) and `URLIdentityPlanner.swift:165`. `URLRuleBasisEntry` gains `createdAt: Date`: from the row at `+URLIdentity.swift:63` and `+WorkMerge.swift:609`; the prospective entry `ComposedTeachingProjection.swift:570` builds for a not-yet-inserted rule carries `.distantFuture` (it is the newest rule by construction, and the value only feeds a sort inside the basis). `TeachingBasis`/`ReparseBasis` are `Equatable` and compared by the refresh gate; both sides are sorted by the same key, so equality is unaffected.++### Site union and reconcile (Req 3.4–3.7)++- `hasWork = distinguishable && (rows.count > 1 || repairsInPlace)`, `repairsInPlace` = flag drift only. `consolidates` keeps its meaning. Both Req 3.7 paths trigger: two Sites via `rows.count > 1`; one double-marked Site via flag drift.+- `keptActive`/`keptCurrent` = `RuleSelection.current` over the merged rows. `ProjectedTitlePattern {pattern, isActive}` and `ProjectedURLRule {rule, isCurrent}` keep their flags; `SiteReconciler.applyUnion` applies the demotions as today, minus the version writes.+- `reconcileWorkLists` puts a hostname on `colliding` when `RuleTally.markedCount > 1` for either kind; `RuleTally` shrinks to `{count, markedCount}`.+- Import into a non-empty library (`+ConfirmImport.swift ~:356`) keeps its post-insert union step, now repairing marked-flag collisions only; the archive's `createdAt` is re-materialised as today, so the tiebreak has its input.++### Resolution (Req 2)++Five sites resolve a cited UUID; the store-side three share one helper, the archive and `WorkMerge` sites are single dictionary lookups:++```swift+/// Rows of `id` in the citing Site; the group representative when several (Q6).+static func citedRow<Rule: MarkableRule>(_ id: UUID, in rows: [Rule], representative: ([Rule]) -> Rule?) -> Rule?+```++- `LibraryValidator.citedRule(_:_:)` / `citedPattern(id:_:)` — `CitationContext` unchanged (`toleratesUnresolved`, Req 2.4).+- `BackupArchiveReferenceChecks.resolvesSameSite` — `rulesByID[cited.id]?.siteHostname == entry.hostname` (one record per UUID in an archive; no representative step).+- `LibraryRepository+RecentPresentation.replayCitedPattern` (`~:377`).+- `TitleProjectionPlanner.computeHistoricalTargeting` — `provenance.patternID == historicalPatternID`.+- `LibraryRepository+WorkMerge.swift ~:680` — existence lookup as above.++`validateV2`/`validateV3` keep whole-struct equality on `URLRuleReference`, which is now UUID equality (Req 2.5). `reference(from:)` becomes `cited.map { URLRuleReference(id: $0.id) }`; a citation is present iff the slot is non-nil.++### Archive generation 8/9 (Req 5)++Following the 7/8 precedent (`21ef6ed`, 79 files): the `BackupV7*` type set is replaced by `BackupV8*` — `BackupV7Types.swift`, `BackupV7Codec.swift`, `BackupV7Exporter.swift` renamed, and every reference updated in `BackupArchiveProjection.swift`, `BackupImporter.swift`, `BackupArchiveReferenceChecks.swift` (including the `formatLabel: "V7"` refusal strings), `ArchiveRecordBuilders.swift`, `BackupImportCharacters.swift`, `BackupImportWorkTypes.swift`, `BackupGroupProjection.swift`, `LibraryRepository+ConfirmImport.swift`, `Asterism/Asterism/ViewModels/SettingsBackupModel.swift`, `Asterism/Asterism/ViewModels/AppLibraryModel.swift`, seven comment-only files, and the tests `BackupV7Fixtures.swift` → `BackupV8Fixtures.swift`, `BackupV7ArchiveTests.swift` → `BackupV8ArchiveTests.swift`. `formatVersion = 8`, `schemaVersion = 9`; `BackupImporter.supportedVersions` follows. The codec keeps stamping `gate = "multi-site"` (Q19). `BackupV8Entry.citations: EntryCitations` picks up the field drop; `BackupV8TitlePattern`/`BackupV8URLRule` keep `version` and `createdAt`. The stray-`version` refusal (Req 5.4) is the existing re-encode checksum; no new check. `FrozenLibraryPathTests` pins the new names. The format/schema bump and the `CitedRule.version` drop land in one commit: an intermediate state would let an 8/9 archive carry versions or a 7/8 archive omit them. `docs/agent-notes/schema-migration.md` (`~:154-171,206`, the note that documents the archive generation) is updated; `rule-wire-format.md`'s stale `BackupV4*`/`BackupV6Codec` names are corrected in passing.++Owner steps for the irreversible parts (post-upgrade archive export, single-device note) are in [prerequisites.md](prerequisites.md).++Golden fixture: `Fixtures/backup-7-8-golden.json` is deleted and `backup-8-9-golden.json` recorded. Recording procedure: `BackupGoldenExportTests`' byte test gains a record mode — when `ASTERISM_RECORD_GOLDEN=1` is set it writes the encoded bytes to the fixture path and fails with "recorded; re-run without the flag", otherwise it compares as today. `BackupGoldenExportTests ~:131` ("a 7/8 archive recorded before V9 restores into a V9 library", drop-superseded-columns Q36) is deleted: its subject is bytes a V8 build produced, which Req 5.2 makes unreadable; re-recording would not preserve what it proved.++### Old-shape blobs (Req 1.3, Q3, Q17)++No code: the synthesized decoder ignores the `version` key. The proof is a JSON literal captured from the current encoder before the change (every slot populated, versions present), decoded and compared against the same citations built version-free. `V8RecordedStoreFixture` encodes through the live type and is not that fixture; it is left as is.++## Components and Interfaces++```swift+public struct CitedRule: Codable, Hashable, Sendable { public var id: UUID }++public struct FieldProvenance: Codable, Equatable, Sendable {+ public let kind: FieldProvenanceKind+ public let patternID: UUID?+ public init(kind:patternID:) throws // .pattern ⇒ patternID != nil; else patternID == nil+}++public struct URLRuleReference: Codable, Equatable, Hashable, Sendable { public let id: UUID }++public enum FieldSettlement {+ case patternSettled(patternID: UUID)+ case patternUnsettled(patternID: UUID, reason: String)+ case manualProtected(reason: String), unsettled(reason: String)+}++public struct PatternRuleSummary { id, form, isActive, plainLanguageRule }++public struct CorpusFingerprint { siteMode, entryCount, latestCaptureAt, titleRuleID: UUID?, urlRuleID: UUID? }++// LibraryProviding+func projectReplacement(hostname:patternDefinition:boundaryEntryID:historicalPatternID:) async throws -> TeachingContract++// Outcomes+ComposedTeachingCommitOutcome.committed(titleRuleID: UUID, urlRuleID: UUID?)+TeachingCommitOutcome.committed(patternID: UUID)+```++`EntryCitations.ruleCitations` keeps its seven rows and labels; `Entry.RuleCitation` keeps `id`. `RuleSuggestionCoordinator.isAutoEligible` keeps its nil test on the two fields (Req 6.3).++## Error Handling++- `URLRuleReference` and `URLRuleBasisEntry` no longer throw on version.+- `LibraryValidationError` reason strings built by `unreadableRule`/`unreadablePattern` keep mentioning the row version — advisory context.+- `buildFieldSettlement`'s "missing identity (corrupt)" arm triggers only on a nil `patternID`.++## Testing Strategy++Deleted with their subjects: `EntryCitationsTests` version-rewrite cases; `DuplicateReconcilerTests:66,:88,:143,:171,:231`; `SiteUnionProjectionTests:173,:196`; `SiteReconcilerTests:248`; `ReconcileAfterSyncTests:84` and its `:328-345` invariant helper; `BackupGroupProjectionTests:569`; `MultiSiteReviewFixTests:179-195`; `ConvergedRuleGroupValidationTests:111,:165`; `CitationBlobRefreshTests:99-106`; `CitationResolutionParityTests:121,:163`; `LibraryValidatorTests:133`; `BackupGoldenExportTests:131`. About 49 test files reference a deleted symbol; the rest are compile-fix only.++| Req | Test |+|---|---|+| 1.2, 1.3 | `EntryCitationsTests`: encoded JSON object has no `version` key at any slot; the Q17 literal decodes equal to the version-free value |+| 1.4, 3.4, 3.5 | `SiteReconcilerTests:120`, `DuplicateReconcilerTests`: citations resolve after union/consolidation with `citationsData` bytes unchanged |+| 2.1–2.3, 2.6 | `CitationResolutionParityTests`: a superseded rule resolves by id; a same-UUID group resolves to the representative; Recent renders a right-id citation regardless of row version; `WorkMerge` snapshot still derives `.legacyUnverified` for an unknown id. Entry detail's field settlement derives from provenance with no store lookup, so 2.3's Entry-detail half is the `FieldSettlement` type change itself |+| 2.4 | existing `LibraryValidatorNilSiteToleranceTests` (unchanged) |+| 2.5 | `LibraryValidatorTests`: v2 identity whose work/sequence cite the same UUID validates |+| 3.1, 3.3 | `ConvergedRuleGroupValidationTests`, `LibraryValidatorTests`: duplicate and non-greatest versions validate; two marked rows still diagnosed; ownership still diagnosed; `validatedRecentSiteMode` returns the mode for duplicate versions; `TitlePattern(version: 0)` still throws |+| 3.2 | `BackupV8ArchiveTests`: an archive with duplicate and non-greatest-current rule versions imports |+| 3.6, 3.7 | `SiteUnionProjectionTests:218` and new: two marked rows → Decision 1 row kept, other demoted, `hasWork` true; single marked row → `hasWork` false; `reconcileWorkLists` names a double-marked hostname and not a duplicate-version one; `ReteachDiagnosisComparisonTests:89` re-seeded with the membership `workURLString` mutation its sibling at `:179` uses (the composed commit compares the whole quarantine entry, so it still rolls back) |+| 3.8, 5.1, 5.3 | `BackupGroupProjectionTests:522` asserts the archived record's `version` equals the kept row's; `BackupGroupRoundTripTests` compares decoded citations and rule `(id, isMarked, createdAt, version)` after import into an empty library |+| 4.1, 4.2 | New `RuleSelectionTests`: tiebreak over marked rows including equal `createdAt`; `Site` accessor tests with two marked rows; a source scan (in `RuleSelectionTests`, not `FrozenLibraryPathTests`) over whole-file text that no non-test source outside `RuleSelection.swift` contains `isActive` or `isCurrent` inside a `first(where:` or trailing-closure `first {` call in any spelling, including `Asterism/AsterismShareExtension` (Q29); its anti-vacuity guard proves the regex matches six fixture strings and not the counting forms `filter`/`count(where:)`/`contains(where:)`. `Models.swift` needs no carve-out since its accessors call `RuleSelection.current` |+| 4.3 | existing `RepositoryTeachingTests` replacement case (marks new, unmarks old) |+| 4.4 | `+Sites`/`+EntryDetail`/basis ordering tests: `(createdAt, id)` |+| 5.2, 5.4, 5.5 | `BackupV8ArchiveTests`: 7/8 refused naming the pair; an 8/9 archive built from a JSON literal carrying a citation `version`, with its checksum computed over that literal (the `BackupV8Fixtures` literal-archive helper), refused as `checksumMismatch`; `BackupGoldenExportTests` on the 8/9 golden `exportImportExportIsByteIdentical` also asserts `strictDiagnoses().isEmpty` over the restored library (Q34)|+| 6.1–6.3 | `RuleSuggestionLedgerTests:608`: new rule UUID invalidates, same UUID does not; coordinator `isAutoEligible` false when either id is nil |+| 7.1 | `FrozenLibraryPathTests` symbol pins; compile is the check for removed fields |+| 7.3 | `make test-performance-m4`, two runs, recorded in `verification-run.md` against `drop-superseded-columns/verification-run.md`. Band at risk: the Req 5.4 capture-projection arms — `RuleSelection.current` replaces a short-circuiting `first(where:)` on the capture path (`+Capture.swift:209,229`) with filter + min over a handful of rows |++Property-based testing is not used: the round-trip properties (1.2/1.3, 5.3) are over a small fixed set of citation shapes that example tests enumerate directly.
diff --git a/specs/rule-citation-by-uuid/implementation.md b/specs/rule-citation-by-uuid/implementation.mdnew file mode 100644index 0000000..6ecdb0a--- /dev/null+++ b/specs/rule-citation-by-uuid/implementation.md@@ -0,0 +1,281 @@+# Implementation: Rule Citation by UUID (T-2281)++27 commits on `T-2281/rule-citation-by-uuid`, plus a final round of small+refactors still in the working tree (a shared `RuleSelection.precedes`+comparator replacing four hand-written `(createdAt, id)` sorts, `activePattern`+hoisted once per Entry-detail load, `URLRuleBasisEntry.reference` made computed,+a `"V8"` format-label constant, and the Q37 drop of the teaching and re-parse+refusals on a non-positive row `version`). Spec in+[requirements.md](requirements.md) / [design.md](design.md); decisions in+[decision_log.md](decision_log.md) (Decision 1 and Q1–Q37); evidence in+[verification-run.md](verification-run.md).++## Beginner Level++### What Changed++When Asterism learns how to read a website — where the chapter number sits in a+title, which part of a URL identifies a book — it stores that knowledge as a+**rule**. Every entry it captures records which rule it used. That record is a+**citation**, exactly like a footnote.++Until this branch a citation was written as *"rule ABC, revision 4"*. Now it is+written as *"rule ABC"*. The revision number is gone from the footnote.++That sounds small, and the code deleted is not. Pinning a revision number meant+the app had to guarantee those numbers stayed unique and in order within one+site — and a whole apparatus existed to keep that promise: renumbering rules+when two site records merged, walking every entry afterwards to rewrite its+footnotes, and refusing to open a library where two numbers had collided. The+branch deletes that apparatus and the promise it kept.++### Why It Matters++Teaching the same site twice — on two devices, or by importing a backup into a+library that already knows the site — used to produce two rules claiming the+same number. That is not damage; it is what happens when the same thing is+learnt twice. But the old invariant called it damage, quarantined the hostname+and sent it for repair.++A UUID is already unique forever. Nothing can collide, so nothing needs+repairing, so the repair machinery has no reason to exist. Provenance now+survives a site merge and a backup round trip without anything being rewritten:+the footnote said "rule ABC" before and says "rule ABC" after.++### Key Concepts++- **Citation** — an entry's footnote naming the rule that produced one of its+ fields. Seven per entry (identity, URL-work, chapter sequence, work URL, work+ assignment, chapter title, and the name-title half of a composed identity).+ They live together in one encoded blob on the entry row.+- **Rule version** — the counter on the rule row itself (`version`). It stays,+ still minted as "one more than the highest", but nothing now depends on it+ being unique or ordered. It is advisory (Q4).+- **Marked / current rule** — a site holds many rules, one per teaching. The one+ the app applies carries a marked flag (`isActive` for title rules,+ `isCurrent` for URL rules). Where two rows are marked, "current" is now the+ most recently created, tie broken by the lower UUID (Decision 1).+- **Archive generation** — the numbered pair a backup file stamps itself with:+ format 8 over schema 9 here, up from 7/8. Only the current generation is+ readable, so a backup from before the upgrade will not import afterwards.+- **Reconcile pass** — the housekeeping sweep that runs after a sync, merging+ duplicate site rows and fixing what CloudKit's partial arrivals leave behind.+ It still demotes a second marked rule; it no longer renumbers anything.++## Intermediate Level++### Changes Overview++`Packages/AsterismCore/Sources/AsterismCore/`, unless noted.++| Area | Files | Change |+|---|---|---|+| Citation values | `EntryCitations.swift`, `ValueObjects.swift`, `URLIdentityTypes.swift` | `CitedRule`, `FieldProvenance` and `URLRuleReference` become UUID-only; `rewritingVersions` deleted; `URLRuleReference.init` stops throwing |+| Current-rule pick | `RuleSelection.swift` (new), `Models.swift` | `MarkableRule` protocol, `RuleSelection.current` / `precedes` / `citedRow`; `Site.activePattern` and new `Site.currentURLRule` |+| Resolution | `LibraryValidator.swift`, `BackupArchiveReferenceChecks.swift`, `+RecentPresentation.swift`, `TitleProjectionPlanner.swift`, `+WorkMerge.swift` | five sites match on id alone, scoped to the citing record's Site/hostname |+| Invariants | `LibraryValidator.swift`, `LibraryRepository.swift` (`RuleTally`), `+Contracts.swift`, `URLIdentityPlanner.swift` | Site-unique versions, current-holds-greatest, and the read-side positivity clauses deleted |+| Repair passes | `SiteUnionProjection.swift`, `SiteReconciler.swift`, `DuplicateReconciler.swift` | renumbering, version rewrite maps and both citation walks deleted; flag drift is the only in-place repair left |+| Archive | `BackupV8{Types,Codec,Exporter}.swift` (renamed from `V7`), `BackupArchiveProjection.swift`, `BackupImporter.swift` | format 8 / schema 9; rule records carry the row's stored version; export rewrite map gone |+| Display / app | `RecentPresentation.swift`, `+EntryDetail.swift`, `Asterism/Asterism/Views/ReparseView.swift`, `ReparseViewModel.swift`, `ConflictRecentPresentation.swift` | version dropped from the rule summary, the field settlement, the badge and the two reparse labels |+| Suggestions | `AsterismIntelligence/RuleSuggestionTypes.swift`, `+RuleSuggestion.swift` | `CorpusFingerprint` and the candidate key on `titleRuleID` / `urlRuleID` |++### Implementation Approach++**A value-type codec change, not a schema stage.** Citations are one `Codable`+value serialised into `Entry.citationsData` and into the archive, so dropping a+field is a codec change. Nothing in the SwiftData schema moves. Existing blobs+keep their `version` keys and the synthesized decoder ignores them (Q3); an+entry that is never edited keeps its old bytes indefinitely.++**`MarkableRule` + two accessors.** A protocol over the three row shapes+(`TitlePattern`, `URLRulePattern`, the value-type `URLRuleBasisEntry`, and now+`PatternBasisEntry`) lets one generic `RuleSelection.current` serve both stored+kinds. `Site.activePattern` and `Site.currentURLRule` are the only places a+marked flag is read to *pick* a rule; counting and demoting reads stay as they+were (Q12).++**A source-scan test as the architectural check.** Req 4.2 is only meaningful if+no reader bypasses the accessors, and that is not a runtime property.+`RuleSelectionTests` scans every production Swift file's whole text with a regex+for a `first(where:)`/`first {` whose predicate reads `isActive`/`isCurrent`,+and fails listing the offenders. Whole-file rather than per-line, because the+repo wraps calls across lines. It carries an anti-vacuity block: six spellings+that must match, three counting forms that must not, and an assertion that each+scan root still enumerates Swift files.++**Tolerant decode with a captured literal.** The old-shape fixture in+`EntryCitationsTests` is a JSON literal copied out of the *previous* encoder+before the type changed (Q17). A fixture that encodes through the live type+would emit version-free bytes and prove nothing.++**Golden record mode.** `BackupGoldenExportTests` gains+`ASTERISM_RECORD_GOLDEN=1`: it writes the fixture and then *fails* with "re-run+without the flag" (Q22). Recording is never mistaken for passing, and the+recorded bytes come from the same code path the comparison reads.++### Trade-offs++- **No transitional encoding and no rollout gate** (Q8). A device on the+ previous build cannot decode a version-free blob, would quarantine the+ hostname and overwrite the blob with defaults on its next write. Accepted+ because one device is in use; recorded so a second is only added on this build.+- **Old blobs are never rewritten** (Q3). Cheap, but it means the store holds+ two encodings indefinitely and the decoder must stay tolerant forever.+- **The rule rows keep `version`** (Q4). Dropping the column is a schema stage+ and out of scope, so an advisory integer survives — still minted, still+ overflow-refused, read by nothing that matters.+- **`createdAt` as the tiebreak** loses to a row that never set it (epoch), and+ a later-synced row with an earlier date can reorder a list. Teaching always+ sets it, so this only bites migrated or hand-built rows.++## Expert Level++### Technical Deep Dive++**Tiebreak semantics.** `RuleSelection.current` is `rows.lazy.filter(\.isMarked)+.min { … }` with the comparator reading `precedes` from the recent end: newer+`createdAt` wins, and among equal dates the *lower* `uuidString` wins — the same+total order `precedes` defines for listings, read backwards, so a list and the+row it highlights cannot disagree. On a **full tie** (same `createdAt` *and*+same `uuidString`, which only an unconverged same-UUID group produces) the+returned *id* is stable but which object comes back follows array order; the doc+comment states this rather than papering over it.++**Same-UUID groups.** `RuleSelection.citedRow` walks the citing Site's rows,+collects every row carrying the cited id, and hands a group of more than one to+`GroupOrdering.representativePattern` / `representativeURLRule` (Req 2.6, Q6).+The single-match path allocates nothing and never calls the closure. Delegating+rather than deciding is deliberate: same-UUID groups are T-2282's subject, and+that ordering still carries `version` as its third key — harmless while the+validator requires such groups to be converged.++**Why no schema stage.** `EntryCitations` is a `Codable` blob in an opaque+column and the archive's own Entry-citation record, so both the store side and+the wire side move with the type. The SwiftData entity graph is untouched:+`retire-migration-chain`'s baseline file dropped one key by hand rather than+regenerating (Q28), which is the whole store-side footprint.++**Checksum-based refusal of a stray `version`.** Req 5.4 needed no new check.+`BackupV8Codec.decode` re-encodes the decoded payload with the canonical encoder+and compares the SHA-256 against the checksum taken over the bytes as they+arrived. A field the codec does not re-encode therefore fails as+`checksumMismatch` — an 8/9 file whose citation carries a `version` key cannot+import with the key silently dropped.++**What the reconcilers still do.** `SiteReconciler.repin` points a hostname's+records at the survivor and nothing else; `applyUnion` writes owner and mark, no+versions; `SiteReconciliationOutcome.renumberedRules` is renamed+`reparentedRules`. `DuplicateReconciler.convergeRules` copies the+representative's definition down and calls `demoteWithinSites`; `alignVersions`,+`patternVersionIsFree`, `urlRuleVersionIsFree` and the Entry-walk+`rewriteCitations` are gone, along with `rewrittenCitations` on the outcome.+`SiteUnionProjection.repairsInPlace` is flag drift alone, and `keptRule` is+`RuleSelection.current` — so `hasWork` still fires on a single double-marked+Site (Req 3.7) and on any multi-row hostname.++**The positivity story (Q31 → Q37).** Q24 kept `version > 0` everywhere on the+grounds that nothing produces a non-positive row. Task 5 then deleted the tally+and union arms that *repaired* one. That inverted the calculus: a `version: 0`+row arriving over CloudKit would have quarantined its hostname permanently. Q31+dropped the read-side checks (`LibraryValidator`,+`BackupArchiveReferenceChecks`, `validatedRecentSiteMode`); Q37 extended it to+the teaching and re-parse refusals, because tolerating a state on read while+hard-refusing it on teach leaves a hostname nothing can heal and nothing can+re-teach. The model-init guards on `TitlePattern`/`URLRulePattern` stand, so+`ArchiveRecordBuilders` still cannot materialise such a row from an archive.++### Architecture Impact++One predicate now defines "current" for every reader *and* for the repair, which+is what makes the demotion invisible: `SiteUnionProjection` keeps the row the+reader was already seeing. Resolution collapses from five hand-written+`id && version` matches to one helper plus two dictionary lookups. The+validator's per-Site version sets and the five-field `RuleTally` are gone, which+`verification-run.md` §5.2 reports as a 2–4% fall across nine store-walk labels+without claiming credit for it.++### Potential Issues++- **A lagging device (Q8).** No transitional encoding exists. A pre-branch build+ opening this library fails to decode citations, quarantines hostnames, and+ overwrites blobs with defaults through `editCitations`. The blob is+ CloudKit-mirrored, so reinstalling the old build does not undo it.+ `prerequisites.md` carries the owner's ordering.+- **`PendingPatternProvenance.patternVersion` (Q36)** is now written at four+ sites and read by no production code. Inside the Non-Goals, so left; flagged+ for T-2282. The Non-Goal's stated reason is itself inexact — the minted row+ version reaches `TitlePattern` through `ProjectionPlan.patternVersion`, not+ through this field.+- **Two demotion picks (Q32).** `SiteUnionProjection.keptRule` keeps the newest+ `createdAt`; `DuplicateReconciler.demoteWithinSites` keeps the first in+ `GroupOrdering` representative order. For a same-UUID double-marked group they+ can disagree. They cannot oscillate — `RuleSelection.current` only demotes+ among marked rows — and same-UUID groups are the duplicate reconciler's+ domain, so it is recorded rather than resolved.+- **Mid-branch archive state (Q27).** Phase 1 re-recorded `backup-7-8-golden.json`+ without version keys, which is a 7/8 archive the design forbids. Tolerable+ only because the branch is squashed and no mid-branch build is installed; 7.2+ deleted that golden and recorded `backup-8-9-golden.json`.++## Completeness Assessment++**Fully implemented**++- Req 1.1–1.4 — citations are UUID-only, encoded version-free at every depth,+ old-shape bytes decode equal via the Q17 literal, and no citation is rewritten+ by a merge or a consolidation.+- Req 2.1–2.6 — all five resolution sites match by id within the citing+ record's Site/hostname; same-UUID groups go through `GroupOrdering`; the+ no-Site tolerance is untouched; `validateV2`/`validateV3` compare whole+ `URLRuleReference` values, which is now UUID equality.+- Req 3.1–3.8 — every version invariant deleted from the validator, the archive+ check, `validatedRecentSiteMode`, the teaching basis and `RuleTally`;+ ownership, at-most-one-marked and the same-UUID convergence checks retained;+ merges re-parent, consolidation converges definitions only, the exporter+ writes stored versions.+- Req 4.1–4.4 — `RuleSelection.current` and the two accessors, pinned by the+ source scan; the four orderings are `(createdAt, id)`.+- Req 5.1–5.5 — 8/9 with a `BackupV8*` type set, exact-pair import gate,+ checksum refusal of a stray `version`, round-trip of+ `(id, isMarked, createdAt, version)`, and the re-recorded golden.+- Req 6.1–6.3 — the fingerprint and candidate key on rule UUIDs; auto-eligibility+ unchanged for a partially taught site.+- Req 7.1–7.3 — grep gate clean, gates green with no new warnings, and two+ `make test-performance-m4` runs with the same eight known issues, all inside+ their ceilings.++**Partially implemented**++- Req 7.1's clause "no type outside tests SHALL carry a citation version" holds,+ but the neighbouring `patternVersion` surface does not shrink to zero: 29 live+ hits remain, all the *row* version Q4 keeps. This matches Q4 and Q36 rather+ than diverging from Req 7.1, which is about citations.++**Missing**++- Nothing from the requirements. Two items are deliberately deferred and+ recorded: dropping the rule rows' `version` column (a Non-Goal, needs a schema+ stage) and deleting `PendingPatternProvenance.patternVersion` (Q36, T-2282).++**Requirements that diverged from the design, or that I could not explain+cleanly**++- **Req 3.1 / 3.2, positivity.** The design's deletion inventory kept+ `version > 0` on the read side; as built it went. Recorded as Q31, extended to+ the teaching and re-parse refusals as Q37. Both are in the decision log; the+ design text carries the *as built* annotations.+- **Req 2.1 / 2.2, phasing.** Task 1.2 asked for temporary `id && version`+ resolution until task 4; once the types were `{id}` that was unsatisfiable and+ resolution went id-only from phase 1. Recorded as Q25.+- **Req 5.5, the intermediate golden.** The 7/8 golden was re-recorded in+ phase 1 with version-free citations — the exact archive state the design+ forbids — because the new encoder could not reproduce the old bytes. Recorded+ as Q27, with the squash and the single-device premise as the justification.+- **Req 6.3, inverted task text.** Task 8.1 said a nil rule id should *block*+ auto-eligibility, the opposite of the requirement and of the pre-branch+ predicate. Recorded and corrected as Q35.+- **Req 4.2, Entry-detail history.** The design filtered history on `!isActive`;+ as built it excludes the row the accessor picked, by object identity (`!==`),+ so rows of one same-UUID group are not all hidden. Recorded as Q30.++No requirement was left unexplained, and every divergence found has a Q entry.
diff --git a/specs/rule-citation-by-uuid/prerequisites.md b/specs/rule-citation-by-uuid/prerequisites.mdnew file mode 100644index 0000000..ecbea9b--- /dev/null+++ b/specs/rule-citation-by-uuid/prerequisites.md@@ -0,0 +1,19 @@+# Prerequisites for Rule Citation by UUID++These steps are the owner's. Every device install is gated by `CLAUDE.md`'s device-run rule: approval at the moment of running, every time.++## What is irreversible++- The new build writes `Entry.citationsData` blobs without a per-citation `version`. The previous build cannot decode them (`CitedRule.version` is a required key there), diagnoses the hostname as unreadable, and its next write to such an Entry overwrites the blob with defaults. The blob is CloudKit-mirrored, so this is not undone by reinstalling the old build.+- The archive moves to 8/9 and only 8/9 is accepted. A 7/8 archive taken **before** the upgrade is refused by the new build; an 8/9 archive is refused by the old one. The restorable archive is therefore one exported **after** upgrading (Q8).++## Before the first install+- [ ] Confirm one device is in use (Q8). If a second device is added later, it must run this build or later before it opens the library.+- [ ] Export a 7/8 archive from the current build anyway. It is the rollback for the *old* build only: delete the app, install the pre-feature build, import that archive. It cannot be imported into the new build.++## Install+- [ ] Approved device check: install `Personal` over the real library, open it, confirm the library reads intact and the diagnosis shows no quarantined hostnames.+- [ ] Export an 8/9 archive immediately after. This is the first archive the new build can restore from.++## Before Release+- [ ] `make test-performance-m4` (host-only, safe, ~21 min) run twice and its numbers recorded in [verification-run.md](verification-run.md) against `specs/drop-superseded-columns/verification-run.md`, with the fate of the Req 5.4 capture-projection arms stated.
diff --git a/specs/rule-citation-by-uuid/requirements.md b/specs/rule-citation-by-uuid/requirements.mdnew file mode 100644index 0000000..7009986--- /dev/null+++ b/specs/rule-citation-by-uuid/requirements.md@@ -0,0 +1,103 @@+# Requirements: Rule Citation by UUID++Transit: T-2281. Spec B of `specs/post-v8-convergence/` (research Item 1). Supersedes T-2055.++## Introduction++An Entry's citations currently pin a rule as `(id, version)`. The version integer carries no information the UUID does not — re-teaching mints a new UUID and a new version together, recalculation reuses both — but pinning it forces every Site to hold unique, ordered versions, and that invariant is what two teaches of the same site collide on and what the renumbering and citation-rewrite machinery exists to repair. This feature cites rules by UUID alone and deletes the invariant and its repair machinery. Citations are one `EntryCitations` value serialised both into the `Entry.citationsData` blob and into the backup archive, so the change is a codec change and an archive generation, not a schema stage.++## Non-Goals++- Dropping the rule rows' own `version` column (`TitlePattern.version`, `URLRulePattern.version`). It stays, still minted as `max + 1` on re-teach with the existing overflow refusals, with nothing depending on its uniqueness or order. Removing it is a later schema bump.+- Reading archives older than the new generation. One generation is accepted, as today.+- Rewriting existing citation blobs to the new shape. Old-shape blobs are read tolerantly; an Entry whose citations are never edited keeps its old bytes indefinitely.+- Protecting a device on the previous build from version-free blobs arriving via CloudKit. One device is in use; no transitional encoding or rollout gate is specified.+- A "Historical rule vN" display ordinal. The badge and the version field on the Entry-detail rule summary are removed, not re-sourced.+- Changing `ComposedTeachingView`'s "version N" text — that is the identity-key version, a different concept.+- Removing the version a re-teach mints for its *new* row from the teaching pipeline (`PendingPatternProvenance.patternVersion`). That is the row version, kept under the first non-goal, not a citation.+- The read-time regime for same-UUID groups (T-2282). This feature changes nothing about how such groups converge.+- Unifying the version invariant behind one predicate (T-2055). The invariant is deleted instead.++## Requirements++### 1. Citations Pin the Rule UUID Only++**User Story:** As a reader, I want an Entry's provenance to survive site merges, duplicate consolidation and export/import without any repair pass, so that re-teaching never rewrites what an earlier capture recorded.++**Acceptance Criteria:**++1. <a name="1.1"></a>A rule citation SHALL consist of the rule row's UUID and nothing else. This covers every citation an Entry carries — identity, URL-work, chapter-sequence, work-URL, work-assignment and chapter-title provenance — and the URL-identity rule reference used while planning a capture. +2. <a name="1.2"></a>WHEN an Entry's citations are encoded, the encoded form SHALL contain no version field for any citation. +3. <a name="1.3"></a>WHEN a citation blob written by a previous build (each citation carrying `version`) is decoded, the system SHALL decode it successfully and ignore the version value; the result SHALL equal the same citations decoded from a version-free encoding. The evidence for this SHALL be a fixture holding literal old-shape bytes, not bytes produced by the current encoder. +4. <a name="1.4"></a>WHEN a rule row is re-parented to a surviving Site by a site merge, or converged onto an existing row by duplicate consolidation, every citation of that rule's UUID SHALL resolve unchanged afterwards without any citation being rewritten. ++### 2. Citation Resolution Is by UUID Within the Citing Record's Site++**User Story:** As a library owner, I want a citation judged by whether the cited rule exists in the Entry's own Site, so that a citation is never rejected for a stale version number.++**Acceptance Criteria:**++1. <a name="2.1"></a>The store validator SHALL treat a citation as resolved WHEN a rule row with the cited UUID exists in the citing Entry's Site, and unresolved otherwise; the rule row's `version` value SHALL NOT affect the outcome. +2. <a name="2.2"></a>The archive reference check SHALL apply the same rule as [2.1](#2.1) to archive records: a cited UUID resolves WHEN a rule record with that UUID exists under the citing Entry's hostname. +3. <a name="2.3"></a>The Entry-detail and recent-presentation readers SHALL resolve a cited rule by UUID within the Entry's Site, so that every resolution site in the app applies the rule in [2.1](#2.1). +4. <a name="2.4"></a>WHERE the citing Entry has no Site, the existing tolerance for unresolved citations SHALL apply unchanged. +5. <a name="2.5"></a>WHERE the validator checks that an Entry's URL-work, chapter-sequence and identity citations name the same rule, it SHALL compare UUIDs only. +6. <a name="2.6"></a>WHERE more than one rule row in the Site carries the cited UUID, resolution SHALL pick the row the existing rule-row group ordering ranks first, and SHALL be deterministic across repeated reads. ++### 3. The Version Invariant Is Retired++**User Story:** As a library owner, I want teaching the same site twice — on two devices, or by importing an archive into a library that already holds the site — to leave the library valid, so that a rule version collision never names a hostname for repair.++**Acceptance Criteria:**++1. <a name="3.1"></a>The store validator SHALL NOT report a Site whose title patterns or URL rules share a version value, or whose marked (current/active) rule does not hold the greatest version, and the Recent screen's site-mode reader SHALL NOT hide a Site on those conditions either. A rule row's `version` SHALL remain positive as a model invariant, as today. The validator SHALL continue to report a rule row whose Site relationship does not point at the Site that lists it. +2. <a name="3.2"></a>The archive reference check SHALL NOT refuse an archive on any of the version conditions in [3.1](#3.1). +3. <a name="3.3"></a>The store validator SHALL continue to report a Site holding more than one marked title pattern or more than one marked URL rule, and the existing same-UUID convergence checks SHALL apply unchanged. +4. <a name="3.4"></a>WHEN two Sites are merged, rule rows SHALL be re-parented to the survivor with their `version` values untouched, and no citation SHALL be rewritten. +5. <a name="3.5"></a>WHEN duplicate consolidation converges rule rows sharing a UUID, the rows' `version` values SHALL be left as found, and no citation SHALL be rewritten. +6. <a name="3.6"></a>The library reconcile pass SHALL NOT name a hostname for repair on account of rule version values; it SHALL still name a hostname holding more than one marked title pattern or URL rule. +7. <a name="3.7"></a>WHEN the site union runs for a hostname holding more than one marked title pattern or URL rule, it SHALL keep the row chosen by [4.1](#4.1) marked and unmark the others, and this repair SHALL run on the library reconcile pass and on import into a library that already holds the hostname. +8. <a name="3.8"></a>The backup exporter SHALL write each rule record's `version` as stored on the row its per-UUID reduction keeps, without renumbering, and SHALL rewrite no citation. ++### 4. "Current" Is the Marked Row, With One Tiebreak++**User Story:** As a reader, I want the rule the app applies to a site to be the one I most recently taught, so that "current" never depends on an integer ordering.++**Acceptance Criteria:**++1. <a name="4.1"></a>A Site's current title pattern SHALL be its marked row; WHERE more than one row is marked, the current one SHALL be the marked row with the newest `createdAt`, then the lowest UUID. The same rule SHALL define the current URL rule. No ordering by `version` SHALL participate. +2. <a name="4.2"></a>Every `Site`-backed reader of the current rule — capture, teaching, rule suggestion, the Sites screen, the Entry detail — SHALL obtain it from one of the two current-rule accessors (title pattern, URL rule), which return the row chosen by [4.1](#4.1), the same row on every read while the Site's rules are unchanged; no such reader SHALL select a rule by reading the marked flag directly. Readers that today refuse or hide a Site holding more than one marked rule SHALL continue to do so; the tiebreak in [4.1](#4.1) decides which row the repair in [3.7](#3.7) keeps and which row the accessors return until it runs. +3. <a name="4.3"></a>WHEN a rule is re-taught, the new row SHALL be marked and the previous row unmarked, as today; the new row's `version` SHALL be minted as today but SHALL NOT be required to exceed any other row's. +4. <a name="4.4"></a>WHEREVER a Site's rules are put in order — presented lists (the Sites screen's pattern list, the Entry detail's historical rules) and internal deterministic orderings (the URL-identity planner, the pattern basis) — the order SHALL be `(createdAt, id)` ascending; `version` SHALL NOT be a sort key. The rule-row group ordering used by [2.6](#2.6) is the one exception and is unchanged. ++### 5. Archive Generation 8/9++**User Story:** As a library owner, I want a backup archive whose citations match the store's shape, so that an export/import round trip preserves provenance exactly.++**Acceptance Criteria:**++1. <a name="5.1"></a>The exporter SHALL write archive format 8 / schema 9, in which Entry citation records carry no version field and rule records carry `version` as stored. The schema number names the store schema the archive was taken from (V9). +2. <a name="5.2"></a>The importer SHALL accept format 8 / schema 9 only, and SHALL refuse a 7/8 archive naming the versions found, matching the existing refusal for unsupported generations. +3. <a name="5.3"></a>WHEN a library is exported and re-imported into an empty library, every Entry's citations SHALL equal the originals as decoded values (an Entry still holding old-shape bytes exports version-free citations) and every exported rule record SHALL be re-materialised with its UUID, marked flag, `createdAt` and `version` value. +4. <a name="5.4"></a>WHEN an 8/9 archive whose citation records carry a `version` field is imported, the importer SHALL refuse it as a checksum mismatch, as today for any field the codec does not re-encode. +5. <a name="5.5"></a>The golden archive fixture SHALL be re-recorded at 8/9 and the 7/8 golden SHALL be removed. ++### 6. Rule-Suggestion Staleness Keys on Rule UUID++**User Story:** As a reader, I want a rule suggestion invalidated when the site's rule changes, so that a suggestion never describes a rule that no longer applies.++**Acceptance Criteria:**++1. <a name="6.1"></a>The suggestion ledger's corpus fingerprint SHALL carry the UUIDs of the Site's current title pattern and current URL rule (per [4.1](#4.1)) in place of their version values. +2. <a name="6.2"></a>WHEN a site is re-taught (a new rule row marked current), the ledger SHALL invalidate that hostname; WHEN a rule is recalculated in place (same UUID), the ledger SHALL leave it valid, as today. +3. <a name="6.3"></a>WHERE a Site has no current title pattern or no current URL rule, the coordinator SHALL treat the site as not fully taught, as today. ++### 7. No Regression Elsewhere++**User Story:** As the maintainer, I want the deletion to be complete and the remaining behaviour unchanged, so that no repair path survives half-alive.++**Acceptance Criteria:**++1. <a name="7.1"></a>After the change, no type outside tests SHALL carry a citation version: `CitedRule`, `FieldProvenance`, the URL-identity rule reference, `Entry.RuleCitation`, the Entry-detail field settlement and rule summary, the composed-teaching outcome, the replacement-projection request and its `historicalPatternVersion` parameter, and the suggestion candidate SHALL expose no per-citation version; the reparse screen's "Pattern vN (preserved)" and "Pending pattern vN" labels SHALL drop the number; and no version rewrite map, renumbering, same-UUID version alignment or version-collision tally SHALL remain. +2. <a name="7.2"></a>`make test-core` and `make test-quick` SHALL pass with no new compiler warnings. +3. <a name="7.3"></a>Each `withKnownIssue` performance band in `make test-performance-m4` SHALL remain inside its regression ceiling after the change.
diff --git a/specs/rule-citation-by-uuid/tasks.md b/specs/rule-citation-by-uuid/tasks.mdnew file mode 100644index 0000000..6909f5f--- /dev/null+++ b/specs/rule-citation-by-uuid/tasks.md@@ -0,0 +1,169 @@+---+references:+ - specs/rule-citation-by-uuid/requirements.md+ - specs/rule-citation-by-uuid/design.md+ - specs/rule-citation-by-uuid/decision_log.md+---+# Rule Citation by UUID (T-2281)++## Citation types++- [x] 1. Retire the citation version <!-- id:4na1azc -->+ - Sequencing: the whole feature must build as one commit — an 8/9 archive must never be producible with citation versions (design, Archive section). Task order is for review; squash at PR.+ - Prerequisites for the owner are in prerequisites.md; none block coding.+ - Stream: 1+ - [x] 1.1. Write failing tests: version-free encoding and old-shape blob tolerance <!-- id:4na1azd -->+ - BEFORE touching any type: capture a JSON literal from the current encoder with every citation slot populated (identity .composed with nameTitle, urlWork, chapterSequence, chapterTitle .pattern, workAssignment .pattern, workURL) and add it to EntryCitationsTests as the Q17 fixture; assert it decodes equal to the same value built with CitedRule(id:) — red as a compile failure until 1.2, so do 1.1 and 1.2 back-to-back (a non-compiling test target blocks every other suite).+ - Add a test asserting the encoded JSON object contains no `version` key at any depth (walk the JSONSerialization tree).+ - Delete `Version rewriting touches every cited slot` and the rewritingVersions nil cases.+ - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/EntryCitationsTests.swift, Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift+ - [x] 1.2. Drop version from CitedRule, FieldProvenance, URLRuleReference and every writer/reader <!-- id:4na1aze -->+ - CitedRule {id}; delete init?(id:version:), rewritingVersions, Entry.RuleCitation.version. FieldProvenance {kind, patternID}, init throws when .pattern lacks patternID or another kind carries one; tolerant(kind:patternID:). URLRuleReference {id} (URLIdentityTypes.swift ~:280), non-throwing (Q16). URLRuleBasisEntry itself (URLIdentityPlanner.swift ~:27) is reshaped in 3.2; here only drop the reference's version.+ - Compile-driven sweep, per the design's deletion inventory: LibraryRepository.applyProjection/snapshot, +ComposedTeaching CitedRule sites and committed(titleRuleID:urlRuleID:) (Q15), TeachingCommitOutcome.committed(patternID:) (Q20), historicalPatternVersion chain (LibraryProviding, ProjectionContract.replacement, +OutcomeComputation, TitleProjectionPlanner.computeHistoricalTargeting matches patternID only, +Contracts ~:212,235, and Asterism/AsterismTests/Helpers/MockLibraryProvider.swift), WorkURLIdentityMint.ruleVersion and its six writers, M4PerformanceFixture ~:318 and its existingVersions block ~:405-409, +WorkMerge ~:680 (keep the `rules.first { $0.id == id }` existence lookup; drop only the version argument), LibraryValidator.reference(from:) and ~:1139,1144 (temporary id&&version resolution stays until task 4), BackupArchiveReferenceChecks (temporary), BackupArchiveProjection ~:598 (drop the rewritingVersions call; the rewrites map itself goes in 7.2).+ - Display: PatternRuleSummary drops version; FieldSettlement cases drop version:; badge(isCurrent:); ReparseView labels become "Pattern (preserved)" / "Pending pattern" (Q13). Update RepositoryTeachingTests ~:1042 and, under Asterism/AsterismTests/, EntryDetailModelTests, ConflictRecentEntryDetailPresentationTests, ReparseViewModelTests.+ - Leave PendingPatternProvenance.patternVersion and all row-version minting alone (Non-Goals).+ - Blocked-by: 4na1azd (Write failing tests: version-free encoding and old-shape blob tolerance)+ - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [7.1](requirements.md#7.1)+ - References: Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift, Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift, Packages/AsterismCore/Sources/AsterismCore/URLIdentityTypes.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift, Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift, Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swift, Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift, Asterism/Asterism/ViewModels/ConflictRecentPresentation.swift, Asterism/Asterism/Views/ReparseView.swift, Asterism/AsterismTests/Helpers/MockLibraryProvider.swift, Asterism/AsterismTests/EntryDetailModelTests.swift, Asterism/AsterismTests/ConflictRecentEntryDetailPresentationTests.swift, Asterism/AsterismTests/ReparseViewModelTests.swift++## Current rule and orderings++- [x] 2. RuleSelection accessors <!-- id:4na1azf -->+ - Blocked-by: 4na1azc (Retire the citation version)+ - Stream: 1+ - [x] 2.1. Write failing tests: RuleSelection tiebreak, Site accessors, flag-read source scan <!-- id:4na1azg -->+ - New RuleSelectionTests: no marked row → nil; one marked → it; two marked with different createdAt → newest; two marked with equal createdAt → lowest uuidString; unmarked newer row never wins.+ - Site accessor tests (SwiftData container, serial): activePattern and currentURLRule with two marked rows return the Decision 1 row on repeated reads.+ - Source-scan test in the FrozenLibraryPathTests style: no non-test Swift file under Sources/AsterismCore, Sources/AsterismIntelligence or Asterism/Asterism outside RuleSelection.swift contains `isActive` or `isCurrent` inside a `first(where:` call (any spelling: key path, closure, `{ $0.` form). Anti-vacuity: the regex must match three in-test fixture strings, one per spelling, and must not match `filter { $0.isActive }`.+ - This scan stays red after 2.2 on one site — DuplicateReconciler.swift ~:530 (`others.first(where: \.isCurrent)` inside urlRuleVersionIsFree), which 6.2 deletes. Do not add DuplicateReconciler to any exclusion list.+ - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift+ - [x] 2.2. Implement MarkableRule, RuleSelection.current, Site.currentURLRule; route every selecting flag read <!-- id:4na1azh -->+ - New file RuleSelection.swift: protocol MarkableRule {id, createdAt, isMarked} (no AnyObject); conformances for TitlePattern (isActive), URLRulePattern (isCurrent), URLRuleBasisEntry (isCurrent); RuleSelection.current(_:) = marked rows, max createdAt, then min uuidString.+ - Models.swift: activePattern via RuleSelection; add currentURLRule.+ - Route the selecting reads listed in the design's flag-read table: +Capture :209,229; +ReparseCapture :355,361,619,632; +ComposedTeaching :130,148,319,322,506,522; +EntryDetail :78,266; +RuleSuggestion :78-79 (reads .id, finished in 8.2); +URLIdentity :25 via RuleSelection over basis entries (keep the throw when none is current).+ - Do NOT touch the count/demote/refusal sites (+Contracts :70,93,301; +ReparseCapture :26,99,558; +Articles; IdentityResolution; URLIdentityPlanner :149; +WorkMerge :595) — Q12.+ - Blocked-by: 4na1azg (Write failing tests: RuleSelection tiebreak, Site accessors, flag-read source scan)+ - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3)+ - References: Packages/AsterismCore/Sources/AsterismCore/Models.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+URLIdentity.swift++- [x] 3. Rule orderings by (createdAt, id) <!-- id:4na1azi -->+ - Blocked-by: 4na1azf (RuleSelection accessors)+ - Stream: 1+ - [x] 3.1. Write failing tests: Sites screen, Entry-detail history, teaching basis and URL basis order <!-- id:4na1azj -->+ - SiteSnapshot.patternIDs order; EntryDetail historicalPatternSummaries order; TeachingBasis.patterns order (PatternBasisEntry already has createdAt); URLSiteEvidenceBasis.rules order. Seed rows whose version order contradicts createdAt order so the old sort fails.+ - Requirements: [4.4](requirements.md#4.4)+ - References: Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift+ - [x] 3.2. Replace the four (version, id) sorts; add createdAt to URLRuleBasisEntry and drop its version <!-- id:4na1azk -->+ - +Sites ~:47, +EntryDetail ~:83, +Contracts ~:86 (and the ProjectionContract ~:75 doc), URLIdentityPlanner ~:165 → (createdAt, id.uuidString).+ - URLRuleBasisEntry: add createdAt, remove version (dead: its guard, sort key and reference use are gone); construction sites +URLIdentity ~:63 and +WorkMerge ~:609 pass the row's createdAt; ComposedTeachingProjection ~:570 passes .distantFuture (Q21). Remove the Site-unique-version throw in URLSiteEvidenceBasis.init (~:146); keep the at-most-one-current throw (~:149).+ - Blocked-by: 4na1azj (Write failing tests: Sites screen, Entry-detail history, teaching basis and URL basis order)+ - Requirements: [4.4](requirements.md#4.4)+ - References: Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift, Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift++## Invariant retirement++- [x] 4. Validator, archive check and read-path resolution by UUID <!-- id:4na1azl -->+ - Note: phase 1 already made resolution id-only (Q25); the validator's Site-unique/greatest-version clauses and Req 2.6 remain for this task.+ - Blocked-by: 4na1azi (Rule orderings by (createdAt, id))+ - Stream: 1+ - [x] 4.1. Write failing tests: id-only resolution, retired version rules, surviving marked/ownership rules <!-- id:4na1azm -->+ - CitationResolutionParityTests: rewrite :40,:61,:80 to resolve by id regardless of row version; delete :121 (version mismatch diagnosed) and :163; add: a same-UUID converged group resolves to GroupOrdering's representative deterministically; WorkMerge snapshot still derives .legacyUnverified for an unknown id.+ - LibraryValidatorTests / ConvergedRuleGroupValidationTests: duplicate versions across unrelated rules validate (invert :111,:165); marked row below a retained rule validates (invert :133); two marked title patterns still diagnosed; a pattern whose site !== the listing Site still diagnosed; v2 identity with work/sequence citing the same UUID validates (2.5); TitlePattern(version: 0) still throws (Q24).+ - validatedRecentSiteMode returns the mode for a Site with duplicate versions.+ - 2.4: LibraryValidatorNilSiteToleranceTests stays green as-is — name it in the task, no new test.+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [3.1](requirements.md#3.1), [3.3](requirements.md#3.3)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift+ - [x] 4.2. Implement id-only resolution and delete the version invariants in validator, archive check and readers <!-- id:4na1azn -->+ - LibraryValidator: citedRule/citedPattern match id only and pick the representative when several rows share the id (new citedRow helper, Q6); delete claimVersion, groupVersion, the Site-unique clause of the fused guards ~:657-692 (ownership clause stays; messages become "… require ownership"), current-holds-greatest ~:723-732 (keep the ≤1-current guard ~:719-722).+ - BackupArchiveReferenceChecks: validateSiteTuple drops the version sets and the greatest clause (keep mode/flag checks); resolvesSameSite = rulesByID[id]?.siteHostname == entry.hostname; v3 name-pattern check drops the version compare.+ - +RecentPresentation: validatedRecentSiteMode drops the Site-unique clause (~:307, keep version > 0); replayCitedPattern ~:377 resolves by id via the same helper.+ - +Contracts: delete the Site-unique refusal ~:117-123 (keep ~:74-79).+ - Blocked-by: 4na1azm (Write failing tests: id-only resolution, retired version rules, surviving marked/ownership rules)+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3)+ - References: Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift, Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift++- [x] 5. Site union and reconcile without renumbering <!-- id:4na1azo -->+ - Blocked-by: 4na1azl (Validator, archive check and read-path resolution by UUID)+ - Stream: 1+ - [x] 5.1. Write failing tests: union keeps versions, demotes by tiebreak, rewrites no citation; reconcile worklist <!-- id:4na1azp -->+ - SiteUnionProjectionTests: delete :173,:196; rewrite :218 so the kept row is the newest-createdAt marked row (not the survivor Site's); new: single Site, two marked rows → hasWork true, kept per Decision 1, other demoted; single Site, one marked → hasWork false; versions never change on any projected row.+ - SiteReconcilerTests: :120 asserts citationsData bytes are identical before/after; delete :248. ReconcileAfterSyncTests: delete :84 and the :328-345 helper. MultiSiteReviewFixTests: delete :179-195. CitationBlobRefreshTests: delete :99-106.+ - reconcileWorkLists: a hostname with two active patterns is named; a hostname with duplicate versions is not.+ - Requirements: [1.4](requirements.md#1.4), [3.4](requirements.md#3.4), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileAfterSyncTests.swift+ - [x] 5.2. Delete assignVersions, versionRewrites, repin's rewrite, RuleTally version arms; keptRule via RuleSelection <!-- id:4na1azq -->+ - SiteUnionProjection: delete assignVersions, countSurviving, owningRank, versionRewrites, ProjectedTitlePattern.version, ProjectedURLRule.version; repairsInPlace = flag drift only; keptRule<Rule: MarkableRule> = RuleSelection.current(marked rows) (Q18); drop its ordered: parameter; rewrite the RuleMembership.reduced doc (~:118-125).+ - SiteReconciler: applyUnion stops writing version; repin loses rewrites:; delete rewriteCitations; rename SiteReconciliationOutcome.renumberedRules → reparentedRules.+ - LibraryRepository: RuleTally = {markedCount}, record(marked:), collides = markedCount > 1; delete markedIsNotGreatest and the version tally at ~:683-703.+ - +ConfirmImport ~:356-360: keep the post-insert union step; rewrite its comment (it now repairs marked-flag collisions only).+ - Blocked-by: 4na1azp (Write failing tests: union keeps versions, demotes by tiebreak, rewrites no citation; reconcile worklist)+ - Requirements: [1.4](requirements.md#1.4), [3.4](requirements.md#3.4), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7)+ - References: Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swift, Packages/AsterismCore/Sources/AsterismCore/SiteReconciler.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift++- [x] 6. Duplicate consolidation without version alignment <!-- id:4na1azr -->+ - Blocked-by: 4na1azo (Site union and reconcile without renumbering)+ - Stream: 1+ - [x] 6.1. Write failing tests: consolidation leaves versions as found and rewrites nothing; re-seed the re-teach rollback case <!-- id:4na1azs -->+ - DuplicateReconcilerTests: delete :66 (incl. the :88 rewrittenCitations assert), :143, :171, :231; new: a same-UUID group with divergent versions converges definitions and flags, versions left as found, no Entry blob rewritten (bytes compared).+ - ReteachDiagnosisComparisonTests :89: re-seed the introduced diagnosis with the membership workURLString mutation the sibling at :179 uses (a teach demotes every marked row before minting, so double-marked cannot be the trigger).+ - Requirements: [1.4](requirements.md#1.4), [3.5](requirements.md#3.5)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/ReteachDiagnosisComparisonTests.swift+ - [x] 6.2. Delete alignVersions, *VersionIsFree, rewriteCitations and the rewrites map from DuplicateReconciler <!-- id:4na1azt -->+ - Delete alignVersions, patternVersionIsFree, urlRuleVersionIsFree, rewriteCitations, the rewrites map in convergeRules (~:339-371), rewrittenVersion in both group results; DuplicateReconciliationOutcome loses rewrittenCitations and its arms in isEmpty/wroteNothing. convergeRules = definition copy-down + demoteWithinSites.+ - Blocked-by: 4na1azs (Write failing tests: consolidation leaves versions as found and rewrites nothing; re-seed the re-teach rollback case)+ - Requirements: [1.4](requirements.md#1.4), [3.5](requirements.md#3.5)+ - References: Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift++## Archive generation 8/9++- [x] 7. Archive 8/9 <!-- id:4na1azu -->+ - Blocked-by: 4na1azr (Duplicate consolidation without version alignment)+ - Stream: 1+ - [x] 7.1. Write failing tests: 7/8 refused, stray citation version refused, versions round-trip as stored, duplicate versions import <!-- id:4na1azv -->+ - Write against the BackupV8* names (they fail to compile until 7.2 — that is the red).+ - BackupV8ArchiveTests: a 7/8 document is refused naming (7, 8) and (8, 9); an 8/9 archive built from a JSON literal whose Entry citation carries `"version": 3`, checksum computed over that literal via the BackupV8Fixtures literal-archive helper (today BackupV7Fixtures ~:287-317), is refused as checksumMismatch; an archive whose Site holds two title patterns at version 1 and a current URL rule below a retired one imports (3.2).+ - BackupGroupRoundTripTests: export → import into an empty library; decoded citations equal; every rule row keeps (id, isMarked, createdAt, version). BackupGroupProjectionTests :522: assert the archived record's version equals the kept row's version; delete :569 (citation rewritten to the archived version).+ - BackupGoldenExportTests: assert formatVersion 8 / schemaVersion 9 against backup-8-9-golden.json; delete the :131 case (Q23).+ - Requirements: [3.2](requirements.md#3.2), [3.8](requirements.md#3.8), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7Fixtures.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift+ - [x] 7.2. Rename BackupV7* to BackupV8*, bump 8/9, delete the export rewrite map, add golden record mode, re-record the golden <!-- id:4na1azw -->+ - git mv BackupV7Types/Codec/Exporter.swift → BackupV8*; rename every BackupV7 symbol; formatVersion = 8, schemaVersion = 9; gate literal stays "multi-site" (Q19); BackupImporter.supportedVersions follows. Reference sweep: BackupArchiveProjection, BackupImporter, BackupArchiveReferenceChecks (formatLabel "V7" strings → "V8"), ArchiveRecordBuilders, BackupImportCharacters, BackupImportWorkTypes, BackupGroupProjection, +ConfirmImport, Asterism/Asterism/ViewModels/SettingsBackupModel.swift and AppLibraryModel.swift, comment-only files, tests (git mv BackupV7Fixtures → BackupV8Fixtures, BackupV7ArchiveTests → BackupV8ArchiveTests), FrozenLibraryPathTests pins.+ - BackupArchiveProjection: the rewrites map, its Entry-mapper parameter and the pattern.version / rule.version record writes already landed in 5.2 (Q33); verify only.+ - BackupGoldenExportTests byte test: when ASTERISM_RECORD_GOLDEN=1, write the encoded bytes to the fixture path and fail with "recorded; re-run without the flag" (Q22). Delete backup-7-8-golden.json; record backup-8-9-golden.json with the flag; re-run without it.+ - Blocked-by: 4na1azv (Write failing tests: 7/8 refused, stray citation version refused, versions round-trip as stored, duplicate versions import)+ - Requirements: [3.8](requirements.md#3.8), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5)+ - References: Packages/AsterismCore/Sources/AsterismCore/BackupV7Types.swift, Packages/AsterismCore/Sources/AsterismCore/BackupV7Codec.swift, Packages/AsterismCore/Sources/AsterismCore/BackupV7Exporter.swift, Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift, Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift, Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-7-8-golden.json++## Suggestion ledger and cleanup++- [x] 8. Suggestion fingerprint keys on rule UUIDs <!-- id:4na1azx -->+ - Blocked-by: 4na1azf (RuleSelection accessors)+ - Stream: 1+ - [x] 8.1. Write failing tests: new rule UUID invalidates, recalculated rule does not, nil id keeps the site eligible <!-- id:4na1azy -->+ - RuleSuggestionLedgerTests :608: reconcile against a fingerprint whose titleRuleID differs invalidates; same ids, other fields equal → still valid. Coordinator: isAutoEligible true when titleRuleID or urlRuleID is nil (not fully taught, Req 6.3) and false when both are present — mirrors the existing nil-version test; the original wording here was inverted (Q35).+ - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3)+ - References: Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift+ - [x] 8.2. Replace CorpusFingerprint and candidate version fields with rule UUIDs <!-- id:4na1azz -->+ - CorpusFingerprint and RuleSuggestionCandidate: titleRuleVersion/urlRuleVersion → titleRuleID/urlRuleID: UUID?. +RuleSuggestion ~:78-79 read site.activePattern?.id and site.currentURLRule?.id. RuleSuggestionCoordinator ~:421,457 follow the rename; the nil test is unchanged.+ - Blocked-by: 4na1azy (Write failing tests: new rule UUID invalidates, recalculated rule does not, nil id blocks auto-eligibility)+ - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3)+ - References: Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swift, Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift++- [x] 9. Rewrite stale invariant comments, validator messages and agent notes <!-- id:4na1b00 -->+ - Doc comments that state the deleted invariant as live rationale: ComposedTeachingProjection ~:68-75, ~:551-554; Models.swift ~:271; LibraryRepository.swift ~:652; +ConfirmImport ~:356-360; SiteUnionProjection RuleMembership doc; EntryCitations.swift header ("cited at a version") and CitedRule doc; grep `Site-unique` and `greatest version` across Sources for the rest.+ - docs/agent-notes/schema-migration.md ~:154-171,206: archive is 8/9, BackupV8*; docs/agent-notes/rule-wire-format.md: replace the stale BackupV4*/BackupV6Codec names with BackupV8*.+ - Blocked-by: 4na1azu (Archive 8/9)+ - Stream: 1+ - Requirements: [7.1](requirements.md#7.1)+ - References: docs/agent-notes/schema-migration.md, docs/agent-notes/rule-wire-format.md++- [x] 10. Full verification: test-core, test-quick, build, performance bands <!-- id:4na1b01 -->+ - make test-core, make test-quick, make build — green, no new warnings.+ - Grep gate for 7.1: no non-test, non-fixture source references CitedRule.version, patternVersion (except PendingPatternProvenance), rewritingVersions, versionRewrites, alignVersions, markedIsNotGreatest, historicalPatternVersion. Also confirm specs/retire-migration-chain/library-graph-baseline.txt is at format 6 and carries no patternVersion key (Q28).+ - make test-performance-m4 twice (host-only, ~21 min each); record both runs in specs/rule-citation-by-uuid/verification-run.md against drop-superseded-columns/verification-run.md, stating the Req 5.4 capture-projection arms' fate. Do not put results in tasks.md.+ - Blocked-by: 4na1azx (Suggestion fingerprint keys on rule UUIDs), 4na1b00 (Rewrite stale invariant comments, validator messages and agent notes)+ - Stream: 1+ - Requirements: [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3)+ - References: specs/drop-superseded-columns/verification-run.md
diff --git a/specs/rule-citation-by-uuid/verification-run.md b/specs/rule-citation-by-uuid/verification-run.mdnew file mode 100644index 0000000..6b1c52e--- /dev/null+++ b/specs/rule-citation-by-uuid/verification-run.md@@ -0,0 +1,227 @@+# Verification Run: Rule Citation by UUID++Task 10's evidence, recorded here rather than in `tasks.md`, which `rune` owns.++**Date**: 2026-08-29+**Commit**: `ca77c17` ("rewrite the stale invariant comments and the agent+notes") — the tree the two performance runs were taken at. It is the last+behaviour-changing commit on the branch; everything after it edits `specs/` markdown or comments+only, so a re-run at a later commit measures the same code.+**Host**: the project machine, macOS 26, Apple Silicon. Host-only throughout — no+device target was run, and none may be: `make test-performance-m4-recent`,+`make install`, `make run` and `xcrun devicectl` all touch the owner's phone and+are the owner's step (`CLAUDE.md`).++**One run is not a baseline, and two are barely more.** `CLAUDE.md` says so and+the suites' own history says so louder: three consecutive release runs of+unchanged code once measured 0.7805 s, 1.2789 s and 0.7389 s on the same path.+Two runs were taken here and both are reported in full; where a label moved, the+question asked is whether *both* runs moved it.++The comparison column throughout is+[`../drop-superseded-columns/verification-run.md`](../drop-superseded-columns/verification-run.md)+§5 (runs A and B, 2026-08-28), which is the last full recording.++Req 7.3 is the claim under test: **each `withKnownIssue` band stays inside its+regression ceiling after the change**. It does, on both runs, and the count of+known issues is unchanged at eight.++---++## 1. The functional gates (Req 7.2)++| | Result |+|---|---|+| `make test-core` | **exit 0**, no failures |+| `make test-quick` | **exit 0**, Test Succeeded |+| `make build` | **exit 0**, Build Succeeded |++**No new compiler warnings.** The builds were run unwrapped and grepped, per+`docs/agent-notes/testing.md`. `make build` and `make test-core` emit none. The+simulator test build emits eight, all in test files, all pre-existing: the+`CharacterExtractionCoordinatorTests`, `CharacterReviewModelTests` and+`DuplicateSurfaceTests` warnings are in files this branch never touched, and the+two files it *did* touch (`OptionalSequenceTeachingMessagesTests`,+`SettingsImportTests`) carry warnings at lines the branch did not change — the+edits were one-line-for-one-line, so the line numbers are stable and the+warnings predate the branch. The release build for the performance suites emits+four more, all `result of call to 'commitReShareUpdate' is unused` in+`RepositoryReShareTests`, a file last touched by an unrelated commit.++One environment note worth keeping: the first `make test-only` after the phase-8+package edit failed to compile the **app** target against a stale+`AsterismCore.swiftmodule` — the exact symptom `docs/agent-notes/testing.md`+describes. Deleting the package's build products fixed it. The source was fine.++## 2. The deletion grep gate (Req 7.1)++Run over `Packages/AsterismCore/Sources`, `Asterism/Asterism` and+`Asterism/AsterismShareExtension` — non-test, non-fixture sources only.++| Symbol | Hits | Verdict |+|---|---|---|+| `CitedRule.version` | 0 | gone — the field does not exist, so the compiler is the check |+| `rewritingVersions` | 1 | a comment in `EntryCitations.swift` recording that the walk **stood** there and why it is gone. Not a live reference |+| `versionRewrites` | 0 | gone |+| `alignVersions` | 1 | the same shape of comment in `DuplicateReconciler.swift` |+| `markedIsNotGreatest` | 0 | gone |+| `historicalPatternVersion` | 0 | gone |+| `patternVersion` | 29 | **every live one the row version Q4 keeps** (the one exception is prose) — `PendingPatternProvenance.patternVersion` and the `TitleProjectionPlanner` / `LibraryRepository+OutcomeComputation` / `+Contracts` plumbing that carries the minted `max + 1` to `TitlePattern(version:)`, including its positivity refusal. No citation carries one |++The `patternVersion` figure is the count from this exact command, whose two+exclusions drop the `validatePatternVersion` guard and the+`computedPatternVersion` accessor — both named for the row version, neither a+citation:++```+grep -rn "patternVersion" Packages/AsterismCore/Sources Asterism/Asterism Asterism/AsterismShareExtension \+ | grep -v "validatePatternVersion" | grep -v "computedPatternVersion" | wc -l+```++It reports **29**, over four files: `TitleProjectionPlanner.swift` (22),+`LibraryRepository+OutcomeComputation.swift` (3),+`LibraryRepository+Contracts.swift` (3) and `DuplicateReconciler.swift` (1 —+prose, the comment naming the deleted `patternVersionIsFree`). Without the two+exclusions it reports 33.++The surviving `rewritingVersions`, `alignVersions` and `DuplicateReconciler`+`patternVersion` hits are prose naming a deleted symbol, which is the point of+the sentence in each case; the gate is about live machinery.++**Q28's baseline file.** `specs/retire-migration-chain/library-graph-baseline.txt`+is at `format 6` and contains **zero** `patternVersion` keys. Its header records+that format 6 is format 5 with one field removed from the citation blob, and+that it was re-recorded by deleting the key rather than by regenerating — which+the file's own header forbids.++## 3. Performance: what was run++| | Run 1 | Run 2 |+|---|---|---|+| Command | `make test-performance-m4` | `make test-performance-m4` |+| **Result** | **exit 0**, 27 tests in 5 suites, **8 known issues** | **exit 0**, 27 tests in 5 suites, **8 known issues** |+| Test time | 1,104.9 s | 1,081.8 s |+| Configuration | `RUNS=1`, `CONTROLLED` unset (median asserted, p95 reported) | same |++Against `drop-superseded-columns`' 1,098.5 s / 1,092.6 s, so the suite costs what+it cost. The release build was paid once per run (~280 s on run 1, cached on+run 2) and is not in those numbers.++**The eight known issues are the same eight, in both runs**: Req 10.1's settling+pass, Req 5.4's three capture-projection arms, Req 5.5's three diagnosis+re-derivations, and the full-tier no-op reconcile. None closed, none opened,+none escaped the regression ceiling asserted outside its block.++## 4. The band the design put at risk: Req 5.4's capture-projection arms++The design's Testing Strategy named these three as the band at risk, because+`RuleSelection.current` replaces a short-circuiting `first(where: \.isActive)` on+the capture path (`+Capture.swift:209,229`) with a lazy filter and a `max` over+the Site's rows.++| Arm | Run 1 | Run 2 | Baseline (A / B) | Δ vs baseline |+|---|---|---|---|---|+| `capture-projection-duplicateSiteRows` | 0.1681 s | 0.1675 s | 0.1685 / 0.1698 s | **−0.2% / −1.4%** |+| `capture-projection-siteMissing` | 0.1620 s | 0.1598 s | 0.1600 / 0.1602 s | +1.2% / −0.3% |+| `capture-projection-duplicateIdentity` | 0.1687 s | 0.1683 s | 0.1667 / 0.1689 s | +1.2% / −0.4% |++**The risk did not materialise.** Every arm is inside the run-to-run spread of+the baseline pair, and two of the three are *faster* on at least one run. The+breach against the 100 ms budget is unchanged at 1.60–1.69×, and all three stay+well inside the 250 ms regression ceiling asserted outside the block.++Why it costs nothing: a Site holds one rule row per teaching revision — a handful+— so the difference between stopping at the first marked row and scanning the+array is a few pointer comparisons against a projection that takes 160 ms. The+`capture-rule-application*` arms, which time the rule application itself rather+than the projection around it, are unmoved at ~0.07 ms.++The three arms **stay `withKnownIssue`**, with their ceilings unchanged. Nothing+in this feature touches the cost they name.++## 5. Every label, both runs, against the baseline++Medians. "Baseline" is `drop-superseded-columns` run A / run B.++| Measurement | Run 1 | Run 2 | Baseline (A / B) | Bound | Verdict |+|---|---|---|---|---|---|+| `reconcile-noop-coherent` | 0.0309 s | 0.0300 s | 0.0302 / 0.0296 s | 10 ms ceiling (known issue), 100 ms floor | breached 3.0–3.1×, in floor |+| `reconcile-noop-arrival` | 0.0307 s | 0.0306 s | 0.0301 / 0.0303 s | 100 ms floor | in floor |+| `duplicate-arrival-pass-gated` | 0.0293 s | 0.0305 s | 0.0314 / 0.0301 s | 3 s class ceiling | in ceiling |+| `duplicate-observation-pass` | 0.9936 s | 1.0328 s | 1.025 / 1.008 s | 2 s budget | in budget |+| `duplicate-settling-pass` | 7.291 s | 7.362 s | 7.411 / 7.347 s | 2 s budget (known issue), 11 s ceiling | breached, in ceiling |+| `reconcile-worst-case-consolidation` | 41.203 s | 40.231 s | 40.076 / 40.042 s | 55 s ceiling | in ceiling — see 5.1 |+| `works-snapshot-duplicate-free` | 1.610 s | 1.682 s | 1.704 / 1.678 s | 3 s ceiling | in ceiling |+| `backup-projection-duplicate-free` | 1.419 s | 1.460 s | 1.485 / 1.484 s | reported only | −2 to −4% |+| `record-counts-duplicate-free` | 0.2292 s | 0.2363 s | 0.232 / 0.235 s | 3 s ceiling | in ceiling |+| `recent-publication-duplicate-free` | 0.8564 s | 0.8849 s | 1.029 / 0.886 s | 2 s budget | in budget |+| `merge-destinations` | 1.350 s | 1.334 s | 1.371 / 1.347 s | 3 s class ceiling | in ceiling |+| `membership-reconcile-noop` | 0.3875 s | 0.3899 s | 0.380 / 0.383 s | 800 ms ceiling | in ceiling |+| `membership-heal-full` | 1.757 s | 1.732 s | 1.704 / 1.709 s | 5 s ceiling | in ceiling |+| `extension-open-and-validate` | 0.7176 s | 0.7240 s | 0.749 / 0.752 s | 1 s budget | in budget |+| `store-level-validation` | 0.7261 s | 0.7221 s | 0.742 / 0.747 s | 1 s budget | in budget |+| `open-coherent` | 0.7236 s | 0.7246 s | 0.743 / 0.750 s | 1 s budget | in budget |+| `open-duplicateSiteRows` | 0.7183 s | 0.7186 s | 0.747 / 0.750 s | 1 s budget, ≤ 1.25× ratio | ratio 0.993× / 0.992× |+| `open-siteMissing` | 0.3387 s | 0.3320 s | 0.336 / 0.334 s | 1 s budget | in budget |+| `open-duplicateIdentity` | 0.7288 s | 0.7171 s | 0.747 / 0.752 s | 1 s budget | in budget |+| `recent-coherent` | 0.8766 s | 0.8698 s | 0.885 / 0.886 s | 2 s budget | in budget |+| `recent-duplicateSiteRows` | 0.8803 s | 0.8712 s | 0.886 / 0.883 s | 2 s budget, ≤ 1.25× ratio | ratio 1.004× / 1.002× |+| `capture-projection-duplicateSiteRows` | 0.1681 s | 0.1675 s | 0.1685 / 0.1698 s | 100 ms budget (known issue), 250 ms ceiling | breached 1.68× / 1.68× |+| `capture-projection-siteMissing` | 0.1620 s | 0.1598 s | 0.1600 / 0.1602 s | as above | breached 1.62× / 1.60× |+| `capture-projection-duplicateIdentity` | 0.1687 s | 0.1683 s | 0.1667 / 0.1689 s | as above | breached 1.69× / 1.68× |+| `capture-rule-application*` (4 arms) | ~0.07 ms | ~0.07 ms | ~0.07 ms | 100 ms budget | in budget |+| `diagnosis-refresh-foreground` | 0.2774 s | 0.2775 s | 0.2764 / 0.2753 s | 250 ms budget (known issue), 400 ms ceiling | breached 1.11×, in ceiling |+| `diagnosis-refresh-after-write` | 0.2808 s | 0.2795 s | 0.2758 / 0.2755 s | as above | breached 1.12×, in ceiling |+| `diagnosis-refresh-duplicateSiteRows` | 0.2799 s | 0.2758 s | 0.2757 / 0.2766 s | as above | breached 1.11×, in ceiling |+| `complete-preview-expanded` | 0.0797 s | 0.0794 s | 0.0794 / 0.0789 s | 1 s budget | in budget |+| `complete-preview-collapsed` | 0.0297 s | 0.0297 s | 0.0300 / 0.0298 s | 1 s budget | in budget |+| `edit-ack-*` (2 arms) | 15 / 6 µs | 19 / 6 µs | 15 / 6 µs | 100 ms budget | in budget |++**Nothing left a bound it was inside, and nothing that was outside one went+further out.** The largest move in either direction is `reconcile-worst-case-+consolidation` on run 1, addressed below; every other label agrees with the+baseline to within 4%, which is the noise these suites are known to carry.++### 5.1 `reconcile-worst-case-consolidation` — noise, not a regression++Run 1 measured 41.203 s (+2.8% on baseline) at a **1.12× spread** with a p95 of+45.778 s — the widest-spread label of either run. Run 2 measured 40.231 s+(+0.5%) at a 1.01× spread. The second run is the one to believe: the arm re-pins+6,000 records five times, and one slow sample on a five-sample distribution moves+the median directly. Both runs are inside the 55 s ceiling with 26% of headroom.++This is the shape `CLAUDE.md`'s warning describes and the reason the second run+was taken; recorded rather than quietly averaged.++### 5.2 The read paths came down slightly, and nothing here claims credit++Nine labels that open, validate or walk the store fell 2–4%+(`extension-open-and-validate`, `store-level-validation`, the four `open-*`+arms, the two `recent-*` arms, `record-counts`). The change does remove work from+those paths — the validator no longer builds per-Site version sets or compares a+marked row's version against the greatest, and `RuleTally` shrank from five+fields to two — but 2–4% on a path where the baseline pair itself disagrees by+up to 1% is not evidence of that, and no bound depends on it. Reported, not+attributed.++`backup-projection-duplicate-free`, the label `drop-superseded-columns` §4.2+flagged as up 14% and left un-bounded, measured 1.419 s and 1.460 s against+1.485 s / 1.484 s. It has not grown. It stays reported rather than bounded, and+`sortedWorkRows` stays the first place to look if it moves again.++## 6. Not run++`make test-performance-m4-recent`, `make install`, `make run` and+`xcrun devicectl` were **not** run, and no agent may run them. Every figure here+is host-only, from a `swift test` of the `AsterismCore` package in release with+`-DASTERISM_PERFORMANCE_TESTING`. Nothing above says anything about what the+shipped app does on a phone.++The device install matters more than usual for this feature: Q8 records that+there is no transitional encoding and no rollout gate, so the first build to+write version-free citation blobs is the one the owner installs, and the only+restorable archive from that point on is an 8/9 one exported **after** the+upgrade. That step and its ordering are `prerequisites.md`'s and the owner's.++`make test-performance-chunks` was not run: the bulk write paths are unchanged by+this spec, and its own comment says to re-run it when they change.
SiteReconcilerTests.swift kept Req 3.4's version assertions and T-2295's nothing is diagnosed assertion. Suites pass, but eyeball the merged test if T-2295's banner fix matters to you.
The spec requires the whole feature to land as one commit (no intermediate 7/8-with-version-free-citations archive, Q27). pr-pilot squash-merges.