asterism branch feature/relational-references commits 46 files 84 touched lines +7455 / -644

Pre-push review: feature/relational-references

Schema V5: Entry.site and Work.site become modelled relationships, populated by a certification-time migration pass; cited-rule resolution follows the record's own Site and the union workaround is deleted. 46 commits, all 21 spec tasks complete, six phase-level adversarial reviews plus this four-lens pre-push review applied.

At a glance

  • Schema V5: V4 frozen as snapshots (any live edit bricks recorded stores — CoreData 134504, measured), live classes moved to V5 with Entry.site/Work.site and internal .nullify inverses; a V3 store traverses V3→V4→V5 in one open.
  • Migration: V5RelationshipPass resolves every hostname through SiteResolutionOrder, one save, marker "5" published only after validation; interruption state (converted store, stale marker) converges on re-run — pinned from a genuine 4.0.0-recorded fixture.
  • Marker contract: app accepts "4"/"5", extension only "5" and declines before constructing a ModelContainer, so the extension can never perform the conversion.
  • Resolution: all eight cited-rule sites search the record's own Site; CitedRuleResolution (the union) deleted; presentation deliberately stays with the hostname winner (Decision 5's presentation/provenance split).
  • Writes: every Entry/Work construction site sets relationship + hostname together via one siteForWrite helper, same row selection as the pass.
  • Measured: every budget green on host except the migration's 10 s at the 5,000-entry worst case (17.3–17.75 s, accepted — Decision 6/Q60); validation is not slower following relationships than resolving strings (Req 5.3 answered).
  • This review fixed 2 cross-surface behaviour divergences, extracted 4 duplicated constructions, repaired 1 vacuous flagship test, and reconciled the changelog/spec with what actually landed.

Verdict

Ready to push

All four review lenses (reuse, quality, efficiency, spec/docs) ran; every major finding was fixed in-review and verified — 858 tests across 98 suites green, zero new compiler warnings. The one knowingly unmet requirement (Req 2.6, the 10 s migration budget) is breached only at the 5,000-entry single-Site worst case and is accepted by the owner (Decision 6, Q60): the real library is under 200 notes, where the measured growth curve puts the pass near 0.1 s. Three device-side measurements remain deliberately pending explicit approval.

Review findings

13 raised · 10 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Asterism keeps notes (Entries) captured from websites, grouped under Works, with per-website parsing rules on Site records. Until now an Entry remembered its website only as text (the hostname) and its title-producing rule as an ID number — the app searched for matches every time. This branch gives each Entry and Work a real database-level link (a pointer) to its Site. The text and IDs all stay as capture-time evidence; the pointer is what the app follows. A one-time migration on first launch sets the pointers — well under a second for a few hundred notes.

Why it matters

The next milestone syncs the library over iCloud, where records arrive in any order — a note can land before its website record. With text lookup, "hasn't arrived yet" and "never existed" are indistinguishable and both looked like damage. With a pointer, the database knows the reference exists: an unarrived Site is a nil pointer that heals itself when the record lands (proven by a real-device probe: 2,995 of 3,000 notes dangled mid-sync and every one healed).

Key concepts

  • Relationship: a stored pointer between records — a contact card linking to a company entry, not just naming the company.
  • Schema migration: the store's layout gains a version (V5); old stores convert on open.
  • Readiness marker: a small file naming the schema version the library is prepared for; the share extension refuses to touch the library until the app has finished preparing it.

Architecture

  • Schema: AsterismSchemaV4 frozen as nested snapshots (live classes → V5 extension); Entry.site/Work.site optional, inverses Site.entries/Site.works internal and .nullify; plan declares [V3, V4, V5]; the V4-only plan is deleted — a plan stopping short of the live schema can no longer open the store.
  • Migration: V5RelationshipPass resolves hostnames through SiteResolutionOrder (never a last-write-wins map), one save, marker last; all certification paths share one extracted tail so the load-bearing ordering exists once.
  • Marker split: ModelContainer.init performs the lightweight conversion, so the extension (shared lock, may run with no app alive) validates the marker before constructing one.
  • Resolution: eight cited-rule sites search the citing record's own Site; the union (CitedRuleResolution) is deleted. Presentation (mode, title cleaning, actions) stays with the hostname winner — teaching itself refuses duplicated hostnames, so those are hostname-level questions (Decision 5).
  • Writes: one siteForWrite helper; every construction site sets both halves in one transaction.

Trade-offs

  • Only two relationships modelled — cited rules resolve through the record's Site rather than eight citation edges each needing a CloudKit inverse (Q11); depends on the mirroring spec reconciling duplicate Sites (Q21, recorded).
  • One save, no batching (Q15): interruption leaves zero progress and re-runs; cost is the 17 s worst-case pass (accepted, Q60).
  • internal inverses are honest convention + a source-scan test, not real access control (Q17 amended) — internal cannot stop in-module traversal.

Ordering constraints are the design

  1. Any edit to a live schema's body bricks recorded stores (134504, measured — Q20); a frozen V4 with an identical V5 dies on duplicate version checksums (Q22), so freeze + V5 landed as one commit.
  2. The pass runs before validateV4Store so diagnostics and the session's quarantine map describe the post-pass graph — a regression test discriminates the swap.
  3. Citation-replay throws died before the union did (Q13): with resolution following entry.site, an unresolvable citation is the normal sync-time state, not corruption.
  4. Write sites landed before reads (Decision 2): a mid-branch review caught backup import producing a certified store with all-nil relationships and no repair path; re-sequencing closed it, an import-into-"5" test pins it.
  5. certifyMigration publishes the marker before sidecar cleanup — the inverse order opens a crash window leaving a converted, marker-less, nonempty store the bootstrap rejects as unverifiable (Q36).

Edge cases

  • Duplicate Sites with split citation ownership: unreachable by the pass (runs only at certification, when duplicates cannot exist — Q16); post-mirroring, relationships arrive as synced pointers. Fixtures pin records inline to model the sync-shaped graph (Decision 4).
  • The frozen snapshots reference four live value types — editing those silently redefines V3/V4; both files carry no-touch warnings.
  • The validator's per-citation scan went O(1) → O(rules-on-site); invisible at the fixture's two-rule Site, unmeasured at a many-version Site (recorded, remedy unapplied).

Monitor

  • Three device measurements pending; installing any V5 build on the phone is one-way (previous build fails on the converted store) — container download first (Q30).
  • Capture-commit is the one write path with a 100 ms budget and no commit-path measurement; per-capture cost is proportional to the target Site's Entry count (~5 ms at 5,000 — nothing at real scale).

Important changes — detailed

AsterismSchemaV5 + frozen V4: the relationships exist

Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift

Why it matters. The schema change everything else builds on — and the riskiest artifact: a wrong frozen snapshot bricks every recorded store at open.

What to look at. AsterismSchemaV5.swift (new), AsterismSchemaV4.swift (frozen snapshots), Models.swift (live classes now V5, Entry.site/Work.site added)

Takeaway. Freezing a SwiftData schema means snapshotting classes AND auditing the value types they embed — the four live payload structs are frozen-by-reference, and editing them silently redefines every schema that embeds them.
Rationale. A probe measured that any edit to V4's body makes a 4.0.0-recorded store refuse to open (CoreData 134504), and that a frozen V4 with a byte-identical V5 fails on duplicate version checksums — so the freeze and the V5 declaration are one inseparable change (Q20, Q22).

V5RelationshipPass: the migration

Packages/AsterismCore/Sources/AsterismCore/V5RelationshipPass.swift

Why it matters. Runs once over every real library; a wrong-row assignment here is permanent (the pass never re-runs on a certified library).

What to look at. V5RelationshipPass.swift; wiring in LibraryRepository+V4Bootstrap.swift (runPassAndCertify)

Takeaway. Deterministic winner selection (SiteResolutionOrder) instead of a last-write-wins dictionary; identity-guarded assignment makes re-runs converge; one save + marker-last makes interruption equal zero progress rather than partial state.
Rationale. Req 2.4 rests on atomicity, not resumption (Q15); the pass runs only when the marker reads "4", so a certified library never pays it again (Q31).

Marker contract split: the extension can never convert the store

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift

Why it matters. ModelContainer.init is what performs the lightweight conversion; whichever process opens first converts. The extension may run with no app alive.

What to look at. validateMarkerContentForApp / validateMarkerContentForExtension; MarkerContractTests proves the decline happens before a container exists and the store bytes stay untouched

Takeaway. When initialisation itself has side effects, the guard must run before the initialiser — asserting ordering in tests by corrupting the store and discriminating on which error returns is a reusable trick.
Rationale. One function accepting {4,5} for both processes would let the extension open an unmigrated store; one demanding "5" would make the app throw on every pre-existing library (Q14).

CitedRuleResolution deleted: resolution follows the record's own Site

Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swift

Why it matters. The point of the milestone — the 72-line union workaround and its ambiguity are gone; eight call sites converted with parity pinned before conversion.

What to look at. V4LibraryValidator (CitationContext, citedRule/citedPattern), +RecentPresentation, +EntryDetail, +ReparseCapture; CitationResolutionParityTests written against the union first

Takeaway. Write the parity tests against the old implementation, keep them green through the conversion — the union suite was converted into proof the relational form covers what the union covered, not deleted.
Rationale. A hostname string could not say which row to look in; the record's own relationship can (Q11). A nil site tolerates rather than quarantines (Decision 3), because post-sync a nil is 'not yet arrived', not damage.

Decision 5: presentation follows the winner, provenance follows the record

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift

Why it matters. The load-bearing boundary for the mirroring milestone — and the place two review rounds found cross-surface divergence (Entry detail vs Recent) that would have gone user-visible under sync.

What to look at. +EntryDetail (presentation half reverted to winner, citation half on entry.site), +RecentPresentation (shared replayCitedPattern with the nil-site predicate inside)

Takeaway. When two surfaces must agree, put the applicability predicate in one function — both divergences found here existed because a guard was duplicated with a difference.
Rationale. Site mode, title cleaning and offered actions are hostname-level teaching questions (teaching refuses duplicated hostnames); which record produced this Entry's fields is record-level. Splitting on that line keeps screens consistent and provenance stable across winner flips (Decision 5, Q61).

Every write sets both halves through one helper

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift

Why it matters. Without this, the app's own writes would recreate the nil-relationship state the migration exists to eliminate — and backup import would certify stores nothing could repair.

What to look at. siteForWrite(hostname:context:); capture, createWork, moveEntry, re-parse, teaching commits, materializeV4Payload

Takeaway. Req 1.4's 'both halves written together' got a single enforcement point instead of ten conventions; moveEntry uses the Entry's own row rather than re-fetching the current winner, so a flipped winner cannot split an Entry from its new Work.
Rationale. The write path and the pass must agree on the winner (both route through SiteResolutionOrder), pinned by a winner-agreement test (Q41, Q44, Decision 2).

A genuine 4.0.0-recorded store as a committed fixture

Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/v4-recorded-4.0.0.sqlite

Why it matters. After the freeze, nothing in the repo could write a 4.0.0 store — so no test could distinguish 'the freeze is faithful' from 'the freeze is self-consistent'. This store is the only artifact that proves hash-fidelity against what actually shipped.

What to look at. v4-recorded-4.0.0.sqlite + v4-recorded-4.0.0-scale.sqlite (432 entries, duplicate-hostname rows), V4RecordedStoreTests, and the interruption-state construction in V5CertificationPathTests

Takeaway. Generate migration fixtures from the pre-change commit in a detached worktree, checkpoint the WAL, and commit the store — reading the code and concluding the snapshot matches is exactly the reasoning this repo's history warns against.
Rationale. Q20's probe established the failure mode is total (store refuses to open), so fidelity had to be proven by opening a real pre-conversion store, not by inspection (review round 1, B2).

Key decisions

Model the references as relationships, keep every string (Decision 1) The persistence layer can resolve, defer, and heal a reference it knows about; it can do none of that for a string. Keeping the strings makes the relationship derived data — re-runnable migration, unchanged archive format, capture-time evidence intact.
Only Entry.site and Work.site; cited rules resolve through the Site (Q11) Eight citation pairs would need eight relationships plus eight CloudKit-mandated to-many inverses. The Site relationship already reaches the rules; what the union was actually working around was that a hostname string could not say which row to search.
Write sites land before relationship reads (Decision 2) Caught in review: backup import rewrote a certified library with all-nil relationships and no repair path. Re-sequencing (dependencies, not renumbering) made the broken state unreachable instead of adding repair machinery for it.
A nil site tolerates; everything else still fails closed (Decision 3) Only the resolution clause is demoted, and only in the tolerant pass — import gates and write-commit validation are unchanged. Post-sync, nil means 'not yet arrived'; quarantining it would disable capture and export for the normal case.
Fixtures model the sync-shaped graph, not the pass (Decision 4) The hostname-winner pass never meets a duplicated hostname with split citation ownership in production (duplicates cannot exist at migration time; sync delivers pointers, not hostnames). Fixtures pin records inline to their citing row — running the pass over artificial duplicates had pinned Entries to rows that do not own their citations.
Presentation follows the hostname winner; provenance follows the record (Decision 5) Mode, title cleaning and actions are hostname-level teaching questions; provenance is record-level. The alternative — everything relational — costs 5,000 to-one faults on Recent's 2 s budget and lets two screens disagree about the same Entry.
The 10 s migration budget breach is recorded, then accepted (Decision 6, Q60) 17.3–17.75 s at the 5,000-entry single-Site worst case, cause measured (inverse-array maintenance, ~n^1.65). Not fixed: batching is the trade Req 2.4's atomicity is built on. Accepted by the owner — the real library is <200 notes, where the curve puts the pass near 0.1 s.
certifyMigration publishes the marker before cleanup (Q36) Inverted from the review's first suggestion: deleting the V3 marker and sidecar before publishing opens a crash window leaving a converted, nonempty store with no marker at all — a state the bootstrap rejects as unverifiable with no way back.

Review findings

SeverityAreaFindingResolution
majorRecentPresentation / EntryDetailThe citation-replay applicability guard was duplicated with a difference: a nil-site citing Entry got 'rule missing' attention in Recent but rendered healthy in Entry detail — a divergence that goes user-visible the moment mirroring ships, contradicting Decision 5's 'agree by construction'.Whole predicate (including nil-site → notApplicable) moved into the shared replayCitedPattern; replayRecentCandidate deleted; ruling recorded as Q61; new test pins both surfaces.
majorV5RelationshipPassTestsThe Req 2.2 flagship losslessness test ran a no-op pass: the fixture pre-links every relationship since task 19, so the identity guard skipped every assignment and the assertions tested the fixture's writes, not the pass.Relationships stripped after seeding; pre-pass linked count asserted 0, post-pass 5,000/1,000 from fresh containers; false comment deleted.
majorV4Bootstrap / V4Migration / V5RelationshipPassThe hostname→winner map existed in four copies (two byte-identical) after the branch deleted the one generic helper covering the shape; the certification tail with its load-bearing Q36 ordering was copy-pasted between both certification paths.SiteResolutionOrder.winnersByHostname extracted (3 of 4 sites converted; validator's loop legitimately different); runPassAndCertify extracted so the ordering exists once.
majorCHANGELOG.mdThe Unreleased section was self-contradicting once V5 landed in the same release: 'migrates to schema V4', a Fixed bullet describing the union search this branch deletes, the first-launch cost buried, and the CloudKit entitlements carry-in unmentioned.Upgrading retargeted to V5 with the one-time cost as its own bullet; the M4a bullet rewritten to point at the new mechanism; entitlements line added.
minorLibraryRepository write sitesFind-or-insert-Site was written three ways, one fetching the same hostname twice in one transaction with a comment justifying the redundancy.One siteForWrite helper at all three sites; the second fetch and its invariant comment deleted.
minorComposedTeachingapplyComposedOutcome re-fetched a Site both callers already held under the same lock — the one place the 'resolved once' comment was untrue.Site passed as a parameter from both callers.
minorV4LibraryValidatorTwo split guards reported 'cannot resolve its retained rule' on arms that fire when the rule DID resolve but the stored extraction/sequence is absent — misdiagnosing the quarantine reason shown to the reader.Accurate per-arm reasons.
minorEntryDetailentry.site and its pattern array were faulted unconditionally on every Entry detail open, though only consumed in the minority replay branch; Recent had the lazy form.Fault moved inside the replay-eligible branch on both surfaces as part of the predicate unification.
minorspecs / design.md / requirements.mddesign.md's Schema V5 section still read as undecided (settled by Q20–Q24); resolution arithmetic stale; Req 2.6 carried no breach marker; Req 3.5 falsified for presentation by Decision 5 and unamended; OVERVIEW said plain 'Done' against the repo's 'Done — one requirement unmet' convention.Dated correction blocks added; Req 2.6/3.5 annotated; OVERVIEW row matches the convention; stale file names and citations (Q44→Q45, Q56 pipe, Decision 1 status) fixed.
minorV4Bootstrap small cleanupsA no-op nil-coalesce reintroducing a raw "4" literal; a version-parameterised publish helper with one caller and one version; duplicated CitationReplay destructuring; an inline tolerance clause the CitationContext existed to centralise; identical catch arms.All five cleaned up (candidateTitle property, resolves(citedPattern:) wrapper, folded helper, collapsed catch).
minortest helpersTempDir defined six times, config()/markerContent/writeMarker/stripRelationships/assertion helpers duplicated across the new suites, and two near-identical seeded-library fixtures.Skipped deliberately: this review's constraint forbids modifying test files beyond actual bugs; consolidation is recorded here for a future cleanup pass.
minorcapture-commit measurementThe capture COMMIT path (where the new relationship write lands, on the 100 ms budget) has no performance test; per-capture cost is proportional to the target Site's existing Entry count (~5 ms at 5,000 entries).Skipped as a code change — nothing at real library scale; recorded in the review and the fixture notes as the one missing write-path measurement.
nitM3PerformanceFixture / SiteInverseReachTests / snapshot namingNine hand-written relationship assignments where one pass call would do; the inverse-reach guard is a regex scan that a fileprivate declaration might replace; test snapshot structs shadow public type names.Skipped — fixture style is recorded as deliberate (Q43), the fileprivate experiment risks schema registration and is noted in the test file, naming is cosmetic.

Per-file diffs

Click to expand.

Asterism/Asterism.xcodeproj/project.pbxproj Modified +8 / -8
diff --git a/Asterism/Asterism.xcodeproj/project.pbxproj b/Asterism/Asterism.xcodeproj/project.pbxprojindex 803497e..23840b2 100644--- a/Asterism/Asterism.xcodeproj/project.pbxproj+++ b/Asterism/Asterism.xcodeproj/project.pbxproj@@ -402,7 +402,7 @@ 				DEVELOPMENT_TEAM = V24684SCZN; 				GENERATE_INFOPLIST_FILE = NO; 				INFOPLIST_FILE = AsterismShareExtension/Info.plist;-				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				IPHONEOS_DEPLOYMENT_TARGET = 26.0; 				LD_RUNPATH_SEARCH_PATHS = ( 					"$(inherited)", 					"@executable_path/Frameworks",@@ -431,7 +431,7 @@ 				DEVELOPMENT_TEAM = V24684SCZN; 				GENERATE_INFOPLIST_FILE = NO; 				INFOPLIST_FILE = AsterismShareExtension/Info.plist;-				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				IPHONEOS_DEPLOYMENT_TARGET = 26.0; 				LD_RUNPATH_SEARCH_PATHS = ( 					"$(inherited)", 					"@executable_path/Frameworks",@@ -475,7 +475,7 @@ 				"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";-				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				IPHONEOS_DEPLOYMENT_TARGET = 26.0; 				LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 				"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 				MACOSX_DEPLOYMENT_TARGET = 26.5;@@ -523,7 +523,7 @@ 				"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";-				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				IPHONEOS_DEPLOYMENT_TARGET = 26.0; 				LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 				"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 				MACOSX_DEPLOYMENT_TARGET = 26.5;@@ -668,7 +668,7 @@ 				CURRENT_PROJECT_VERSION = 1; 				DEVELOPMENT_TEAM = V24684SCZN; 				GENERATE_INFOPLIST_FILE = YES;-				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				IPHONEOS_DEPLOYMENT_TARGET = 26.0; 				MACOSX_DEPLOYMENT_TARGET = 26.5; 				MARKETING_VERSION = 1.0; 				PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.AsterismTests;@@ -694,7 +694,7 @@ 				CURRENT_PROJECT_VERSION = 1; 				DEVELOPMENT_TEAM = V24684SCZN; 				GENERATE_INFOPLIST_FILE = YES;-				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				IPHONEOS_DEPLOYMENT_TARGET = 26.0; 				MACOSX_DEPLOYMENT_TARGET = 26.5; 				MARKETING_VERSION = 1.0; 				PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.AsterismTests;@@ -719,7 +719,7 @@ 				CURRENT_PROJECT_VERSION = 1; 				DEVELOPMENT_TEAM = V24684SCZN; 				GENERATE_INFOPLIST_FILE = YES;-				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				IPHONEOS_DEPLOYMENT_TARGET = 26.0; 				MACOSX_DEPLOYMENT_TARGET = 26.5; 				MARKETING_VERSION = 1.0; 				PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.AsterismUITests;@@ -744,7 +744,7 @@ 				CURRENT_PROJECT_VERSION = 1; 				DEVELOPMENT_TEAM = V24684SCZN; 				GENERATE_INFOPLIST_FILE = YES;-				IPHONEOS_DEPLOYMENT_TARGET = 26.5;+				IPHONEOS_DEPLOYMENT_TARGET = 26.0; 				MACOSX_DEPLOYMENT_TARGET = 26.5; 				MARKETING_VERSION = 1.0; 				PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.AsterismUITests;
Asterism/Asterism/Asterism.Development.entitlements Modified +20 / -3
diff --git a/Asterism/Asterism/Asterism.Development.entitlements b/Asterism/Asterism/Asterism.Development.entitlementsindex baf648a..3377355 100644--- a/Asterism/Asterism/Asterism.Development.entitlements+++ b/Asterism/Asterism/Asterism.Development.entitlements@@ -1,5 +1,22 @@ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">-<plist version="1.0"><dict>-<key>com.apple.security.application-groups</key><array><string>group.me.nore.ig.Asterism.dev</string></array>-</dict></plist>+<plist version="1.0">+<dict>+	<key>aps-environment</key>+	<string>development</string>+	<key>com.apple.developer.aps-environment</key>+	<string>development</string>+	<key>com.apple.developer.icloud-container-identifiers</key>+	<array>+		<string>iCloud.me.nore.ig.Asterism.dev</string>+	</array>+	<key>com.apple.developer.icloud-services</key>+	<array>+		<string>CloudKit</string>+	</array>+	<key>com.apple.security.application-groups</key>+	<array>+		<string>group.me.nore.ig.Asterism.dev</string>+	</array>+</dict>+</plist>
Asterism/Asterism/Asterism.Personal.entitlements Modified +20 / -3
diff --git a/Asterism/Asterism/Asterism.Personal.entitlements b/Asterism/Asterism/Asterism.Personal.entitlementsindex 8fa6706..8354973 100644--- a/Asterism/Asterism/Asterism.Personal.entitlements+++ b/Asterism/Asterism/Asterism.Personal.entitlements@@ -1,5 +1,22 @@ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">-<plist version="1.0"><dict>-<key>com.apple.security.application-groups</key><array><string>group.me.nore.ig.Asterism</string></array>-</dict></plist>+<plist version="1.0">+<dict>+	<key>aps-environment</key>+	<string>development</string>+	<key>com.apple.developer.aps-environment</key>+	<string>development</string>+	<key>com.apple.developer.icloud-container-identifiers</key>+	<array>+		<string>iCloud.me.nore.ig.Asterism</string>+	</array>+	<key>com.apple.developer.icloud-services</key>+	<array>+		<string>CloudKit</string>+	</array>+	<key>com.apple.security.application-groups</key>+	<array>+		<string>group.me.nore.ig.Asterism</string>+	</array>+</dict>+</plist>
Asterism/Asterism/Info.plist Modified +8 / -4
diff --git a/Asterism/Asterism/Info.plist b/Asterism/Asterism/Info.plistindex 2899649..c8e9c56 100644--- a/Asterism/Asterism/Info.plist+++ b/Asterism/Asterism/Info.plist@@ -2,9 +2,13 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict>-    <key>AsterismAppGroupIdentifier</key>-    <string>$(ASTERISM_APP_GROUP_IDENTIFIER)</string>-    <key>AsterismStoreRelativePath</key>-    <string>$(ASTERISM_STORE_RELATIVE_PATH)</string>+	<key>AsterismAppGroupIdentifier</key>+	<string>$(ASTERISM_APP_GROUP_IDENTIFIER)</string>+	<key>AsterismStoreRelativePath</key>+	<string>$(ASTERISM_STORE_RELATIVE_PATH)</string>+	<key>UIBackgroundModes</key>+	<array>+		<string>remote-notification</string>+	</array> </dict> </plist>
Asterism/Asterism/Views/RecentView.swift Modified +5 / -2
diff --git a/Asterism/Asterism/Views/RecentView.swift b/Asterism/Asterism/Views/RecentView.swiftindex 38cdd2c..42bb4d5 100644--- a/Asterism/Asterism/Views/RecentView.swift+++ b/Asterism/Asterism/Views/RecentView.swift@@ -224,14 +224,17 @@ struct RecentEntryRow: View {     }      /// One short sentence per cause, saying what is unresolved rather than that-    /// something is. No action is offered here: for three of the four causes-    /// teaching is refused, and the route is the diagnosis screen (Req 4.1).+    /// something is. The label itself offers no action: for three of the five+    /// causes teaching is refused, and the route is the diagnosis screen+    /// (Req 4.1). `citationUnresolved` is the exception — its hostname is+    /// teachable, so the row keeps the Re-teach pill beside this label.     private static func attentionLabel(_ attention: RecentRowAttention) -> String {         switch attention {         case .siteMissing: "No site record for this address"         case .siteDuplicated: "This site is stored more than once"         case .siteRulesInvalid: "This site's saved rules are not valid"         case .workMissing: "The work this entry belongs to is missing"+        case .citationUnresolved: "The rule that named this entry is missing"         }     } 
Asterism/AsterismTests/CrossViewRefreshTests.swift Modified +6 / -2
diff --git a/Asterism/AsterismTests/CrossViewRefreshTests.swift b/Asterism/AsterismTests/CrossViewRefreshTests.swiftindex 50e8f80..ec29d7a 100644--- a/Asterism/AsterismTests/CrossViewRefreshTests.swift+++ b/Asterism/AsterismTests/CrossViewRefreshTests.swift@@ -5,7 +5,11 @@ import Testing @testable import Asterism  /// Creates a real empty V4 store and its readiness marker so the app bootstrap-/// (openV4ForApp) reaches `.ready`.+/// (openV4ForApp) reaches `.ready`. The store is built at the current schema,+/// so it is marked migrated — the extension opens no other version (Q14).+/// The store is *empty*, so there is nothing for the relationship pass to+/// populate and no record here carries a nil `site` (task 19): this is exactly+/// mark-at-birth's shape, which certifies at `"5"` and runs no pass (Q26). private func publishV3ReadyForTests(for config: LibraryConfiguration) {     let storeDir = config.v4StoreURL.deletingLastPathComponent()     try? FileManager.default.createDirectory(at: storeDir, withIntermediateDirectories: true)@@ -14,7 +18,7 @@ private func publishV3ReadyForTests(for config: LibraryConfiguration) {         try? context.save()         withExtendedLifetime(container) {}     }-    try? LibraryRepository.publishV4Readiness(at: config.v4MarkerURL)+    try? LibraryRepository.publishV5Readiness(at: config.v4MarkerURL) }  /// Tests for cross-view refresh: mutations in detail models trigger
Asterism/AsterismTests/Helpers/MockLibraryProvider.swift Modified +0 / -8
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex f5c883b..b6d52da 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -127,9 +127,6 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     var entryTeachingDetailResult: Result<EntryTeachingDetail, Error> = .failure(MockError.notConfigured)     var entryTeachingDetailCallCount = 0 -    var titlePatternResult: Result<TitlePatternSnapshot, Error> = .failure(MockError.notConfigured)-    var titlePatternCallCount = 0-     var projectInitialTeachingResult: Result<TeachingContract, Error> = .failure(MockError.notConfigured)     var projectInitialTeachingCallCount = 0 @@ -161,11 +158,6 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     var commitCaptureCallCount = 0     var lastCommittedCaptureContract: CaptureContract? -    func titlePattern(id: UUID) async throws -> TitlePatternSnapshot {-        titlePatternCallCount += 1-        return try titlePatternResult.get()-    }-     func entryTeachingDetail(id: UUID) async throws -> EntryTeachingDetail {         entryTeachingDetailCallCount += 1         return try entryTeachingDetailResult.get()
Asterism/AsterismTests/IntegrationSafetyNetTests.swift Modified +18 / -8
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftindex 90d3a1e..d077c3c 100644--- a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -249,11 +249,15 @@ struct IntegrationSafetyNetTests {         let (openResult, _) = try await LibraryRepository.openV4ForApp(config)         #expect(openResult == .ready(.zero)) -        // The extension can capture from here on. This is the guard T-1969-        // deliberately gives up: capture into a fresh library is now possible-        // before a reader restores a backup through Settings. A replace-style-        // import would discard that capture, but only behind the two-step-        // destructive confirmation, which shows the current counts first.+        // The extension can capture from here on — mark-at-birth certifies an+        // empty store at "5" (Q26), so the handoff proved here is the real+        // app→extension one, with nothing standing in for it.+        //+        // This is the guard T-1969 deliberately gives up: capture into a fresh+        // library is now possible before a reader restores a backup through+        // Settings. A replace-style import would discard that capture, but only+        // behind the two-step destructive confirmation, which shows the current+        // counts first.         let (extResult, extRepo) = try await LibraryRepository.openV4ForExtension(config)         #expect(extResult == .ready(.zero))         let captured = try await extRepo.capture(@@ -316,7 +320,9 @@ struct IntegrationSafetyNetTests {         #expect(counts.entries == 1)         #expect(counts.works == 1) -        // Extension should now work on the target configuration+        // Extension should now work on the target configuration: the fresh open+        // above created an empty store, which mark-at-birth certifies at "5"+        // (Q26), and a fill import does not republish the marker.         let (_, extRepo) = try await LibraryRepository.openV4ForExtension(targetConfig)         let observed = try await extRepo.entry(id: entry.id)         #expect(observed.note == "Import note ✓")@@ -1073,14 +1079,18 @@ private func publishV3Ready(for config: LibraryConfiguration) throws { }  /// Creates a real empty V4 store and its readiness marker so the app bootstrap-/// (openV4ForApp) reaches `.ready`.+/// (openV4ForApp) reaches `.ready`. The store is built at the current schema,+/// so it is marked migrated — the extension opens no other version (Q14).+/// The store is *empty*, so there is nothing for the relationship pass to+/// populate and no record here carries a nil `site` (task 19): this is exactly+/// mark-at-birth's shape, which certifies at `"5"` and runs no pass (Q26). private func createReadyV4Library(for config: LibraryConfiguration) throws {     let storeDir = config.v4StoreURL.deletingLastPathComponent()     try FileManager.default.createDirectory(at: storeDir, withIntermediateDirectories: true)     let container = try LibraryRepository.openV4Container(at: config.v4StoreURL)     try ModelContext(container).save()     withExtendedLifetime(container) {}-    try LibraryRepository.publishV4Readiness(at: config.v4MarkerURL)+    try LibraryRepository.publishV5Readiness(at: config.v4MarkerURL) }  private func openV3AppRepository(
CHANGELOG.md Modified +20 / -4
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 2d85c9f..cda6fde 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,9 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Upgrading -- **Your library migrates to schema V4 the first time you open the app after-  this update.** The migration is crash-safe and needs no action from you: it-  runs once, verifies itself, and marks the library ready.+- **Your library migrates to schema V5 the first time you open the app after+  this update.** A library still on an older schema traverses every intervening+  step in that same single open. The migration is crash-safe and needs no action+  from you: it runs once, verifies itself, and marks the library ready.+- **On a large library that first launch takes a noticeable moment.** The pass+  that gives every entry and work a real link to its site dominates it: measured+  at roughly 17 s for a 5,000-entry library on a Mac, which is the deliberate+  worst case — every one of those entries on a single site. A library of a few+  hundred notes is well under a second. The pass commits all at once, so an+  interrupted launch simply runs it again rather than leaving the library+  half-converted. - **Open the app once before sharing to it.** The share extension deliberately   refuses to capture until the app has completed the migration, so a share   attempted before the first launch will fail closed rather than write to a@@ -22,6 +30,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).   comes from whichever rule supplies it. Existing taught Sites carry over   without re-teaching. +### Changed++- An Entry and a Work now carry a modelled relationship to their Site (schema V5) alongside the hostname strings they have always recorded. The relationship is the reference the app will act on; the strings remain capture-time evidence and the archive's reference format, so backups are unaffected. The store converts to V5 the first time the app opens it after this update, and the same launch populates the relationships from the recorded hostnames — on a large library this pass dominates that first launch: five thousand entries measured ~17 s in a host release build, above the 10 s the requirements budgeted, recorded as a known issue while the device number remains unmeasured. The pass itself: every hostname resolves through the same deterministic row selection the app uses everywhere else, records whose hostname matches no Site keep a nil relationship as a tolerated state, and the whole pass commits as one save with the readiness marker published only afterwards — an interrupted launch simply re-runs the pass and converges. The share extension declines to capture into a library until that certification has happened; a fresh, empty library is certified immediately and shares from first launch. After certification, every write the app makes — capture, creating a Work, moving an entry, teaching commits, and backup import — sets the relationship together with the hostname it has always recorded, selecting the Site row the same way the migration does, so the two halves cannot drift apart.+- Resolving a rule an Entry cites now looks only among the rules owned by the Site record the Entry itself points at, instead of searching the union of every row sharing the hostname — with each record carrying its own reference, the ambiguity that union existed to bridge is gone. Which Site presents a screen is unchanged: mode, title cleaning, and offered actions still follow the hostname's current teaching, while a record's recorded provenance now survives any change in which row that is.+- Recent and an Entry's detail screen now render a row whose cited title pattern cannot be resolved — marked as needing attention, with the cited pattern's identity kept visible as evidence — instead of the one unresolvable citation failing the whole feed or screen. Re-teaching the site remains available on such a row, because re-teaching is the repair.+ ### Fixed  - Fixed a new library becoming permanently unopenable, and removed the first-run setup screen that caused it. A fresh install used to create its library and then withhold the marker that certifies it until you answered "import a backup or start empty" — a question with nothing behind it, since there was no library to import into yet. Anything writing in that gap left a library the app could no longer classify, and every later launch refused to open it with no way back short of deleting the app. The library is now marked the moment it is created, so a fresh install opens straight into an empty library and the question is gone. One consequence worth knowing: the share extension now works from first launch instead of waiting for that answer, so a page shared before you restore a backup will be in the library when you do — restoring still shows you what it is about to discard and still asks twice. Importing a backup is unchanged and still lives in Settings. A library that is nonempty but uncertified is still refused rather than guessed at, which is the case that check was written for; a certified library whose store file has gone now says so instead of quietly starting you over on an empty one.@@ -30,7 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Fixed Teach being offered on sites where teaching is refused. A site with duplicate rows no longer shows a Teach action in Recent or on an entry's detail screen, and its entries no longer count toward the "needs teaching" total — tapping that total previously filtered to a list of rows carrying no action. The rows still appear and are still marked as needing attention; the diagnostics screen is the route for them. A site whose stored rules are merely inconsistent is unaffected and still offers Teach, because re-teaching is exactly what repairs it. - Fixed teaching being offered on sites where it could not be saved. Teaching, the Work-only transition, and the URL identity review now decline up front on a site with duplicate rows — where teaching cannot be trusted and could not have been committed anyway — instead of accepting the work and failing at the end. A site that simply has no stored row is unaffected and still teachable; it also repairs itself the next time you capture from that host. - Fixed Recent, Entry detail and Work Merge failing entirely because of one unresolvable record. Recent now emits a row it cannot fully resolve rather than refusing the whole feed — identified by its capture title, or the Work's display title, and marked as needing attention, with the cause being either no Site row for the hostname or a missing referenced Work. Such a row offers no Teach action, because teaching a hostname with no Site row was itself a dead end. A Site whose stored rules are internally inconsistent no longer breaks the feed either; it renders without a mode instead. Entry detail and Work Merge resolve a single row where they previously asserted there was exactly one. Merge is deliberately more conservative than the rest: it commits where the records involved are unaffected, and otherwise **refuses with a stated reason** rather than merging part of a duplicated set — previously it could have moved one twin's Entries and deleted it while the other survived.-- Fixed an Entry's recorded provenance appearing and disappearing when a hostname has more than one Site row. Which row "wins" depends on what each one has been taught, so a teaching commit elsewhere could flip it — and a pattern belonging to the row that lost became unfindable, making the affected Entries' replay fail until the winner happened to flip back. Resolving a pattern or rule an Entry already cites now searches every Site row for that hostname, so it keeps resolving regardless of which row currently wins. Applying rules to a *new* capture still uses the winning row only, which is the one case where picking a single row is the point.+- Fixed an Entry's recorded provenance appearing and disappearing when a hostname has more than one Site row. Which row "wins" depends on what each one has been taught, so a teaching commit elsewhere could flip it — and a pattern belonging to the row that lost became unfindable, making the affected Entries' replay fail until the winner happened to flip back. Provenance no longer flickers that way. The mechanism is the one described under Changed: a record now carries its own relationship to the Site whose rules it cites, and resolution follows that fixed reference instead of searching every row sharing the hostname. Applying rules to a *new* capture still uses the winning row only, which is the one case where picking a single row is the point. - Fixed a single damaged record locking you out of the whole library. Three states that previously refused the open now degrade instead: an Entry or Work whose hostname has no Site row, more than one Site row for a hostname, and two records sharing an application identifier. The validator gained a tolerant entry point that records these as diagnoses rather than throwing, and the identity lookups resolve a deterministic winner instead of giving up — `fetchSites`, `fetchEntry`, `fetchWork` and the pattern lookup no longer cap their fetch at two rows, which previously made three or more duplicates unresolvable in principle. Capture from the share extension now succeeds in all three states, and a re-share update against a duplicated Entry saves instead of reporting the capture invalid. Backup **import** is deliberately unaffected: all three import gates now call a strict entry point that keeps today's exact behaviour, so an incoherent archive is still refused. States outside the tolerated set are unchanged — an unrecognised stored value or an unreadable store still fails closed, and a blank hostname or Work title still quarantines that hostname. - Fixed the performance suites reporting success without measuring anything. Four independent defects stacked: the opt-in gate was written `ASTERISM_RUN_PHYSICAL_PERFORMANCE=1 $(PIPEFAIL) <cmd>` and `PIPEFAIL` expands to `set -o pipefail;`, so the assignment applied to `set` and never reached the test process, which then skipped the whole suite and reported green; the device targets could not have passed the gate to the XCTest runner regardless, because `xcodebuild` forwards only `TEST_RUNNER_`-prefixed variables; a passing run printed no number, because `#expect` reports only on failure, leaving nothing to record a baseline from; and the `Asterism Personal` scheme's test action listed the `AsterismTests` unit bundle, which needs `ENABLE_TESTABILITY` — set only on `Development` — so it failed to compile and cancelled the whole test action, including the UI suites. The M4 suite additionally ran in debug, and cannot compile in release without `-DASTERISM_PERFORMANCE_TESTING`, since the fixture it depends on is guarded on `DEBUG`. No measured performance number predating this change should be trusted. The M4 suite now records a distribution — min, median, p95, max and spread — rather than a single value, and asserts the median against the budget on every run while keeping the p95 tail check for controlled runs. The previous statistic was the second-slowest of twenty samples, so one scheduling hiccup set the recorded number: three consecutive runs of unchanged code spanned 0.7389 s to 1.2789 s and breached the budget once. - Fixed the unsettled-chapters acknowledgment's confirm button being unreachable to VoiceOver and to UI tests: an accessibility identifier on the container without a matching containment trait collapsed the subtree and hid the controls inside it.@@ -158,6 +172,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Changed +- Enabled the iCloud (CloudKit) capability on both app configurations, each paired with its own container, along with the remote-notification background mode that CloudKit's change signalling depends on. This is groundwork for the mirroring milestone and nothing syncs yet — the app still opens its store with mirroring off. The share extensions deliberately carry no iCloud entitlement, so a later change that tried to sync from the extension fails at container load instead of quietly exporting the same records twice.+- Lowered the minimum iOS version from 26.5 to 26.0. The design document has always said iOS 26+, but every build configuration pinned 26.5, so the app refused to install on a device running any earlier 26.x — which is how this was found: a second device on 26.2 was ruled ineligible outright. Nothing in the code depended on an API newer than 26.0; both configurations build and the unit suite passes unchanged. - Split the planned M4 CloudKit milestone into three specs and specified the first of them, Library Integrity Tolerance. The original plan assumed the hazard of enabling sync before reconciliation was duplicate records; the larger hazard is missing ones. `Entry.hostname` and `Work.siteHostname` are plain strings rather than modelled relationships, so CloudKit cannot preserve the ordering the store-level validator assumes, and an Entry arriving before its Site — the expected transient state of every sync — currently throws at store level and leaves the library unopenable in both processes. That is also circular, since the app cannot run the reconciliation that would repair the graph while the graph prevents the store opening. M4a therefore makes three states degrade instead of failing (absent Site row, more than one Site row per hostname, duplicate application UUIDs): seventeen throw sites demote to recorded diagnoses, ambiguous identity lookups resolve to a deterministic winner, cited pattern ids resolve across all rows for a hostname so provenance replay survives duplication, a diagnosis surface makes the state visible, and re-teaching can clear a diagnosis it fixes — which it currently cannot, because the teaching commit compares post-commit diagnoses with no pre-commit baseline. No CloudKit, no schema change, no archive-format change; every requirement is verifiable offline. M4b then enables mirroring and M4c reconciles duplicates, so the reconciler is written against duplicates actually observed rather than guessed at. - Recorded two constraints for M4b found while specifying M4a. Only the app will mirror: TN3164's "Avoid synchronizing a store with multiple persistent containers" names the app-and-extension-share-a-store case directly, each container keeps its own export history token so both processes can export one object twice, and an extension is terminated too soon after completing its request for an asynchronous export to finish anyway. And the backup half of that milestone is an archive-format change rather than a policy change: export self-validates by decoding its own bytes, that decode runs the reference validator, and duplicate Site rows cannot be represented at all because `BackupV4Site` is keyed by hostname and every reference to a Site is a hostname string. Probing `initializeCloudKitSchema` established that the V4 and V3 schemas need no change to mirror, leaving CKRecord round-tripping of the Codable-struct (composite) attributes as M4b's first task. - Restructured the design document's milestone section to match the shipped state: M1–M3 marked shipped, the unplanned M3.5 Unified Teaching Composition recorded as its own entry because it consumed schema V4 and the `.m4` capability gate, and a warning that milestone labels and gate names of the same number no longer refer to the same work. The document header now states that implementation is under way and that the per-milestone specs in `specs/` are the newer record wherever they contradict it — a precedence rule that did not previously exist, and which several sections now need (§2.3 describes duplicate handling as pairs, while M4c must handle three or more).
CLAUDE.md Modified +3 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex 1b8c21f..04a0aac 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -43,11 +43,13 @@ invocations where a target exists. - `make test-core` — AsterismCore package tests (host, fast, safe) - `make test-quick` — unit-test bundle only (simulator) - `make test` / `make test-ui` — full suites (simulator)-- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run+- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **Takes ~30 minutes** since the V4→V5 migration measurement joined it: each sample is ~17 s of migration plus a ~13 s reset that has to be committed and reopened to be a pre-pass graph at all (Q58 of `specs/relational-references`). - `make test-performance`, `make test-performance-m4-recent` — **physical device, see above**  The `-m4` and `-m4-recent` targets are easy to confuse and only one of them is safe: `test-performance-m4` is a `swift test` run of the `AsterismCore` package on the host, while `test-performance-m4-recent` builds the `Personal` configuration and installs it over the real app on a phone. +If you run `swift test` by hand instead of through the Makefile, pass `--no-parallel`. It is load-bearing, not a flakiness preference: mixed-schema suites in one parallel process fight over SwiftData's global entity registry and die with `NSUnknownKeyException`. See `docs/agent-notes/testing.md`.+ There is no linter or formatter configured in this repo — no `.swiftformat`, `.swiftlint.yml`, or `.swift-format`, and no `make lint`/`format` target. A clean `make test-core` with no new compiler warnings is the pre-commit bar.
Makefile Modified +13 / -1
diff --git a/Makefile b/Makefileindex 8fe0f33..fa9efeb 100644--- a/Makefile+++ b/Makefile@@ -65,6 +65,9 @@ help:  .PHONY: test-core # Swift package tests are not members of the app scheme's test plan.+# `--no-parallel` is load-bearing, not a speed choice: parallel `swift test`+# crashes the run with `NSUnknownKeyException: the entity Site is not key value+# coding-compliant for the key "entries"` (Q34, docs/agent-notes/testing.md). test-core: 	$(PIPEFAIL) swift test --package-path Packages/AsterismCore --no-parallel $(if $(CORE_TEST),--filter '$(CORE_TEST)',) $(PIPE_PRETTY) @@ -223,6 +226,15 @@ test-performance-m4-recent: # ASTERISM_RUN_PHYSICAL_PERFORMANCE=1, so the default `make test-core` never runs # them. Run this target to exercise the budgets (Req 8.5, 6.5, Q9). #+# It also carries the relational-references scale work: store-level validation+# (Req 5.3) in M4ScalePerformanceTests, and the V4 -> V5 relationship migration+# against its 10 s budget (Req 2.6) in M4MigrationScalePerformanceTests.+#+# Budget your time: one RUNS=1 pass takes ~30 minutes, most of it in the+# migration suite. Every sample there is ~17 s of migration plus a ~13 s reset+# that has to be committed and reopened to be a pre-pass graph at all, and there+# is no shortcut that does not turn the measurement into one of a warm store.+# # Set PERFORMANCE_LOG to collect the measured distributions into a file. Each # line carries median, p95, min, max and the max/min spread. #@@ -263,7 +275,7 @@ test-performance-m4: 			--no-parallel \ 			-c release \ 			-Xswiftc -DASTERISM_PERFORMANCE_TESTING \-			--filter 'M4(ScalePerformance|ToleratedScalePerformance|ToleratedFixture)Tests' \+			--filter 'M4(ScalePerformance|ToleratedScalePerformance|ToleratedFixture|MigrationScalePerformance)Tests' \ 			|| exit $$?; \ 	done 
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV3.swift Modified +11 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV3.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV3.swiftindex 3cef15b..9b298b5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV3.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV3.swift@@ -13,6 +13,17 @@ import SwiftData /// ("Entry", "Site", …) as the live V4 classes without a top-level collision: the /// only top-level references are typealiases (Decision 6). `buildSidecar` reads /// the M3 interpretation + trim off these frozen classes before conversion.+///+/// # Frozen *by reference*, not only by file+///+/// The nesting freezes the class bodies; it does **not** freeze the value types+/// they store. `URLIdentityRule`, `JunkSuffixRule`, `WorkTitleTrimRule`,+/// `SegmentRangeSpec` and `SegmentPositionSpec` are live top-level types in+/// `ValueObjects.swift`, shared with the V4 snapshot and the live V5 classes.+/// Editing the stored shape of any of them silently redefines this frozen+/// schema, and per Q20 a redefined frozen schema makes a store recorded at that+/// version refuse to open (134504). A stored-shape change to any of them+/// therefore needs new versioned copies nested here, not an edit in place. public enum AsterismSchemaV3: VersionedSchema {     public static let versionIdentifier = Schema.Version(3, 0, 0) 
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swift Modified +142 / -26
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swiftindex 1f82b3a..733a174 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swift@@ -1,17 +1,37 @@ import Foundation import SwiftData -/// The M4 runtime schema (Decision 6) — the schema the app and the share-/// extension actually open.+/// The frozen M4 schema (Decision 6, Q20). Its model classes are immutable+/// snapshots of the M4 shape: `Site` has already lost `titleInterpretationRaw`+/// and `workTitleTrimRule`, and no relational reference exists yet — `Entry` and+/// `Work` name their Site only by string. ///-/// The live model classes are V4's: `Site` no longer carries-/// `titleInterpretationRaw` or `workTitleTrimRule`, and every reader resolves-/// naming and trims from the Site's active title rule instead. The pre-M4 shape-/// survives only as the frozen nested snapshots in `AsterismSchemaV3`, which-/// exist to give the migration plan a `from` version. Freezing V3 and dropping-/// the two columns landed together (Decision 6): the freeze is not implementable-/// while the live classes are still V3's, and the drop is inseparable from-/// rewriting the readers of those columns.+/// V4 is frozen because *any* edit to its body makes a V4-recorded store refuse+/// to open: `addPersistentStore` fails with `NSCocoaErrorDomain` 134504,+/// "Cannot use staged migration with an unknown model version" (Q20, probed).+/// The live classes therefore moved to `AsterismSchemaV5`, and this declaration+/// exists only to give the migration plan a `from` version for stores recorded+/// at 4.0.0.+///+/// The classes are nested here so they can carry the same SwiftData entity names+/// ("Entry", "Site", …) as the live V5 classes without a top-level collision: the+/// only top-level references are typealiases (Decision 6). Nothing reads a+/// V4-shaped object at runtime — unlike the V3 snapshot, whose dropped columns+/// `buildSidecar` still reads — so these carry stored columns only.+///+/// # These snapshots are frozen *by reference*, not only by file+///+/// The nesting freezes the class bodies; it does **not** freeze the value types+/// they store. `SegmentRangeSpec`, `SegmentPositionSpec`, `URLIdentityRule` and+/// `JunkSuffixRule` are live top-level types in `ValueObjects.swift`, shared with+/// the V3 snapshot and with the live V5 classes. Editing any of them changes the+/// stored shape of this frozen schema silently — and per Q20 that is exactly what+/// makes a recorded store refuse to open (134504).+///+/// So: **any change to the stored shape of those four types requires new+/// versioned copies of them**, nested beside these classes and repointed here,+/// rather than an edit in place. Adding a computed member or a method to them is+/// safe; adding, removing or retyping a stored property is not. public enum AsterismSchemaV4: VersionedSchema {     public static let versionIdentifier = Schema.Version(4, 0, 0) @@ -20,23 +40,119 @@ public enum AsterismSchemaV4: VersionedSchema {     } } -/// `[V3, V4]` migration plan. The V3 → V4 schema change is lightweight — the-/// M4-additive `Entry`/`TitlePattern` columns are nullable/defaulted, so-/// SwiftData adds them automatically for a real pre-M4 store, and declaring the-/// path lets a V3-recorded store open under the V4 schema.-///-/// The Work-only → whole-title-rule data transformation and the durable-sidecar-/// crash-safety (Decision 3) are **not** carried by a SwiftData custom stage: a-/// custom stage does not fire between structurally shared schemas, and it would-/// also run inside the share extension (which must never migrate — Req 5.4). The-/// bootstrap (`openV4ForApp`) instead writes the sidecar and runs the completion-/// pass itself, app-only, under the exclusive lock. See `V4Migration`.-public enum AsterismV4MigrationPlan: SchemaMigrationPlan {-    public static var schemas: [any VersionedSchema.Type] {-        [AsterismSchemaV3.self, AsterismSchemaV4.self]+extension AsterismSchemaV4 {+    @Model+    public final class Entry {+        public var id: UUID = UUID()+        public var captureTitle: String = ""+        public var captureTitleSourceRaw: String = CaptureTitleSource.manual.rawValue+        public var rawURLString: String = ""+        public var canonicalURLString: String?+        public var hostname: String = ""+        public var entryIdentityKey: String = ""+        public var identityKeyVersion: Int = 1+        public var conservativeIdentityKey: String = ""+        public var identityBasisRaw: String = EntryIdentityBasis.conservative.rawValue+        public var identityURLRuleID: UUID?+        public var identityURLRuleVersion: Int?+        public var identityNameTitleRuleID: UUID?+        public var identityNameTitleRuleVersion: Int?+        public var urlWorkIdentity: String?+        public var urlWorkRuleID: UUID?+        public var urlWorkRuleVersion: Int?+        public var chapterSequence: String?+        public var chapterSequenceRuleID: UUID?+        public var chapterSequenceRuleVersion: Int?+        public var chapterTitle: String?+        public var chapterTitleProvenanceRaw: String = FieldProvenanceKind.none.rawValue+        public var chapterPatternID: UUID?+        public var chapterPatternVersion: Int?+        public var note: String = ""+        public var ratingRaw: String?+        public var firstCapturedAt: Date = Date(timeIntervalSince1970: 0)+        public var lastSharedAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var work: Work?+        public var workAssignmentProvenanceRaw: String = FieldProvenanceKind.none.rawValue+        public var workPatternID: UUID?+        public var workPatternVersion: Int?+        public var workURLRuleID: UUID?+        public var workURLRuleVersion: Int?+        public var workURLAssignmentKindRaw: String?+        public var intentionallyUnattached: Bool = false++        public init() {}+    }++    @Model+    public final class Work {+        public var id: UUID = UUID()+        public var displayTitle: String = ""+        public var lastParsedTitle: String?+        public var siteHostname: String = ""+        public var urlIdentity: String?+        public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue+        public var urlIdentityRuleID: UUID?+        public var urlIdentityRuleVersion: Int?+        public var workURLString: String?+        public var genericNotes: String = ""+        public var typeRaw: String = WorkType.other.rawValue+        public var genreTags: [String] = []+        public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+        @Relationship(deleteRule: .nullify, inverse: \Entry.work)+        public var entries: [Entry]?++        public init() {}+    }++    @Model+    public final class Site {+        public var hostname: String = ""+        public var displayName: String = ""+        public var modeRaw: String = SiteMode.untaught.rawValue+        @Relationship(deleteRule: .cascade, inverse: \TitlePattern.site)+        public var patterns: [TitlePattern]?+        @Relationship(deleteRule: .cascade, inverse: \URLRulePattern.site)+        public var urlRules: [URLRulePattern]?+        public var urlIdentityRule: URLIdentityRule?+        public var junkSuffixRule: JunkSuffixRule?++        public init() {}     } -    public static var stages: [MigrationStage] {-        [.lightweight(fromVersion: AsterismSchemaV3.self, toVersion: AsterismSchemaV4.self)]+    @Model+    public final class TitlePattern {+        public var id: UUID = UUID()+        public var version: Int = 1+        public var isActive: Bool = false+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var formRaw: String = PatternForm.segment.rawValue+        public var segmentWorkAnchor: SegmentRangeSpec?+        public var segmentIgnoredAnchors: [SegmentPositionSpec]?+        public var phrasePrefix: String?+        public var phraseSeparator: String?+        public var phraseSuffix: String?+        public var fieldOrderRaw: String?+        public var trimPrefix: String?+        public var trimSuffix: String?+        public var chapterless: Bool = false+        public var site: Site?++        public init() {}+    }++    @Model+    public final class URLRulePattern {+        public var id: UUID = UUID()+        public var version: Int = 1+        public var isCurrent: Bool = false+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var originRaw: String = URLRuleOrigin.readerTaught.rawValue+        public var definitionData: Data = Data()+        public var site: Site?++        public init() {}     } }
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift Added +54 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swiftnew file mode 100644index 0000000..d61d4ce--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift@@ -0,0 +1,54 @@+import Foundation+import SwiftData++/// The M4a runtime schema — the schema the app and the share extension actually+/// open. Its body is `Models.swift`, which opens `extension AsterismSchemaV5`.+///+/// V5 adds the two relational references this milestone exists for:+/// `Entry.site` and `Work.site`, both optional, with the `.nullify` inverses+/// `Site.entries` and `Site.works`. The hostname strings stay beside them as+/// capture-time evidence (Q3). Nothing is removed, so the V4 → V5 change is+/// lightweight.+///+/// The inverses are `internal` while `Site` is `public` (Q17): CloudKit requires+/// every relationship to have one, but traversing `Site.entries` faults every+/// Entry for a hostname, which the Req 5.1 budgets would pay for. There is+/// deliberately no `entryValues` / `workValues` accessor beside `patternValues`+/// / `urlRuleValues`.+public enum AsterismSchemaV5: VersionedSchema {+    public static let versionIdentifier = Schema.Version(5, 0, 0)++    public static var models: [any PersistentModel.Type] {+        [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self]+    }+}++/// `[V3, V4, V5]` migration plan, replacing `AsterismV4MigrationPlan`. Both+/// stages are lightweight: V3 → V4 adds the M4-additive columns and drops the+/// two Site columns, V4 → V5 adds the two relationships and their inverses. A+/// V3-recorded store traverses both stages in a single open (Q22).+///+/// Both stages are needed and both must be structurally real. Freezing V4 with a+/// V5 that is a verbatim copy of it aborts the process on the first real+/// migration with `NSInvalidArgumentException`, "Duplicate version checksums+/// detected."; dropping the V4 → V5 stage instead fails the open with+/// `NSCocoaErrorDomain` 134504, "Cannot use staged migration with an unknown+/// coordinator model version." The relationships are what make V5 distinct, so+/// the freeze and the relationships ship together (Q22).+///+/// The data pass that populates the relationships is **not** a SwiftData custom+/// stage: a custom stage would also run inside the share extension, which must+/// never migrate (Req 2.3). The app bootstrap runs it under the exclusive lock,+/// as V4's completion pass already does. See `V4Migration`.+public enum AsterismV5MigrationPlan: SchemaMigrationPlan {+    public static var schemas: [any VersionedSchema.Type] {+        [AsterismSchemaV3.self, AsterismSchemaV4.self, AsterismSchemaV5.self]+    }++    public static var stages: [MigrationStage] {+        [+            .lightweight(fromVersion: AsterismSchemaV3.self, toVersion: AsterismSchemaV4.self),+            .lightweight(fromVersion: AsterismSchemaV4.self, toVersion: AsterismSchemaV5.self),+        ]+    }+}
Packages/AsterismCore/Sources/AsterismCore/CitedRuleResolution.swift Deleted +0 / -72
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CitedRuleResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/CitedRuleResolution.swiftdeleted file mode 100644index 4f064cd..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/CitedRuleResolution.swift+++ /dev/null@@ -1,72 +0,0 @@-import Foundation-import SwiftData--// Decision 9, and the half of it that is easy to lose.-//-// A hostname carrying more than one Site row has **two** resolution rules, and-// which one applies depends on what the caller is asking:-//-// - **Applying rules to a new capture** — which title rule parses this title,-//   which URL rule derives this identity — uses the winning row only. Two rows-//   can own conflicting *current* rules, the ambiguity is genuine, and-//   `SiteResolutionOrder` picking one is the honest resolution. That side lives-//   in `IdentityResolution.swift`.-//-// - **Resolving a pattern or rule id a record already cites** — provenance-//   replay in the validator, Entry detail disclosure, Recent's candidate-//   replay, `titlePattern(id:)` — searches the **union** of every Site row for-//   the hostname. There is no ambiguity to resolve: exactly one record carries-//   that id, and which row happens to own it says nothing about the Entry's-//   provenance. This file is that side.-//-// **Do not collapse these into one rule.** It is the obvious simplification and-// it reintroduces a bug the design already paid for. The winner is-// *content-dependent* — measurement showed a teaching commit on either row-// flipping it immediately — so a winner-only cited lookup makes an Entry's-// replay resolve, then fail, then resolve again as unrelated teaching lands,-// with no diagnosis that could explain it. A union is order-independent by-// construction, so it is *more* deterministic than the winner-only form, not-// less. See Decision 9, Req 2.6, and Q18 (which narrowed Req 2.2 because of it).-//-// Cost: this is a read-path concern only. Every entry point below is O(1) or-// short-circuits for a single row before touching a relationship, so neither-// capture nor extension open pays for it (Decision 10).--/// Resolves rule ids that a record **already cites**, across every Site row for-/// the hostname. The counterpart to `SiteResolutionOrder`, which answers the-/// other question — see the file comment before merging them.-public enum CitedRuleResolution {--    /// Whether a cited `TitlePattern` resolves for a record on `hostname`: true-    /// when **any** Site row for that hostname owns it.-    ///-    /// Replaces the winner-only `pattern.site === site` identity test. For a-    /// hostname with one row the two are equivalent; for a duplicated hostname-    /// only this form survives a winner flip.-    public static func resolves(_ pattern: TitlePattern, forRecordsOn hostname: String) -> Bool {-        pattern.site?.hostname == hostname-    }--    /// Whether a cited `URLRulePattern` resolves for a record on `hostname`.-    /// Same rule, same reason.-    public static func resolves(_ rule: URLRulePattern, forRecordsOn hostname: String) -> Bool {-        rule.site?.hostname == hostname-    }--    /// Every title pattern retained by any of `rows`, which are the Site rows for-    /// one hostname, winner first. Callers replaying an Entry's cited pattern-    /// search this rather than the winner's own `patternValues`.-    ///-    /// Returns the single row's own array untouched when there is nothing to-    /// union, so the ordinary library allocates nothing extra.-    public static func retainedPatterns(across rows: [Site]) -> [TitlePattern] {-        guard rows.count > 1 else { return rows.first?.patternValues ?? [] }-        return rows.flatMap(\.patternValues)-    }--    /// Every URL rule retained by any of `rows`. Same shape, same fast path.-    public static func retainedURLRules(across rows: [Site]) -> [URLRulePattern] {-        guard rows.count > 1 else { return rows.first?.urlRuleValues ?? [] }-        return rows.flatMap(\.urlRuleValues)-    }-}
Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swift Modified +26 / -6
diff --git a/Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swiftindex 3d92a00..ac359e0 100644--- a/Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swift@@ -19,12 +19,17 @@ import SwiftData  /// Orders the Site rows sharing one hostname, winner first. ///-/// **This answers one of two questions, not both** (Decision 9). The winner is-/// what *applying rules to a new capture* uses, where two rows can own-/// conflicting current rules and the ambiguity is genuine. Resolving a pattern-/// or rule id a record **already cites** must not come through here: see-/// `CitedRuleResolution`, which searches the union of the rows instead, and the-/// file comment there for why merging the two reintroduces a bug.+/// **This answers one of two questions, not both** — Decision 9 of+/// `specs/library-integrity-tolerance`, as amended by Decision 5 of+/// `specs/relational-references`. The winner is what *presentation* and+/// *applying rules to a new capture* use: which teaching governs this hostname+/// today, where two rows can own conflicting current rules and the ambiguity is+/// genuine (Req 3.1, 3.3). Resolving a pattern or rule id a record **already+/// cites** must not come through here: it resolves among the rules the citing+/// record's own Site relationship owns (Req 3.2). The winner is+/// *content-dependent* — a teaching commit on either row flips it — so routing a+/// cited lookup through it made an Entry's provenance replay resolve, then fail,+/// then resolve again as unrelated teaching landed. public enum SiteResolutionOrder {      /// Total order, stable across processes and relaunches for one store file.@@ -35,6 +40,21 @@ public enum SiteResolutionOrder {         return sites.map(SiteOrderKey.init).sorted(by: precedes).map(\.site)     } +    /// The winning row for every hostname present in `sites`, keyed by hostname.+    ///+    /// The shape every whole-store pass needs: the migration passes resolve each+    /// record's hostname through it, and Recent presents each hostname through+    /// it. One helper so a second one cannot drift into a different winner rule+    /// — the `sitesByHost[hostname] = site` last-write-wins map the V4 pass once+    /// built is exactly what Q16 removed.+    public static func winnersByHostname(_ sites: [Site]) -> [String: Site] {+        var winners: [String: Site] = [:]+        for (hostname, rows) in Dictionary(grouping: sites, by: \.hostname) {+            winners[hostname] = sorted(rows).first+        }+        return winners+    }+     /// The comparator behind `sorted`, exposed for the order-algebra tests.     /// Recomputes both rows' keys, so `sorted` uses the memoised form instead.     internal static func precedes(_ lhs: Site, _ rhs: Site) -> Bool {
Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift Modified +0 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftindex 593a662..3088f17 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -10,7 +10,6 @@ public protocol LibraryProviding: Sendable {     func entry(id: UUID) async throws -> EntrySnapshot     func work(id: UUID) async throws -> WorkSnapshot     func workDestinations(for entryID: UUID) async throws -> [WorkSnapshot]-    func titlePattern(id: UUID) async throws -> TitlePatternSnapshot      /// Coherent entry teaching detail: site mode, pattern summaries, settlements,     /// available actions, and unresolved replay. Built in one locked context.
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swiftindex 29f3c01..a4f0068 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift@@ -165,7 +165,7 @@ extension LibraryRepository {             return .stale(reason: "expected ready store for replacement, but state changed")         } -        try validateV4MarkerContent(at: configuration.v4MarkerURL)+        try validateMarkerContentForApp(at: configuration.v4MarkerURL)          let container: ModelContainer         do {
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportV4.swift Modified +12 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportV4.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportV4.swiftindex bf6a461..8563c11 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportV4.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportV4.swift@@ -15,7 +15,10 @@ extension LibraryRepository {     static func validateImportPlanPayloadV4(         _ payload: BackupV4Payload     ) throws -> LibraryRecordCounts {-        let schema = Schema(versionedSchema: AsterismSchemaV4.self)+        // The live schema, not the frozen `AsterismSchemaV4`: `materializeV4Payload`+        // inserts live classes, and after the V4 freeze those are different+        // entities from V4's snapshots (Q20).+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)         let configuration = ModelConfiguration(             schema: schema,             isStoredInMemoryOnly: true,@@ -100,6 +103,13 @@ extension LibraryRepository {             work.titleProvenanceRaw = record.titleProvenance.rawValue             work.modifiedAt = record.modifiedAt             context.insert(work)+            // Req 2.5: an archive references its Site by hostname, so the+            // relationship is derived from exactly that — the same map the+            // rules above are wired from. Nothing republishes the readiness+            // marker after an import, so the relationship pass never runs over+            // what this writes; if it were left nil here it would stay nil+            // forever (Decision 2).+            work.site = sitesByHostname[record.siteHostname]             worksByID[record.id] = work         } @@ -144,6 +154,7 @@ extension LibraryRepository {             entry.workURLAssignmentKindRaw = record.workURLAssignmentKind?.rawValue             entry.intentionallyUnattached = record.intentionallyUnattached             context.insert(entry)+            entry.site = sitesByHostname[record.hostname]         }     } }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift Modified +9 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swiftindex ec3aa9e..e92f079 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift@@ -170,7 +170,7 @@ extension LibraryRepository {              // 6. Apply the composed outcome to Entries and Works.             try Self.applyComposedOutcome(-                context: context, hostname: hostname, outcome: currentOutcome,+                context: context, hostname: hostname, site: site, outcome: currentOutcome,                 titleRuleID: titleRuleID, titleVersion: titleVersion,                 titleDefinition: contract.request.titleDefinition,                 titleTrimPrefix: Self.nonEmpty(contract.request.trimPrefix),@@ -305,7 +305,7 @@ extension LibraryRepository {             }              try Self.applyComposedOutcome(-                context: context, hostname: hostname, outcome: currentOutcome,+                context: context, hostname: hostname, site: site, outcome: currentOutcome,                 titleRuleID: activePattern.id, titleVersion: activePattern.version,                 titleDefinition: contract.request.titleDefinition,                 titleTrimPrefix: Self.nonEmpty(contract.request.trimPrefix),@@ -477,8 +477,13 @@ extension LibraryRepository {     /// Writes the composed outcome to the live Entries and Works: identity keys,     /// sequences, chapter titles, provenance, Work-identity dispositions, and     /// prospective-Work creation. Shared by composed teaching and recalculation.+    /// `site` is the row every Work this call creates points at (Req 1.4): the+    /// caller's own row, resolved once under the lock it already holds, so a+    /// composed commit and the relationship pass cannot disagree about a+    /// duplicated hostname and no second lookup can drift from the first.     static func applyComposedOutcome(-        context: ModelContext, hostname: String, outcome: ComposedTeachingOutcome,+        context: ModelContext, hostname: String, site: Site,+        outcome: ComposedTeachingOutcome,         titleRuleID: UUID, titleVersion: Int, titleDefinition: PatternDefinition,         titleTrimPrefix: String?, titleTrimSuffix: String?,         url: (id: UUID, version: Int, definition: URLRuleDefinition)?, timestamp: Date@@ -498,6 +503,7 @@ extension LibraryRepository {         var createdByKey: [ProspectiveWorkKey: Work] = [:]         for intent in outcome.prospectiveWorks {             let work = Work(displayTitle: intent.displayTitle.value, siteHostname: hostname, timestamp: timestamp)+            work.site = site             work.lastParsedTitle = intent.lastParsedTitle.value             work.titleProvenanceRaw = TitleProvenance.parsed.rawValue             if case .urlIdentity(let identity) = intent.key, let url {
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift Modified +3 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swiftindex 7e2f6a8..6fd9f6a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift@@ -324,6 +324,9 @@ extension LibraryRepository {                 work.lastParsedTitle = title                 work.titleProvenanceRaw = TitleProvenance.parsed.rawValue                 context.insert(work)+                // Both halves in the same save (Req 1.4); `site` is this+                // commit's `fetchSites().first`.+                work.site = site                 createdWorks[title] = work             } 
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift Modified +54 / -52
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swiftindex bea8d2f..98d03c4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift@@ -16,12 +16,19 @@ extension LibraryRepository {             let entrySnap = try Self.snapshot(entry)             let hostname = entrySnap.hostname -            // Req 2.1: this guard asserted `sites.count == 1` and threw in **two**-            // tolerated states — more than one row, and none — failing the whole-            // detail screen for either. Both now resolve: `fetchSites` returns the-            // rows in `SiteResolutionOrder` with the winner first, and a hostname-            // with no row is simply an untaught hostname (Q12), which is a state-            // every path already handles.+            // **Presentation follows the hostname winner; provenance follows the+            // relationship** (Decision 5 of `specs/relational-references`).+            // Which mode this hostname is in, what it currently teaches, and+            // which actions it offers are hostname-level questions — teaching+            // refuses a duplicated hostname at every entry point, so "what does+            // this hostname's teaching say" has one answer per hostname, and it+            // must be the same answer Recent gives for the same Entry. The+            // citation replay below is the record-level half and follows+            // `entry.site` instead.+            //+            // `fetchSites` returns the rows in `SiteResolutionOrder` with the+            // winner first, and a hostname with no row is simply an untaught+            // hostname (Q12), which is a state every path already handles.             let sites = try Self.fetchSites(hostname: hostname, context: context)             let site = sites.first             let siteMode: SiteMode@@ -37,13 +44,15 @@ extension LibraryRepository {                 siteMode = .untaught             } -            // Two searches, deliberately (Decision 9). `allPatterns` is the-            // winning row's own tuple: what it currently teaches, and what the-            // summaries below disclose. `citedPatterns` is the union across-            // every row for the hostname, and is what a provenance replay of an-            // id the Entry already recorded searches — see the replay below.+            // Two searches, deliberately (Decision 5). `allPatterns` is the+            // winning row's own tuple: what the hostname currently teaches, and+            // what the summaries below disclose. What a provenance replay of an+            // id the Entry already recorded searches is what the Entry's *own*+            // Site retains (Req 3.2) — see the replay below, which faults that+            // relationship only when there is a citation to replay. A fixed+            // pointer, so the replay cannot change as unrelated teaching flips+            // the winner.             let allPatterns = site?.patternValues ?? []-            let citedPatterns = CitedRuleResolution.retainedPatterns(across: sites)             let activePatterns = allPatterns.filter(\.isActive)             let isWorkOnly = site?.isWorkOnlyTitleRule ?? false             switch siteMode {@@ -107,7 +116,7 @@ extension LibraryRepository {                 hasValue: entrySnap.chapterTitle != nil             ) -            let assignmentSettlement = Self.buildFieldSettlement(+            let baseAssignmentSettlement = Self.buildFieldSettlement(                 provenance: entrySnap.workAssignmentProvenance,                 fieldName: "assignment",                 hasValue: entrySnap.workID != nil,@@ -142,46 +151,39 @@ extension LibraryRepository {                 : Self.computeAvailableActions(siteMode: siteMode, isWorkOnly: isWorkOnly)              // An unresolved assignment is replayed with the exact retained pattern-            // referenced by assignment provenance, never whichever pattern is active now.-            // The id is one the Entry already cites, so the search is the union-            // of the hostname's Site rows and not the winner's own patterns-            // (Decision 9) — otherwise this replay would start failing the-            // moment a teaching commit elsewhere flipped which row wins.+            // referenced by assignment provenance, never whichever pattern is+            // active now. The id is one the Entry already cites, so the search+            // is the Entry's own Site's retained patterns (Req 3.2) — a fixed+            // pointer, so this replay cannot start failing the moment a teaching+            // commit elsewhere flips which row wins the hostname.             //-            // The two throws below are NOT caught locally — they propagate out and-            // fail the whole detail screen. Unreachable for the three tolerated-            // states (this branch requires a resolved Site, and the union covers-            // every row for the hostname), so it holds today. It is a sharp edge-            // for whoever widens that set: this call site does not look like it-            // needs attention when the set grows, and it does. The same applies to-            // `replayRecentCandidate` in `+RecentPresentation.swift`.-            let unresolvedCandidateTitle: String?-            if !sites.isEmpty,-               entrySnap.workID == nil,-               entrySnap.workAssignmentProvenance.kind == .pattern,-               !entrySnap.intentionallyUnattached {-                guard let patternID = entrySnap.workAssignmentProvenance.patternID,-                      let patternVersion = entrySnap.workAssignmentProvenance.patternVersion,-                      let producingPattern = citedPatterns.first(where: {-                          $0.id == patternID && $0.version == patternVersion-                      }) else {-                    throw LibraryRepositoryError.corruptLibrary(-                        operation: "entry teaching detail replay",-                        reason: "Unresolved assignment references a missing retained pattern"-                    )-                }-                let parseResult = TitlePatternApplicator.apply(definition: try producingPattern.definition, to: entrySnap.captureTitle)-                switch parseResult {-                case .success(let parsed):-                    unresolvedCandidateTitle = parsed.workTitle-                case .failure(let error):-                    throw LibraryRepositoryError.corruptLibrary(-                        operation: "entry teaching detail replay",-                        reason: "Retained assignment pattern cannot reproduce its candidate title: \(error)"-                    )-                }+            // **Both arms of this replay used to throw, and neither throw was+            // caught locally — they propagated out and failed the whole detail+            // screen.** Resolution follows `entry.site`, so a nil relationship+            // reaches the failure branches on the first hydration. An+            // 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+            // 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+            // so is not applicable rather than unresolvable, now lives.+            let replay = Self.replayCitedPattern(+                for: entrySnap, citingSite: { entry.site })+            let assignmentSettlement: FieldSettlement+            if replay == .unresolvable,+               let patternID = entrySnap.workAssignmentProvenance.patternID,+               let patternVersion = entrySnap.workAssignmentProvenance.patternVersion {+                assignmentSettlement = .patternUnsettled(+                    patternID: patternID,+                    version: patternVersion,+                    reason: "Cited title pattern does not resolve")             } else {-                unresolvedCandidateTitle = nil+                // Includes the citation that carries no identity at all:+                // `buildFieldSettlement` already names that as corrupt, and+                // repeating it here would be a worse description of the same fact.+                assignmentSettlement = baseAssignmentSettlement             }              return EntryTeachingDetail(@@ -192,7 +194,7 @@ extension LibraryRepository {                 chapterSettlement: chapterSettlement,                 assignmentSettlement: assignmentSettlement,                 availableActions: availableActions,-                unresolvedCandidateTitle: unresolvedCandidateTitle,+                unresolvedCandidateTitle: replay.candidateTitle,                 // With no Site row there is no cleaning or trimming to apply, so                 // the immutable capture title is the presentation title.                 displayTitle: site.map {
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift Modified +81 / -83
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swiftindex 80bf8fa..54c22e6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift@@ -26,27 +26,39 @@ extension LibraryRepository {             recentPerformanceSignposter.endInterval("RecentPublication", signpostState)         }         return try await withLockedContext(mode: .shared, operation: "building Recent presentation") { context in-            let entries = try context.fetch(FetchDescriptor<Entry>()).map(Self.snapshot)+            // The records travel with their snapshots because a candidate replay+            // resolves a pattern id the Entry already cites among the rules the+            // Entry's *own* Site owns (Req 3.2) — `entry.site`, a relationship+            // the snapshot deliberately does not carry.+            let entries = try context.fetch(FetchDescriptor<Entry>())+                .map { (snapshot: try Self.snapshot($0), record: $0) }             let works = try context.fetch(FetchDescriptor<Work>())             let sites = try context.fetch(FetchDescriptor<Site>())              let workTitles = Self.recentWorkTitles(works)-            // Decision 9: `sitesByHostname` resolves the row whose *current*-            // teaching drives the row's mode and presentation, while a candidate-            // replay resolves a pattern id the Entry already cites — which any-            // row for the hostname may own. Two questions, one grouping, both-            // computed once for the whole publication rather than per Entry.-            let siteRowsByHostname = Self.recentSiteRowsByHostname(sites)-            let sitesByHostname = siteRowsByHostname.compactMapValues {-                SiteResolutionOrder.sorted($0).first-            }-            let citedPatternsByHostname = siteRowsByHostname.mapValues(-                CitedRuleResolution.retainedPatterns(across:))+            // `sitesByHostname` resolves the row whose *current* teaching drives+            // the row's mode and presentation — a genuine winner-selection+            // question, since the Entry may point at a different row than the+            // one whose teaching presents the hostname today.+            //+            // **Presentation follows the hostname winner; provenance follows the+            // relationship** (Decision 5 of `specs/relational-references`). This+            // is the presentation half, and Entry detail resolves it the same+            // way, so the two screens cannot disagree about the same Entry. The+            // provenance half is `replayCitedPattern` below, which searches+            // `entry.site`'s own patterns. Converting this to a per-Entry+            // `record.site` read would also charge 5,000 to-one faults to a path+            // on a 2 s budget (Req 5.1).+            // More than one row for a hostname is one of the three tolerated+            // states, so this resolves a winner rather than throwing.+            let sitesByHostname = SiteResolutionOrder.winnersByHostname(sites)             // `compactMapValues` drops the hostnames whose winning row retains an             // illegal tuple, so a missing mode and a missing row look the same             // here and are told apart by `sitesByHostname` below.             let siteModesByHostname = sitesByHostname.compactMapValues(Self.validatedRecentSiteMode)-            let sortedEntries = entries.sorted(by: Self.recentEntryOrder)+            let sortedEntries = entries.sorted {+                Self.recentEntryOrder($0.snapshot, $1.snapshot)+            }             // Req 3.4, read from the same list `requireNoDuplicateSiteRows` reads             // rather than from the fetched rows, so the rows that offer an action             // and the commit that accepts one cannot disagree about which@@ -58,7 +70,7 @@ extension LibraryRepository {             var rowsByDay: [(day: Date, rows: [RecentPresentationRow])] = []             var actionableCount = 0 -            for entry in sortedEntries {+            for (entry, record) in sortedEntries {                 // Req 2.1 and 2.2: a row that cannot be resolved is still emitted,                 // identified by its capture title and marked with what is wrong.                 // Each of these three used to throw and take the whole publication@@ -69,14 +81,16 @@ extension LibraryRepository {                 let missingWork = entry.workID != nil && workDisplayTitle == nil                 let isDuplicatedHostname = duplicatedHostnames.contains(entry.hostname) -                // One row, one cause. The Site causes rank first because they also-                // explain why the row carries no mode and therefore no action.+                // One row, one cause — the Site-shaped half of it, since the fifth+                // cause is only known once the citation has been replayed below.+                // These rank first because they also explain why the row carries+                // no mode and therefore no action.                 // Duplication outranks an illegal tuple, inverting Q24's order for                 // the quarantine payload: there the tuple reason wins because it is                 // the actionable one, but on a duplicated hostname re-teaching is                 // refused, so naming the tuple would promise a repair that is not                 // available.-                let attention: RecentRowAttention? =+                let siteAttention: RecentRowAttention? =                     if site == nil { .siteMissing }                     else if isDuplicatedHostname { .siteDuplicated }                     else if mode == nil { .siteRulesInvalid }@@ -102,7 +116,7 @@ extension LibraryRepository {                     && (mode.map { Self.isRecentEntryActionable(entry, siteMode: $0) } ?? false)                 let actionType: RecentRowActionType                 let displayCaptureTitle: String-                let unresolvedCandidateTitle: String?+                let replay: CitationReplay                 if let site, let mode {                     // Whole-title (Work-only) Sites are re-taught through the                     // composed surface, not the Recent inline re-teach pill.@@ -117,10 +131,7 @@ extension LibraryRepository {                         siteMode: mode,                         site: site                     )-                    unresolvedCandidateTitle = try Self.replayRecentCandidate(-                        for: entry,-                        retainedPatterns: citedPatternsByHostname[entry.hostname] ?? []-                    )+                    replay = Self.replayCitedPattern(for: entry, citingSite: { record.site })                 } else {                     actionType = .none                     // Immutable capture input, so it is always available — which is@@ -128,9 +139,16 @@ extension LibraryRepository {                     // satisfiable for a row nothing else resolves.                     displayCaptureTitle = entry.captureTitle                     // No trustworthy rules to replay the citation against.-                    unresolvedCandidateTitle = nil+                    replay = .notApplicable                 } +                // The Site causes still rank first — they explain why a row+                // carries no mode and therefore no action, which an unresolvable+                // citation does not. The two cannot collide in any case: a replay+                // only happens once a Site and mode resolved, and only for an+                // Entry holding no Work, which is what `.workMissing` requires.+                let attention = siteAttention ?? (replay == .unresolvable ? .citationUnresolved : nil)+                 let row = RecentPresentationRow(                     id: entry.id,                     entry: entry,@@ -139,7 +157,7 @@ extension LibraryRepository {                     workDisplayTitle: workDisplayTitle,                     // Sequence-only chapters present their sequence (Req 8.12).                     chapterTitle: entry.chapterTitle ?? entry.chapterSequence,-                    unresolvedCandidateTitle: unresolvedCandidateTitle,+                    unresolvedCandidateTitle: replay.candidateTitle,                     siteMode: mode,                     isActionable: actionable,                     actionType: actionType,@@ -208,26 +226,6 @@ extension LibraryRepository {         return result     } -    /// Every Site row for each hostname, in fetch order. More than one row for a-    /// hostname is one of the three tolerated states, so this no longer throws.-    ///-    /// The publication asks two different questions of these rows and both are-    /// answered from this one grouping (Decision 9):-    ///-    /// - *Which row wins?* — `SiteResolutionOrder.sorted($0).first`. The winner-    ///   is what the row's mode and presentation are read against.-    /// - *What may a cited id resolve to?* —-    ///   `CitedRuleResolution.retainedPatterns(across:)`, the union across every-    ///   row, since any of them may own a pattern an Entry already cites.-    ///-    /// Collapsing the two onto the winner would make an Entry's candidate replay-    /// depend on which row currently wins.-    private static func recentSiteRowsByHostname(_ sites: [Site]) -> [String: [Site]] {-        var rowsByHostname: [String: [Site]] = [:]-        for site in sites { rowsByHostname[site.hostname, default: []].append(site) }-        return rowsByHostname-    }-     /// The Site's mode when its committed tuple is legal, and **nil when it is     /// not**.     ///@@ -283,56 +281,56 @@ extension LibraryRepository {         )     } -    /// **Still throws, and the throw is not caught locally — it propagates out of-    /// the publication.** That is deliberate and currently safe: for the three-    /// tolerated states of Req 1.1 these branches are unreachable, because the-    /// caller only reaches here once a Site row has resolved, and-    /// `CitedRuleResolution` searches *every* row for the hostname, so a-    /// duplicated Site cannot hide a cited pattern (Decision 9).+    /// Replays the title pattern an Entry already cites, for the unresolved+    /// 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).     ///-    /// It is a sharp edge for whoever widens the tolerated set. Decision 4 closes-    /// that set deliberately, so this holds today — but a fourth incoherent shape-    /// reaching this path would let one bad Entry fail the whole Recent-    /// publication again, which is the failure this milestone existed to remove.-    /// This call site does not look like it needs attention when the set grows.-    /// It does. The same applies to `+EntryDetail.swift`'s citation replay.-    private static func replayRecentCandidate(+    /// **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+    /// nil-Site test, so one Entry whose relationship had not arrived rendered+    /// as a broken citation on Recent and as a healthy one on the detail screen+    /// for the same record. A nil Site yields `.notApplicable` on both — the+    /// consequence Decision 5 states: the evidence is absent, not broken, and+    /// teaching the hostname is the repair.+    ///+    /// **This used to throw `corruptLibrary`, and the throw was not caught+    /// locally — it propagated out of the whole publication and out of the whole+    /// detail screen.** Resolution follows `entry.site`, so a nil relationship+    /// reaches these branches on the first hydration — 2,995 of 3,000 Entries at+    /// the CloudKit probe's peak — and one such Entry would have failed all of+    /// Recent. So the replay reports rather than throws (Q13, Req 3.4).+    ///+    /// `citingSite` is a closure, and the cheap snapshot fields are tested+    /// before it is called, so the relationship is faulted only for the rows+    /// that actually replay a citation and not for every row on screen.+    internal static func replayCitedPattern(         for entry: EntrySnapshot,-        retainedPatterns: [TitlePattern]-    ) throws -> String? {+        citingSite: () -> Site?+    ) -> CitationReplay {         guard entry.workID == nil,               entry.workAssignmentProvenance.kind == .pattern,-              !entry.intentionallyUnattached else {-            return nil+              !entry.intentionallyUnattached,+              let site = citingSite() else {+            return .notApplicable         }         guard let patternID = entry.workAssignmentProvenance.patternID,               let patternVersion = entry.workAssignmentProvenance.patternVersion,-              let pattern = retainedPatterns.first(where: {+              let pattern = site.patternValues.first(where: {                   $0.id == patternID && $0.version == patternVersion-              }) else {-            throw LibraryRepositoryError.corruptLibrary(-                operation: "replaying Recent unresolved assignment",-                reason: "Entry '\(entry.id)' references a missing retained pattern"-            )-        }--        let definition: PatternDefinition-        do {-            definition = try pattern.definition-        } catch {-            throw LibraryRepositoryError.corruptLibrary(-                operation: "replaying Recent unresolved assignment",-                reason: "Entry '\(entry.id)' references an invalid retained pattern: \(error)"-            )+              }),+              let definition = try? pattern.definition else {+            return .unresolvable         }         switch TitlePatternApplicator.apply(definition: definition, to: entry.captureTitle) {         case .success(let result):-            return result.workTitle-        case .failure(let error):-            throw LibraryRepositoryError.corruptLibrary(-                operation: "replaying Recent unresolved assignment",-                reason: "Entry '\(entry.id)' retained pattern cannot reproduce its candidate: \(error)"-            )+            return .replayed(result.workTitle)+        case .failure:+            // A retained pattern that cannot reproduce its own candidate is as+            // unresolvable to the reader as an absent one, and says the same+            // thing: this row's citation no longer explains its title.+            return .unresolvable         }     } 
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift Modified +46 / -34
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swiftindex 0fadf04..0f182e0 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift@@ -11,8 +11,12 @@ extension LibraryRepository {             let entry = try Self.fetchEntry(id: entryID, context: context)             let hostname = entry.hostname -            let sites = try Self.fetchSites(hostname: hostname, context: context)-            guard let site = sites.first, site.mode == .taught else {+            // Reached from an existing Entry, so the Site is the Entry's own+            // relationship (Req 3.1, task 16) — the row whose rules produced+            // the Entry's fields, not whichever row currently wins the+            // hostname. A nil relationship reads as an untaught hostname and+            // takes the same refusal it always did.+            guard let site = entry.site, site.mode == .taught else {                 throw LibraryRepositoryError.invalidInput(                     operation: "Re-parse",                     reason: "Site is not in taught mode"@@ -72,8 +76,10 @@ extension LibraryRepository {             let entry = try Self.fetchEntry(id: contract.request.entryID, context: context)             let hostname = entry.hostname -            let sites = try Self.fetchSites(hostname: hostname, context: context)-            guard let site = sites.first, site.mode == .taught else {+            // The Entry's own row again (Req 3.1, task 16), so the commit+            // rebuilds its basis from the same Site the projection read — and+            // the Works it creates land on that row (`work.site = site` below).+            guard let site = entry.site, site.mode == .taught else {                 throw LibraryRepositoryError.invalidInput(                     operation: "Re-parse commit",                     reason: "Site is not in taught mode"@@ -150,6 +156,11 @@ extension LibraryRepository {                     work.lastParsedTitle = title                     work.titleProvenanceRaw = TitleProvenance.parsed.rawValue                     context.insert(work)+                    // Both halves in the same save (Req 1.4). `site` is the+                    // re-parsed Entry's own row, so the Work created for that+                    // Entry cannot land on a different row than the Entry it+                    // serves (the Q44 rule, one path over).+                    work.site = site                     createdWorks[title] = work                 }             }@@ -257,10 +268,15 @@ extension LibraryRepository {             // 5. Equality confirmed — NOW insert Site if absent, then apply.             let timestamp = self.clock.now() -            let existingSites = try Self.fetchSites(hostname: validated.hostname, context: context)-            if existingSites.isEmpty {-                context.insert(Site(hostname: validated.hostname))-            }+            // Hostname lookup, kept deliberately (Req 3.3, task 16): the Entry+            // being captured does not exist yet, so no record identifies a Site+            // — this asks whether the hostname has *any* row before creating+            // one, and takes the winner where it has several. Applying rules to+            // a new capture faces genuine ambiguity when two rows own+            // conflicting current rules, so one winner is the honest answer.+            // The Entry is then *assigned* to that row, so the tuple validation+            // below resolves the ids it was given within the row that owns them.+            let site = try Self.siteForWrite(hostname: validated.hostname, context: context)              let entry = Entry(                 captureTitle: contract.request.captureTitle,@@ -283,18 +299,14 @@ extension LibraryRepository {             // taught Site with no active title rule (transitional M3 Work-only)             // skips rule application (Req 9.4): the Entry saves conservatively.             //-            // Both halves of Decision 9 meet here, and they must not be merged.-            // `siteRows.first` is the winner: applying rules to a new capture-            // faces genuine ambiguity when two rows own conflicting current-            // rules, and one winner is the honest answer. The tuple validation-            // below searches the union of `siteRows` instead, because by then-            // the Entry *cites* the ids it was given.-            let siteRows = try Self.fetchSites(hostname: validated.hostname, context: context)-            let site = siteRows.first+            // Assigned here, before the tuple validation below (Req 1.4), to the+            // row resolved above — the pre-existing winner, or the one this+            // transaction inserted for a hostname that had none.+            entry.site = site             let quarantined = self.quarantineReason(hostname: validated.hostname) != nil-            if let site, site.mode == .articles {+            if site.mode == .articles {                 entry.intentionallyUnattached = true-            } else if let site, site.mode == .taught, !quarantined,+            } else if site.mode == .taught, !quarantined,                       let activePattern = site.patternValues.first(where: \.isActive) {                 let urlRecord = site.urlRuleValues.first(where: \.isCurrent)                 let url: (id: UUID, version: Int, definition: URLRuleDefinition)? =@@ -319,24 +331,20 @@ extension LibraryRepository {                 Self.applyCaptureAssignment(                     to: entry, assignment: contract.outcome.composedAssignment, derivation: derivation,                     titleRuleID: activePattern.id, titleVersion: activePattern.version, url: url,-                    allWorks: allWorks, hostname: validated.hostname, context: context, timestamp: timestamp)+                    allWorks: allWorks, hostname: validated.hostname, site: site,+                    context: context, timestamp: timestamp)             }              // Validate the written tuple only (Req 6.5, Q9): no full-graph pass.-            // The cited search space is the union of the hostname's rows, so an-            // Entry reusing a rule id from the row that did not win still-            // replays (Decision 9). Both helpers return the single row's own-            // array untouched, so the ordinary capture faults nothing extra.-            if let site {-                do {-                    try V4LibraryValidator.validateEntryTuple(-                        entry: entry, site: site, works: entry.work.map { [$0] } ?? [],-                        patterns: CitedRuleResolution.retainedPatterns(across: siteRows),-                        rules: CitedRuleResolution.retainedURLRules(across: siteRows))-                } catch {-                    context.rollback()-                    return .invalidated(reason: "capture produced an invalid Entry tuple: \(error)")-                }+            // Cited ids resolve among what `entry.site` owns (Req 3.2) — the+            // row assigned above, which is also where every id the commit just+            // wrote came from, extraction replay included.+            do {+                try V4LibraryValidator.validateEntryTuple(+                    entry: entry, site: site, works: entry.work.map { [$0] } ?? [])+            } catch {+                context.rollback()+                return .invalidated(reason: "capture produced an invalid Entry tuple: \(error)")             }              do { try self.saveStrategy.save(context) }@@ -355,7 +363,7 @@ extension LibraryRepository {     private static func applyCaptureAssignment(         to entry: Entry, assignment: ComposedAssignmentProjection?, derivation: ComposedDerivation,         titleRuleID: UUID, titleVersion: Int, url: (id: UUID, version: Int, definition: URLRuleDefinition)?,-        allWorks: [Work], hostname: String, context: ModelContext, timestamp: Date+        allWorks: [Work], hostname: String, site: Site?, context: ModelContext, timestamp: Date     ) {         switch assignment {         case .none, .protected, .noChange:@@ -384,6 +392,7 @@ extension LibraryRepository {         case .create(let key):             guard let name = derivation.workName, !M2Unicode.isBlank(name) else { break }             let work = Work(id: UUID(), displayTitle: name, siteHostname: hostname, timestamp: timestamp)+            work.site = site             work.lastParsedTitle = name             work.titleProvenanceRaw = TitleProvenance.parsed.rawValue             if case .urlIdentity(let identity) = key, let url {@@ -402,6 +411,9 @@ extension LibraryRepository {     // MARK: - Capture basis builder      private func buildCaptureBasis(hostname: String, context: ModelContext) throws -> CaptureBasis {+        // Hostname lookup, kept deliberately (Req 3.3, task 16): a capture is+        // projected from a raw URL before any Entry exists, so a hostname is+        // all there is to resolve — this is one of the two moments Q8 preserves.         let sites = try Self.fetchSites(hostname: hostname, context: context)         let site = sites.first 
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift Modified +164 / -39
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swiftindex 9250745..c66dcae 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift@@ -5,14 +5,20 @@ import SwiftData /// V4 runtime opening and the in-place migration bootstrap (Req 5.4, Decision 3). /// /// The app owns migration entirely: it writes the durable sidecar, runs the-/// completion pass, validates with `V4LibraryValidator`, and only then publishes-/// the `AsterismV4.ready` marker (containing `"4"`) and deletes the V3 marker and-/// sidecar. The extension never migrates — it requires the V4 marker + store or-/// fails closed. M3's "valid nonempty unmarked ⟹ ready" heuristic is not carried-/// into V4: unverifiable partial-migration states fail loudly.+/// completion pass and the V5 relationship pass, validates with+/// `V4LibraryValidator`, and only then publishes the readiness marker+/// (containing `"5"`, the only version the extension opens — Q14) and deletes+/// the V3 marker and sidecar. The extension never migrates — it requires the+/// marker + store or fails closed. M3's "valid nonempty unmarked ⟹ ready"+/// heuristic is not carried into V4: unverifiable partial-migration states fail+/// loudly. /// /// An *empty* store is marked ready as soon as it exists, so the app either opens /// a ready library or throws — there is no third state for the reader to resolve.+/// It is marked at `"5"`: there is nothing in it to migrate, so it is already in+/// the state the relationship pass produces (Q26). A populated library still+/// marked `"4"` — one a pre-freeze build certified — runs the relationship pass+/// on its next app open and is republished at `"5"` (Q31). public extension LibraryRepository {     /// The result of evaluating fixed-path V4 state under an exclusive lease.     enum V4OpeningResult: Equatable, Sendable {@@ -71,7 +77,8 @@ public extension LibraryRepository {                 reason: "a V3 readiness marker exists but the store is missing; "                     + "restore from a backup rather than starting an empty library")         }-        if v4Marker { try validateV4MarkerContent(at: configuration.v4MarkerURL) }+        var markerVersion: String?+        if v4Marker { markerVersion = try validateMarkerContentForApp(at: configuration.v4MarkerURL) }          // A present sidecar must verify before it can drive anything (Q25).         var validSidecar: MigrationSidecar?@@ -85,14 +92,22 @@ public extension LibraryRepository {             }         } -        // Already certified V4: validate, clean up any stale handoff, open.+        // Already certified: run the relationship pass where the marker says it+        // has not run yet, validate, clean up any stale handoff, open.         if v4Marker {             let container = try openV4Container(at: configuration.v4StoreURL)             let context = ModelContext(container)-            let diagnostics = try validateV4Store(context: context)-            // Both markers → V4 governs; delete the stale V3 marker and sidecar.-            if v3Marker { try? fileManager.removeItem(at: configuration.v3MarkerURL) }-            MigrationSidecarCodec.remove(at: configuration.migrationSidecarURL)+            // A `"4"` marker is a library the relationship pass has not run+            // over — a pre-freeze certification, or a pass an interruption cut+            // short after `ModelContainer.init` had already converted the+            // store (Q28). A `"5"` marker means the pass ran at certification+            // and does not run again (Q29, Q31).+            // A nil `markerVersion` is unreachable here and compares unequal+            // anyway, which runs the idempotent pass — the fail-safe answer.+            let needsRelationshipPass = markerVersion != extensionOpenableMarkerVersion+            let diagnostics = try runPassAndCertify(+                configuration, context: context, saveStrategy: saveStrategy,+                runPass: needsRelationshipPass)             let counts = try v3Counts(context: context)             return (.ready(counts), makeRepository(                 configuration, container, capabilities, clock, saveStrategy,@@ -176,7 +191,14 @@ public extension LibraryRepository {             }             // Marker last: a crash before this leaves an empty unmarked store,             // which this same branch marks on the next launch.-            try publishV4Readiness(at: configuration.v4MarkerURL)+            //+            // Certified at `"5"`, not `"4"`: an empty store has nothing to+            // migrate, so the relationship pass would be a no-op over it and+            // the library is already in the state the pass produces (Q26).+            // Marking it `"4"` would leave the share extension declining a+            // library that will never be migrated — the pass runs only for+            // populated stores.+            try publishV5Readiness(at: configuration.v4MarkerURL)             v4Logger.debug("Marked an empty unmarked V4 store as ready")             return (.ready(.zero), makeRepository(                 configuration, container, capabilities, clock, saveStrategy))@@ -208,7 +230,9 @@ public extension LibraryRepository {                 operation: "opening V4 library from extension",                 reason: "the containing app has not initialized the current library")         }-        try validateV4MarkerContent(at: configuration.v4MarkerURL)+        // Before any container: `ModelContainer.init` is what converts the store,+        // and this process holds only a shared lock (Q14).+        try validateMarkerContentForExtension(at: configuration.v4MarkerURL)          let container: ModelContainer         do {@@ -229,14 +253,15 @@ public extension LibraryRepository { extension LibraryRepository {     static let v4Logger = Logger(subsystem: "me.nore.ig.Asterism", category: "V4Bootstrap") -    /// Opens the fixed-path V4 container with the V4 schema and the `[V3, V4]`-    /// lightweight migration plan (adds M4-additive columns for real pre-M4-    /// stores; lets a V3-recorded store open under the V4 schema).+    /// Opens the fixed-path store with the live V5 schema and the `[V3, V4, V5]`+    /// lightweight migration plan (adds the M4-additive columns for real pre-M4+    /// stores, then the M4a relationships; a V3-recorded store traverses both+    /// stages in one open — Q22).     // `public` so the app target can open a V4 store for the isolated UI-test     // bootstrap of the composed teaching surface (Req 7.3); production opens via     // `openV4ForApp` (task 25's runtime switch).     public static func openV4Container(at storeURL: URL) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV4.self)+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)         let storeConfiguration = ModelConfiguration(             "AsterismV3", // same on-disk store name as V3 (Q13)             schema: schema,@@ -245,15 +270,19 @@ extension LibraryRepository {         )         return try ModelContainer(             for: schema,-            migrationPlan: AsterismV4MigrationPlan.self,+            migrationPlan: AsterismV5MigrationPlan.self,             configurations: [storeConfiguration]         )     } -    /// Publishes the V4 readiness marker atomically (schema version 4, Q13).-    public static func publishV4Readiness(at url: URL) throws {+    /// Publishes readiness for a migrated library (schema version 5) — the only+    /// version the share extension opens, and the only version production+    /// publishes (Q32): every certification path runs the relationship pass+    /// first, so a `"4"` marker can now only come from a pre-freeze build's+    /// library, and the app republishes it here after the pass.+    public static func publishV5Readiness(at url: URL) throws {         do {-            try Data("4\n".utf8).write(to: url, options: .atomic)+            try Data("\(extensionOpenableMarkerVersion)\n".utf8).write(to: url, options: .atomic)             try FileManager.default.setAttributes(                 [.posixPermissions: NSNumber(value: Int16(0o600))],                 ofItemAtPath: url.path)@@ -287,9 +316,10 @@ extension LibraryRepository {     }      /// Opens the V4 container (converting the store in place), runs the completion-    /// pass, validates store-level integrity, and certifies readiness — writing-    /// the V4 marker only after validation, then deleting the V3 marker and-    /// sidecar. Per-Site diagnoses do not block certification (Q29).+    /// pass and the V5 relationship pass, validates store-level integrity, and+    /// certifies readiness — writing the `"5"` marker only after validation, then+    /// deleting the V3 marker and sidecar. Per-Site diagnoses do not block+    /// certification (Q29 of the V4 spec).     static func certifyMigration(         _ configuration: LibraryConfiguration,         sidecar: MigrationSidecar,@@ -308,19 +338,19 @@ extension LibraryRepository {          do {             try V4Migration.runCompletionPass(context: context, sidecar: sidecar, now: clock.now())-        } catch let error as V4Migration.Error {-            throw LibraryRepositoryError.libraryUnavailable(-                operation: "running the migration completion pass", reason: String(describing: error))         } catch {+            // `V4Migration.Error` describes itself; anything else is a store+            // fault. Both abort the certification with the same operation.             throw LibraryRepositoryError.libraryUnavailable(                 operation: "running the migration completion pass", reason: String(describing: error))         } -        let diagnostics = try validateV4Store(context: context)--        try publishV4Readiness(at: configuration.v4MarkerURL)-        try? FileManager.default.removeItem(at: configuration.v3MarkerURL)-        MigrationSidecarCodec.remove(at: configuration.migrationSidecarURL)+        // The pass always runs here: without it, `certifyMigration` would stamp+        // `"5"` on a library whose relationships were never populated — an+        // M3-era library upgrading in one launch would certify with every+        // relationship nil.+        let diagnostics = try runPassAndCertify(+            configuration, context: context, saveStrategy: saveStrategy, runPass: true)          let counts = try v3Counts(context: context)         v4Logger.debug("V4 migration certified with \(counts.sites, privacy: .public) Sites")@@ -329,6 +359,49 @@ extension LibraryRepository {             quarantined: diagnostics.quarantineMap(), diagnostics: diagnostics))     } +    /// The certification tail both V4 open paths share, in the order Q36 pins:+    /// the relationship pass, then store validation, then the `"5"` marker, and+    /// only then the handoff evidence the marker replaces.+    ///+    /// The pass runs BEFORE `validateV4Store`: diagnostics feed the session's+    /// quarantine map, and computed first they would describe the pre-pass graph+    /// — every relationship still nil — leaving the library open under+    /// quarantines the pass had just made obsolete.+    ///+    /// The marker goes after the work and the cleanup after the marker (Q36).+    /// `"5"` is published only once the pass's one save has committed and the+    /// store validated — the "marker last" Q15 asks for, where "last" means+    /// after the migration, not after housekeeping. The stale V3 marker and the+    /// sidecar are the recovery evidence for the state this call is leaving, so+    /// they go *after* the marker that replaces them, never before.+    ///+    /// `runPass` is false only for a library the marker already reports as+    /// migrated: the pass ran at its certification and does not run again (Q29,+    /// Q31), and there is no new marker to publish for it either.+    static func runPassAndCertify(+        _ configuration: LibraryConfiguration,+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy,+        runPass: Bool+    ) throws -> LibraryDiagnostics {+        if runPass {+            do {+                try V5RelationshipPass.run(context: context, saveStrategy: saveStrategy)+            } catch {+                throw LibraryRepositoryError.libraryUnavailable(+                    operation: "running the relationship migration pass",+                    reason: String(describing: error))+            }+        }+        let diagnostics = try validateV4Store(context: context)+        if runPass { try publishV5Readiness(at: configuration.v4MarkerURL) }+        // V4 governs, so a V3 marker is stale wherever one is left. Absent on+        // most calls, which `try?` covers along with a removal that fails.+        try? FileManager.default.removeItem(at: configuration.v3MarkerURL)+        MigrationSidecarCodec.remove(at: configuration.migrationSidecarURL)+        return diagnostics+    }+     /// Store-level validation for the V4 open path. States outside Req 1.1 still     /// fail closed; the three tolerated states and every illegal Site tuple come     /// back as diagnoses, so the library opens and quarantines what it must@@ -358,20 +431,72 @@ extension LibraryRepository {         }     } -    /// Confirms the readiness marker declares schema version 4; a future or-    /// unreadable marker fails closed.-    static func validateV4MarkerContent(at url: URL) throws {+    /// Schema versions the app opens: `"4"` is a library the relationship+    /// migration has not run over yet, `"5"` one it has.+    static let appOpenableMarkerVersions: Set<String> = ["4", "5"]++    /// The only version the share extension opens (Q14).+    static let extensionOpenableMarkerVersion = "5"++    /// App side: the marker must declare a version the app opens. The migration+    /// exists precisely for libraries still at `"4"`, so demanding `"5"` here+    /// would throw on every library it exists for. A version outside the set,+    /// or an unreadable marker, fails closed. Returns the validated version so+    /// the bootstrap can tell a `"4"` library — one the relationship pass must+    /// still run over — from a migrated one (Q31).+    @discardableResult+    static func validateMarkerContentForApp(at url: URL) throws -> String {+        let version = try readMarkerVersion(at: url)+        guard appOpenableMarkerVersions.contains(version) else {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "validating V4 readiness",+                reason: "marker declares an unsupported schema version")+        }+        return version+    }++    /// Extension side: only a migrated library opens (Q14, Req 2.3).+    ///+    /// `openV4Container` is shared with the app, so `ModelContainer.init`+    /// performs the lightweight conversion in whichever process opens first.+    /// The `flock`-based lease *does* serialise the extension against a+    /// migration in progress — the app holds `LOCK_EX` while `openV4ForApp`+    /// runs — but only for as long as it is held. Once the app returns, two+    /// extension invocations can hold `LOCK_SH` concurrently and both attempt+    /// the conversion, and the extension can be invoked when the app is not+    /// running at all. Refusing here, before any container is constructed, is+    /// what keeps the conversion in the app. Accepting `"4"` as the app-side+    /// check does would defeat that entirely.+    static func validateMarkerContentForExtension(at url: URL) throws {+        let version = try readMarkerVersion(at: url)+        if version == extensionOpenableMarkerVersion { return }+        // A version the app still opens is a library awaiting migration, which+        // launching the app resolves — the shipped message says so. Anything+        // else is a marker no build understands.+        throw LibraryRepositoryError.libraryUnavailable(+            operation: "opening V4 library from extension",+            reason: appOpenableMarkerVersions.contains(version)+                ? "the containing app has not initialized the current library"+                : "marker declares an unsupported schema version")+    }++    /// Reads the schema version the readiness marker declares. An unreadable+    /// marker fails closed with a reason that says so; unrecognisable *text*+    /// comes back as itself, for the caller's accepted-set test to reject —+    /// "not text at all" and "a version no build understands" are different+    /// faults and a reader is told which one it has.+    private static func readMarkerVersion(at url: URL) throws -> String {         let data: Data         do { data = try Data(contentsOf: url) } catch {             throw LibraryRepositoryError.libraryUnavailable(                 operation: "reading V4 readiness marker", reason: String(describing: error))         }-        guard let text = String(data: data, encoding: .utf8),-              text.trimmingCharacters(in: .whitespacesAndNewlines) == "4" else {+        guard let text = String(data: data, encoding: .utf8) else {             throw LibraryRepositoryError.libraryUnavailable(-                operation: "validating V4 readiness",-                reason: "marker declares an unsupported schema version")+                operation: "reading V4 readiness marker",+                reason: "readiness marker is not readable text")         }+        return text.trimmingCharacters(in: .whitespacesAndNewlines)     }      public static func makeRepository(
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift Modified +18 / -4
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swiftindex 37abdb7..bd193e7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift@@ -316,6 +316,19 @@ extension LibraryRepository {         // what a merge would do. The winner supplies the rule when a row exists;         // a hostname with no row has no current rule to derive identities from,         // which is what an untaught hostname looks like anyway (Q12).+        //+        // This stays a hostname lookup deliberately, and **not** because no Work+        // model is in hand — `buildMergeWorkBasis` fetched both, two lines above.+        // A merge spans *two* Works, which may point at different rows of the+        // same hostname; neither relationship is privileged over the other, so+        // there is no record-shaped answer to take. The question is+        // hostname-shaped — which teaching currently governs the merge — and the+        // winner is that answer (Q54). On a duplicated hostname the merge surface+        // is refused upstream anyway: `commitMerge` sees the `.duplicateSiteRows`+        // quarantine and returns `.invalidated`, so the winner only ever governs+        // a basis the reader is being shown rather than one that commits.+        // `buildWorkURLBasis` has one Work in hand and reads `work.site`+        // (Req 3.1).         let hostname = sourceBasis.snapshot.siteHostname         let sites = try fetchSites(hostname: hostname, context: context)         let currentRules = sites.first?.urlRuleValues.filter(\.isCurrent) ?? []@@ -440,10 +453,11 @@ extension LibraryRepository {             ruleReference: reference         ) -        // Same demotion as the Merge basis: the winning row supplies the current-        // rule, and a hostname with no row simply has none.-        let sites = try Self.fetchSites(hostname: work.siteHostname, context: context)-        let currentRules = sites.first?.urlRuleValues.filter(\.isCurrent) ?? []+        // A Work is in hand, so its Site is its own relationship (Req 3.1) — no+        // winner is selected among rows sharing the hostname. A Work with a nil+        // relationship simply has no current rule, the same answer a hostname+        // with no row gives.+        let currentRules = work.site?.urlRuleValues.filter(\.isCurrent) ?? []         guard currentRules.count <= 1 else {             throw LibraryRepositoryError.corruptLibrary(                 operation: "building Work URL basis",
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift Modified +37 / -36
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex c432002..8fb4aa4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -414,13 +414,7 @@ public actor LibraryRepository {         let validated = try Self.validateCapture(draft)         Self.logger.debug("Capturing Entry for a validated Site boundary")         return try await withLockedContext(mode: .exclusive, operation: "saving capture") { context in-            let sites = try Self.fetchSites(hostname: validated.hostname, context: context)-            if sites.isEmpty {-                context.insert(Site(hostname: validated.hostname))-                Self.logger.debug("Capture creates the first Site for its hostname")-            } else {-                Self.logger.debug("Capture reuses an existing Site")-            }+            let site = try Self.siteForWrite(hostname: validated.hostname, context: context)              let timestamp = clock.now()             let entry = Entry(@@ -435,6 +429,10 @@ public actor LibraryRepository {                 rating: draft.rating             )             context.insert(entry)+            // Both halves of the Site reference in one save (Req 1.4): the+            // hostname string above is the capture-time evidence, the+            // relationship is what the app resolves through.+            entry.site = site             // Conservative capture (this convenience applies no rules): the v1 key             // and its alias are both the immutable raw URL (Q21), so the Entry is             // V4-conservative and same-URL re-shares match forever.@@ -516,11 +514,14 @@ public actor LibraryRepository {         catch { throw LibraryRepositoryError.invalidInput(operation: "validating Work hostname", reason: String(describing: error)) }          return try await withLockedContext(mode: .exclusive, operation: "creating Work") { context in-            let sites = try Self.fetchSites(hostname: hostname, context: context)-            if sites.isEmpty { context.insert(Site(hostname: hostname)) }+            let site = try Self.siteForWrite(hostname: hostname, context: context)             let timestamp = MillisecondInstant.quantize(clock.now())             let work = Work(displayTitle: draft.displayTitle, siteHostname: hostname, timestamp: timestamp)             context.insert(work)+            // The Work points at the row the lookup returned — or at the one this+            // call inserted, never at whichever row a later fetch would order+            // first (Req 1.4).+            work.site = site             do { try saveStrategy.save(context) }             catch {                 throw LibraryRepositoryError.libraryUnavailable(@@ -628,6 +629,15 @@ public actor LibraryRepository {             case .newWork(let displayTitle):                 let work = Work(displayTitle: displayTitle, siteHostname: entry.hostname, timestamp: timestamp)                 context.insert(work)+                // The Entry's *own* row, not whichever row currently wins the+                // hostname (Req 1.4, Q44). `entry.site` is already pinned; a+                // winner that has since flipped would otherwise land the new+                // Work on a different row than the Entry it was created for,+                // and this path would pay for a fetch it never needed. The+                // lookup remains only as the fallback for an Entry whose own+                // relationship never arrived — Req 2.1's tolerated state.+                work.site = try entry.site+                    ?? Self.fetchSites(hostname: entry.hostname, context: context).first                 priorWork?.modifiedAt = timestamp                 entry.work = work                 entry.intentionallyUnattached = false@@ -656,33 +666,6 @@ public actor LibraryRepository {   -    /// Fetch a title pattern by ID.-    ///-    /// This is cited-id resolution: the caller holds an id some Entry recorded.-    /// The predicate is on the application id alone, with no Site scoping, which-    /// is the union of every Site row by construction (Decision 9). **Do not-    /// narrow it to the winning row's patterns** — a pattern owned by a losing-    /// duplicate row must still resolve, and must keep resolving when a teaching-    /// commit flips which row wins. `RecordResolutionOrder` below resolves the-    /// unrelated case of two patterns sharing one application UUID.-    public func titlePattern(id: UUID) async throws -> TitlePatternSnapshot {-        try await withLockedContext(mode: .shared, operation: "reading TitlePattern") { context in-            let descriptor = FetchDescriptor<TitlePattern>(predicate: #Predicate { $0.id == id })-            let patterns = RecordResolutionOrder.sortedPatterns(try context.fetch(descriptor))-            guard let pattern = patterns.first else {-                throw LibraryRepositoryError.recordNotFound(type: "TitlePattern", id: id)-            }-            return TitlePatternSnapshot(-                id: pattern.id,-                version: pattern.version,-                isActive: pattern.isActive,-                createdAt: pattern.createdAt,-                definition: try pattern.definition,-                siteHostname: pattern.site?.hostname ?? ""-            )-        }-    }-     // MARK: - Private Teaching Helpers      internal static func applyProjection(@@ -959,6 +942,24 @@ public actor LibraryRepository {         return SiteResolutionOrder.sorted(try context.fetch(descriptor))     } +    /// The Site row a write should attach its new record to: the hostname's+    /// winner, or a freshly inserted row where the hostname has none.+    ///+    /// One helper for all three write sites (Req 1.4, Q16). `fetchSites` is+    /// `SiteResolutionOrder`, so the row returned here is the one the+    /// relationship pass would pick for the same hostname — a second selection+    /// rule at any one write site would pin a captured record to one row and a+    /// migrated one to another. The inserted row is returned directly rather+    /// than re-fetched: it is by construction the hostname's only row.+    internal static func siteForWrite(hostname: String, context: ModelContext) throws -> Site {+        if let existing = try fetchSites(hostname: hostname, context: context).first {+            return existing+        }+        let site = Site(hostname: hostname)+        context.insert(site)+        return site+    }+     internal static func fetchEntry(id: UUID, context: ModelContext) throws -> Entry {         let descriptor = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id })         let entries = RecordResolutionOrder.sortedEntries(try context.fetch(descriptor))
Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swift Modified +5 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swiftindex cd5abde..8d6d915 100644--- a/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swift@@ -134,6 +134,10 @@ extension LibraryRepository {                     work.titleProvenance = localIndex.isMultiple(of: 3) ? .manual : .parsed                     work.lastParsedTitle = localIndex.isMultiple(of: 2) ? title : nil                     context.insert(work)+                    // Both halves, as every write path sets them (Req 1.4). The+                    // fixture is guarded on an empty store, so this map holds+                    // exactly one row per hostname.+                    work.site = sitesByHostname[hostname]                     siteWorks.append(work)                     globalWorkIndex += 1                 }@@ -196,6 +200,7 @@ extension LibraryRepository {                         }                     }                     context.insert(entry)+                    entry.site = sitesByHostname[hostname]                     globalEntryIndex += 1                 }             }
Packages/AsterismCore/Sources/AsterismCore/M3PerformanceFixture.swift Modified +15 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceFixture.swiftindex 9f6e7ef..bc5a592 100644--- a/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceFixture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceFixture.swift@@ -44,6 +44,12 @@ extension LibraryRepository {              // Create an ordinary taught Site with an active title pattern and a             // current URL rule.+            //+            // This is the fixture's only Site row, and every Work and Entry the+            // phases below build carries `site` in the same save the hostname+            // string is written in (Req 1.4). Without those assignments the+            // scale suites would measure a graph with no relationships at all —+            // one the app cannot produce.             let site = Site(hostname: hostname)             site.mode = .taught             context.insert(site)@@ -108,6 +114,7 @@ extension LibraryRepository {                 work.urlIdentityRuleID = ruleID                 work.urlIdentityRuleVersion = ruleVersion                 context.insert(work)+                work.site = site                  for seqIndex in 0..<5 {                     let entryIndex = workIndex * 5 + seqIndex@@ -164,6 +171,7 @@ extension LibraryRepository {                     entry.workURLAssignmentKind = .identity                     entry.workAssignmentProvenance = .urlRule                     context.insert(entry)+                    entry.site = site                 }             } @@ -187,6 +195,7 @@ extension LibraryRepository {                 work.urlIdentity = nil                 work.urlIdentityState = .none                 context.insert(work)+                work.site = site                  for seqIndex in 0..<5 {                     let entryIndex = workIndex * 5 + seqIndex@@ -206,6 +215,7 @@ extension LibraryRepository {                     entry.identityBasis = .conservative                     entry.workAssignmentProvenance = .pattern                     context.insert(entry)+                    entry.site = site                 }             } @@ -236,6 +246,7 @@ extension LibraryRepository {                 )                 entry.identityBasis = .conservative                 context.insert(entry)+                entry.site = site             }              // --- 4. Collision entries (300 entries, 30 groups × 2 Works × 5 Entries) ---@@ -256,6 +267,7 @@ extension LibraryRepository {                     work.urlIdentityRuleID = ruleID                     work.urlIdentityRuleVersion = ruleVersion                     context.insert(work)+                    work.site = site                      for entryOffset in 0..<5 {                         let entryIndex = groupIndex * 10 + workOffset * 5 + entryOffset@@ -293,6 +305,7 @@ extension LibraryRepository {                         entry.workURLAssignmentKind = .identity                         entry.workAssignmentProvenance = .urlRule                         context.insert(entry)+                        entry.site = site                     }                 }             }@@ -312,6 +325,7 @@ extension LibraryRepository {                 work.urlIdentity = nil                 work.urlIdentityState = .none                 context.insert(work)+                work.site = site                  for entryOffset in 0..<10 {                     let entryIndex = groupIndex * 10 + entryOffset@@ -351,6 +365,7 @@ extension LibraryRepository {                     entry.workURLAssignmentKind = .identity                     entry.workAssignmentProvenance = .urlRule                     context.insert(entry)+                    entry.site = site                 }             } 
Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift Modified +10 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swiftindex 8c876c9..7b34359 100644--- a/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift@@ -98,6 +98,12 @@ extension LibraryRepository {                 )                 entry.conservativeIdentityKey = rawURL                 context.insert(entry)+                // Both halves in the same save, as `capture` does (Req 1.4).+                // Phase 2's composed commit creates the 1,000 Works and points+                // each at this same row, so the finished fixture is the graph+                // the app actually produces — which is what the scale budgets+                // are measured against.+                entry.site = site             }              let finalEntries = try context.fetchCount(FetchDescriptor<Entry>())@@ -170,6 +176,9 @@ extension LibraryRepository {                 context.insert(second)              case .siteMissing:+                // `Site.entries` and `Site.works` are `.nullify`, so deleting+                // the row leaves all 5,000 relationships nil — which is exactly+                // the state this case models and the one Req 2.1 permits.                 // Here the cascade is the point: "no Site row for this hostname"                 // means its rules are gone too, which is what an Entry that                 // arrives before its Site actually looks like. The validator@@ -203,6 +212,7 @@ extension LibraryRepository {                 )                 twin.conservativeIdentityKey = twinURL                 context.insert(twin)+                twin.site = taught             }              try saveStrategy.save(context)
Packages/AsterismCore/Sources/AsterismCore/Models.swift Modified +37 / -14
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex f0ca8f9..fdf77d1 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -1,19 +1,20 @@ import Foundation import SwiftData -// The live model classes are V4's, nested inside `AsterismSchemaV4` (Decision 6).-// Top-level typealiases keep every call site (`Entry`, `Site`, …) unchanged while-// the frozen pre-M4 snapshot `AsterismSchemaV3` carries nested classes with the-// same SwiftData entity names — legal only because there is exactly one *top-level*-// `@Model` per entity name (all model classes are nested, the top-level names are-// typealiases). Two top-level `@Model`s sharing an entity name crash `ModelContext`.-public typealias Entry = AsterismSchemaV4.Entry-public typealias Work = AsterismSchemaV4.Work-public typealias Site = AsterismSchemaV4.Site-public typealias TitlePattern = AsterismSchemaV4.TitlePattern-public typealias URLRulePattern = AsterismSchemaV4.URLRulePattern--extension AsterismSchemaV4 {+// The live model classes are V5's, nested inside `AsterismSchemaV5` (Decision 6,+// Q20). Top-level typealiases keep every call site (`Entry`, `Site`, …) unchanged+// while the frozen snapshots `AsterismSchemaV3` and `AsterismSchemaV4` carry+// nested classes with the same SwiftData entity names — legal only because there+// is exactly one *top-level* `@Model` per entity name (all model classes are+// nested, the top-level names are typealiases). Two top-level `@Model`s sharing+// an entity name crash `ModelContext`.+public typealias Entry = AsterismSchemaV5.Entry+public typealias Work = AsterismSchemaV5.Work+public typealias Site = AsterismSchemaV5.Site+public typealias TitlePattern = AsterismSchemaV5.TitlePattern+public typealias URLRulePattern = AsterismSchemaV5.URLRulePattern++extension AsterismSchemaV5 {  @Model public final class Entry {@@ -23,6 +24,12 @@ public final class Entry {     public var rawURLString: String = ""     public var canonicalURLString: String?     public var hostname: String = ""+    /// M4a: the Site this Entry was captured on, as a modelled reference (Req+    /// 1.1). `hostname` stays beside it as capture-time evidence (Q3). Nil while+    /// the Site has not arrived, or where no Site row matches — a tolerated+    /// state, not an error (Req 2.1). Written in the same save as `hostname`+    /// (Req 1.4).+    public var site: Site?     public var entryIdentityKey: String = ""     public var identityKeyVersion: Int = 1     /// M4 secondary lookup index: the conservative (v1) key for this Entry's raw@@ -126,6 +133,10 @@ public final class Work {     public var displayTitle: String = ""     public var lastParsedTitle: String?     public var siteHostname: String = ""+    /// M4a: the Site this Work belongs to, as a modelled reference (Req 1.1).+    /// `siteHostname` stays beside it as capture-time evidence (Q3). Same+    /// nil-tolerance and same-save rule as `Entry.site`.+    public var site: Site?     public var urlIdentity: String?     public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue     public var urlIdentityRuleID: UUID?@@ -175,6 +186,18 @@ public final class Site {     public var patterns: [TitlePattern]?     @Relationship(deleteRule: .cascade, inverse: \URLRulePattern.site)     public var urlRules: [URLRulePattern]?+    /// Inverse of `Entry.site`, present only because CloudKit requires every+    /// relationship to have one (Req 1.3). **Deliberately `internal`** while+    /// `Site` is `public`, so the app target cannot reach it: traversing this+    /// faults every Entry for a hostname — roughly 125× the fan-out of+    /// `Work.entries` — which the Req 5.1 budgets would pay for (Q17). No+    /// `entryValues` accessor exists, for the same reason.+    @Relationship(deleteRule: .nullify, inverse: \Entry.site)+    var entries: [Entry]?+    /// Inverse of `Work.site`. Same reasoning as `entries` — internal, no+    /// convenience accessor (Q17).+    @Relationship(deleteRule: .nullify, inverse: \Work.site)+    var works: [Work]?     /// Frozen V2 field retained only until strict legacy mapping moves it into     /// historical URLRulePattern records.     public var urlIdentityRule: URLIdentityRule?@@ -429,4 +452,4 @@ public final class URLRulePattern {     } } -} // extension AsterismSchemaV4+} // extension AsterismSchemaV5
Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift Modified +36 / -4
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swiftindex 025b973..5181495 100644--- a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift@@ -111,10 +111,12 @@ public enum RecentRowActionType: String, Equatable, Sendable { /// closed set: the row still appears, identified by its capture title, and says /// what the app cannot resolve rather than vanishing or failing the screen. ///-/// Two of these are Req 2.2's unresolvable causes; the other two are states in-/// which the row resolves but the hostname's teaching cannot be trusted, so the-/// row carries no action. Marking them is what keeps a row whose action was-/// withdrawn from reading as a settled one.+/// Two of these are Req 2.2's unresolvable causes; two more are states in which+/// the row resolves but the hostname's teaching cannot be trusted, so the row+/// carries no action. Marking them is what keeps a row whose action was+/// withdrawn from reading as a settled one. The fifth, `citationUnresolved`, is+/// the one case that keeps its action, because re-teaching is the repair rather+/// than a dead end. public enum RecentRowAttention: String, Equatable, Sendable {     /// No Site row exists for the Entry's hostname.     case siteMissing@@ -130,6 +132,36 @@ public enum RecentRowAttention: String, Equatable, Sendable {     /// re-teaching cannot clear a second row (Req 3.4). The route is the     /// diagnostics screen (Req 4.1).     case siteDuplicated+    /// The Entry cites a title pattern that resolves nowhere, so its unresolved+    /// candidate title cannot be replayed. The Site resolved, so unlike the four+    /// above this row keeps its mode and its Teach/Re-teach action: re-teaching+    /// this hostname is not refused, and it is the repair (relational-references+    /// Req 3.4, Q13).+    case citationUnresolved+}++/// The outcome of replaying a title pattern a record already cites (Req 3.4,+/// Q13). Every arm is a rendering; none is an error, which is the whole point.+///+/// Shared vocabulary: Recent turns `.unresolvable` into `.citationUnresolved`+/// and Entry detail turns it into an unsettled assignment, from one replay+/// helper, so the two surfaces cannot disagree about the same Entry.+internal enum CitationReplay: Equatable {+    /// The record cites no pattern to replay — it is settled, manually assigned,+    /// intentionally unattached, or its own Site never arrived.+    case notApplicable+    /// 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 pattern it matches cannot reproduce the candidate. Rendered as needing+    /// attention; never thrown.+    case unresolvable++    /// The replayed candidate title, and nil for every arm that produced none.+    var candidateTitle: String? {+        if case .replayed(let title) = self { title } else { nil }+    } }  // MARK: - EntryTeachingDetail DTO (Audit §3)
Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift Modified +0 / -10
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swiftindex 1cb932e..5e15cd1 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift@@ -100,16 +100,6 @@ public struct SiteSnapshot: Equatable, Sendable {     public let junkSuffixRule: JunkSuffixRule? } -public struct TitlePatternSnapshot: Equatable, Sendable {-    public let id: UUID-    public let version: Int-    public let isActive: Bool-    public let createdAt: Date-    public let definition: PatternDefinition-    public let siteHostname: String-}--  public struct DatedEntryGroup: Equatable, Sendable {     public let day: Date
Packages/AsterismCore/Sources/AsterismCore/ToleratedStateFixture.swift Modified +15 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ToleratedStateFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/ToleratedStateFixture.swiftindex b6269b4..d18186a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ToleratedStateFixture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ToleratedStateFixture.swift@@ -105,6 +105,21 @@ extension LibraryRepository {             // path would refuse every one of these shapes, which is why the             // fixture writes underneath it.             try saveStrategy.save(context)++            // Then link both relationships, exactly as certification does+            // (Req 1.4, task 19). Running the pass rather than assigning inline+            // is what makes the fixture's graph the one the app produces: it+            // resolves each hostname through `SiteResolutionOrder`, so+            // `.duplicateSiteRows` pins its Entry to the same winner a capture+            // would have, and it must run *after* the save above because a+            // temporary `PersistentIdentifier` has no defined order.+            //+            // `.siteMissing` needs no exception and is given none: its Entries+            // are on a hostname carrying no Site row, so the pass leaves their+            // relationship nil. That nil is the point of the kind — an Entry+            // whose Site is absent — and it is the one state Req 2.1 permits a+            // certified library to hold.+            try V5RelationshipPass.run(context: context, saveStrategy: saveStrategy)         }     } 
Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swift Modified +11 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swift b/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swiftindex 25d2561..c407961 100644--- a/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swift@@ -3,6 +3,17 @@ import SwiftData  /// Raw V2 store operations used only by the explicit developer migration path. /// They intentionally do not write runtime readiness; the app owns that step.+///+/// **The one write site that deliberately does not set `Entry.site` /+/// `Work.site`** (Req 1.4, Q45). Everything here happens inside a container+/// built from `Schema(versionedSchema: AsterismSchemaV2.self)` — a 2.0.0 stamp+/// over the *live* model classes — and `create` refuses to run unless the+/// destination does not exist, so the only store this type ever touches is one+/// it has just written itself and immediately reads back through+/// `readSnapshot`. That snapshot is hostname-keyed, the V2 store is a separate+/// file from the V4/V5 store, and nothing carries a V2-written relationship+/// forward. Setting the relationships here would write a V5-only column into a+/// store recorded at 2.0.0 that no reader will ever look at. public enum V2MigrationStore {     public static func artifactURLs(for storeURL: URL) -> [URL] {         [
Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swift Modified +190 / -77
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swift b/Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swiftindex 43eb80a..152f9d2 100644--- a/Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swift@@ -157,8 +157,6 @@ public enum V4LibraryValidator {         // application UUID does not also register as a broken Work/Entry inverse.         let entries = index(entryRows, RecordResolutionOrder.sortedEntries)         let works = index(workRows, RecordResolutionOrder.sortedWorks)-        let patterns = winners(patternRows, RecordResolutionOrder.sortedPatterns)-        let rules = winners(ruleRows, RecordResolutionOrder.sortedURLRules)          tolerated += duplicateIdentities(             entryRows, type: "Entry", id: \.id, hostname: \.hostname)@@ -195,7 +193,7 @@ public enum V4LibraryValidator {          for group in workRows {             guard let work = works[group.key]?.first else { continue }-            guard let site = sites[work.siteHostname] else {+            guard sites[work.siteHostname] != nil else {                 guard strictness == .tolerant else {                     throw unresolved("Work", work.id.uuidString, "Site \(work.siteHostname)")                 }@@ -203,7 +201,9 @@ public enum V4LibraryValidator {                 continue             }             do {-                try validate(work: work, site: site, entries: entries, rules: rules)+                try validate(+                    work: work, entries: entries,+                    tolerateUnlinkedCitations: strictness == .tolerant)             } catch let error as V4ValidationError {                 record(work.siteHostname, error)             }@@ -227,7 +227,9 @@ public enum V4LibraryValidator {                 continue             }             do {-                try validate(entry: entry, site: site, works: works, patterns: patterns, rules: rules)+                try validate(+                    entry: entry, site: site, works: works,+                    tolerateUnlinkedCitations: strictness == .tolerant)             } catch let error as V4ValidationError {                 record(entry.hostname, error)             }@@ -248,19 +250,102 @@ public enum V4LibraryValidator {     /// a full-graph pass (Req 6.5, Q9): capture commits validate only the tuple     /// they wrote. Throws a typed `V4ValidationError` on an illegal tuple.     ///-    /// `patterns` and `rules` are the *cited* search space, so a caller on a-    /// hostname that may carry more than one Site row passes the union of every-    /// row's rules — `CitedRuleResolution.retainedPatterns(across:)` — not the-    /// winning row's own arrays (Decision 9). `site` is still the winner: it is-    /// what the tuple's mode and ownership are read against.+    /// Every cited pattern and rule — the extraction replay included — resolves+    /// among what `entry.site` owns (Req 3.2), so there is no cited search space+    /// to pass. `site` is what the tuple's mode and ownership are read against —+    /// the same row the write site assigned (Req 1.4).     public static func validateEntryTuple(-        entry: Entry, site: Site, works: [Work], patterns: [TitlePattern], rules: [URLRulePattern]+        entry: Entry, site: Site, works: [Work]     ) throws {         var worksByID: [String: [Work]] = [:]         for work in works { worksByID[work.id.uuidString, default: []].append(work) }-        let patternsByID = Dictionary(patterns.map { ($0.id.uuidString, $0) }, uniquingKeysWith: { a, _ in a })-        let rulesByID = Dictionary(rules.map { ($0.id.uuidString, $0) }, uniquingKeysWith: { a, _ in a })-        try validate(entry: entry, site: site, works: worksByID, patterns: patternsByID, rules: rulesByID)+        // `tolerateUnlinkedCitations: false`. Req 3.4's tolerance is a property+        // of the two *open* paths: a library already on disk may hold records+        // whose Site relationship never arrived. A commit validates the tuple it+        // is about to write, and a write site sets both halves in the same save+        // (Req 1.4), so an unlinked citation here is a bug in the writer, not a+        // state to survive.+        try validate(+            entry: entry, site: site, works: worksByID,+            tolerateUnlinkedCitations: false)+    }++    // MARK: - Citation tolerance (Req 3.4, Q27) and resolution (Req 3.2)++    /// What a cited-rule site needs to know beyond the citation itself: the Site+    /// the citing record points at — the **search space** (Req 3.2) — and+    /// whether a citation that fails to resolve is tolerated because there is+    /// no such Site.+    ///+    /// The four cited-rule sites — `validate(work:…)`'s `.rule` identity,+    /// `validateV3`'s name contributor, `validateChapter`'s pattern provenance+    /// and `requiredReference` — resolve among the rules the citing record's own+    /// 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.+    private struct CitationContext {+        /// The citing record's own Site relationship: the cited search space,+        /// and — when nil — the reason a failure is tolerated.+        let citingSite: Site?+        /// Only the two open paths tolerate. The import gates run `.strict` and+        /// must keep refusing an archive that cannot resolve its own citations+        /// (Decision 3), and a commit validating what it just wrote is stricter+        /// still — see `validateEntryTuple`.+        let tolerant: Bool++        /// Whether an unresolvable citation is demoted from a tuple failure to a+        /// tolerated state.+        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.+    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+    /// 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+        }+    }++    /// 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+    /// `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+    ) -> Bool {+        citedPattern(id: id, version: version, citation) != nil || citation.toleratesUnresolved+    }++    /// The cited title pattern among the citing record's own Site's rules, or+    /// nil when it does not resolve — including when there is no Site to search.+    /// 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+    ) -> TitlePattern? {+        citation.citingSite?.patternValues.first { $0.id == id && $0.version == version }     }      // MARK: - Site (closed tuple table, supersedes M3 8.1)@@ -282,10 +367,11 @@ public enum V4LibraryValidator {         }          // `=== site` throughout this routine is deliberate and is *not* the-        // cited-id lookup Decision 9 widened. This asks whether this Site row's-        // own tuple is internally consistent; a second row's records belong to-        // that row's tuple, not to this one. Widening it to the hostname would-        // report every duplicated hostname's membership set as incomplete.+        // cited-id lookup (Req 3.2, Decision 4). This asks whether this Site+        // row's own tuple is internally consistent; a second row's records+        // belong to that row's tuple, not to this one. Widening it to the+        // hostname would report every duplicated hostname's membership set as+        // incomplete.         let patterns = site.patternValues         let rules = site.urlRuleValues         guard Set(patterns.map(\.id)) == Set(allPatterns.filter { $0.site === site }.map(\.id)),@@ -363,11 +449,12 @@ public enum V4LibraryValidator {      private static func validate(         work: Work,-        site: Site,         entries: [String: [Entry]],-        rules: [String: URLRulePattern]+        tolerateUnlinkedCitations: Bool     ) throws {         let id = work.id.uuidString+        let citation = CitationContext(+            citingSite: work.site, tolerant: tolerateUnlinkedCitations)         guard !M2Unicode.isBlank(work.displayTitle) else { throw invalid("Work", id, "display title is blank") }         guard let state = WorkURLIdentityState(rawValue: work.urlIdentityStateRaw) else {             throw invalid("Work", id, "unknown URL identity state")@@ -379,14 +466,15 @@ public enum V4LibraryValidator {                 throw invalid("Work", id, "none identity cannot carry a value or rule")             }         case .rule:-            // Cited id, so ownership spans every Site row for the hostname-            // (Decision 9) — not `=== site`, which is the winning row and would-            // make this Work's identity resolve or fail by whichever row-            // currently wins.+            // Cited id, resolved among the rules this Work's own Site owns+            // (Req 3.2) — a fixed pointer, not the hostname winner, so the+            // identity cannot resolve or fail by whichever row currently wins.+            // A Work with no Site relationship tolerates the failure instead of+            // quarantining its hostname (Req 3.4, Q27); the identity value and+            // the reference's completeness are not part of that demotion.             guard let identity = work.urlIdentity, !M2Unicode.isBlank(identity),                   let reference = completeReference(id: work.urlIdentityRuleID, version: work.urlIdentityRuleVersion),-                  let rule = rules[reference.id.uuidString], rule.version == reference.version,-                  CitedRuleResolution.resolves(rule, forRecordsOn: site.hostname) else {+                  resolves(citedRule: reference, citation) else {                 throw invalid("Work", id, "rule identity requires a resolving rule on its own site")             }         case .legacyUnverified:@@ -412,10 +500,11 @@ public enum V4LibraryValidator {         entry: Entry,         site: Site,         works: [String: [Work]],-        patterns: [String: TitlePattern],-        rules: [String: URLRulePattern]+        tolerateUnlinkedCitations: Bool     ) throws {         let id = entry.id.uuidString+        let citation = CitationContext(+            citingSite: entry.site, tolerant: tolerateUnlinkedCitations)         if let work = entry.work {             guard works[work.id.uuidString]?.contains(where: { $0 === work }) == true,                   work.siteHostname == site.hostname,@@ -431,11 +520,11 @@ public enum V4LibraryValidator {          let workReference = try validatedOptionalReference(             owner: "Entry", id: id, field: "Work extraction",-            referenceID: entry.urlWorkRuleID, version: entry.urlWorkRuleVersion, site: site, rules: rules)+            referenceID: entry.urlWorkRuleID, version: entry.urlWorkRuleVersion, citation)         let sequenceReference = try validatedOptionalReference(             owner: "Entry", id: id, field: "chapter sequence",             referenceID: entry.chapterSequenceRuleID, version: entry.chapterSequenceRuleVersion,-            site: site, rules: rules)+            citation)          switch (entry.urlWorkIdentity, workReference, entry.chapterSequence, sequenceReference) {         case (nil, nil, nil, nil):@@ -451,7 +540,9 @@ public enum V4LibraryValidator {         default:             throw invalid("Entry", id, "invalid extraction/provenance tuple")         }-        try validateExtractionReplay(entry, workReference: workReference, sequenceReference: sequenceReference, rules: rules)+        try validateExtractionReplay(+            entry, workReference: workReference, sequenceReference: sequenceReference,+            citation)          guard let basis = EntryIdentityBasis(rawValue: entry.identityBasisRaw) else {             throw invalid("Entry", id, "unknown identity basis")@@ -470,21 +561,22 @@ public enum V4LibraryValidator {             let identityReference = try requiredReference(                 owner: "Entry", id: id, field: "identity",                 referenceID: entry.identityURLRuleID, version: entry.identityURLRuleVersion,-                site: site, rules: rules)+                citation)             switch entry.identityKeyVersion {             case 2:                 try validateV2(entry, id: id, identityReference: identityReference,                     workReference: workReference, sequenceReference: sequenceReference)             case 3:-                try validateV3(entry, id: id, site: site, identityReference: identityReference,-                    workReference: workReference, sequenceReference: sequenceReference, patterns: patterns)+                try validateV3(entry, id: id, identityReference: identityReference,+                    workReference: workReference, sequenceReference: sequenceReference,+                    citation)             default:                 throw invalid("Entry", id, "URL-rule basis requires key version 2 or 3")             }         } -        try validateChapter(entry, site: site, patterns: patterns)-        try validateAssignment(entry, site: site, work: entry.work, rules: rules)+        try validateChapter(entry, site: site, citation)+        try validateAssignment(entry, site: site, work: entry.work, citation)     }      private static func validateV2(@@ -514,19 +606,28 @@ public enum V4LibraryValidator {     private static func validateV3(         _ entry: Entry,         id: String,-        site: Site,         identityReference: URLRuleReference,         workReference: URLRuleReference?,         sequenceReference: URLRuleReference?,-        patterns: [String: TitlePattern]+        _ citation: CitationContext     ) throws {+        // Cited id, resolved among the patterns this Entry's own Site owns+        // (Req 3.2). An Entry with no Site relationship tolerates a contributor+        // that does not resolve (Req 3.4, Q27) — the rest of the v3 arm is+        // unchanged.+        let v3IdentityReason =+            "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 = completeReference(id: entry.identityNameTitleRuleID, version: entry.identityNameTitleRuleVersion),-              let namePattern = patterns[nameRef.id.uuidString], namePattern.version == nameRef.version,-              // Cited id: any Site row for the hostname may own it (Decision 9).-              CitedRuleResolution.resolves(namePattern, forRecordsOn: site.hostname) else {-            throw invalid("Entry", id, "v3 identity requires a sequence rule and a resolving name contributor on its own site, with no Work identity")+              let nameRef = completeReference(id: entry.identityNameTitleRuleID, version: entry.identityNameTitleRuleVersion)+        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)+        guard namePattern != nil || citation.toleratesUnresolved else {+            throw invalid("Entry", id, v3IdentityReason)         }         let decoded: URLSequenceNameIdentity         do {@@ -536,7 +637,10 @@ public enum V4LibraryValidator {               decoded.chapterSequence == ExactScalarString(sequence) else {             throw invalid("Entry", id, "v3 key does not match its host or sequence")         }-        // Replay the embedded name from the cited title rule (Req 4.2).+        // Replay the embedded name from the cited title rule (Req 4.2). A+        // tolerated contributor leaves nothing to replay from, so the key's+        // host and sequence are all that can be checked.+        guard let namePattern else { return }         let definition: PatternDefinition         do { definition = try namePattern.definition }         catch { throw invalid("Entry", id, "v3 name contributor has an invalid definition") }@@ -552,24 +656,43 @@ public enum V4LibraryValidator {         _ entry: Entry,         workReference: URLRuleReference?,         sequenceReference: URLRuleReference?,-        rules: [String: URLRulePattern]+        _ citation: CitationContext     ) throws {         let id = entry.id.uuidString+        // The retained rule is resolved among the rules the Entry's own Site+        // owns (Req 3.2), exactly as `resolves(citedRule:)` one call up did. A+        // library-global index would leave one residual path open: an Entry with+        // a nil relationship whose cited rule id happens to exist elsewhere in+        // the store would replay against a rule it does not own, fail the replay+        // and quarantine its hostname — the state Req 3.4 forbids and the rest+        // of Decision 3 closed (Q56). A reference `requiredReference` has+        // already tolerated therefore tolerates here on the same terms, rather+        // than being re-thrown one call later (Q40).         if let workReference {-            guard let rule = rules[workReference.id.uuidString], rule.version == workReference.version,-                  let storedWork = entry.urlWorkIdentity else {+            guard let rule = citedRule(workReference, citation) else {+                if citation.toleratesUnresolved { return }                 throw invalid("Entry", id, "Work extraction replay cannot resolve its retained rule")             }+            guard let storedWork = entry.urlWorkIdentity else {+                // The rule resolved; what is missing is the extraction to+                // compare it against.+                throw invalid("Entry", id, "Work extraction replay has no stored URL extraction to replay against")+            }             let replayed = try? URLRuleApplicator.apply(rule.definition, to: ExactScalarString(entry.rawURLString))             guard let replayed, replayed.workIdentity == ExactScalarString(storedWork),                   replayed.chapterSequence == entry.chapterSequence.map(ExactScalarString.init) else {                 throw invalid("Entry", id, "stored URL extraction does not equal retained-rule replay")             }         } else if let sequenceReference {-            guard let rule = rules[sequenceReference.id.uuidString], rule.version == sequenceReference.version,-                  let storedSequence = entry.chapterSequence else {+            guard let rule = citedRule(sequenceReference, citation) else {+                if citation.toleratesUnresolved { return }                 throw invalid("Entry", id, "sequence extraction replay cannot resolve its retained rule")             }+            guard let storedSequence = entry.chapterSequence else {+                // As above: the retained rule resolved, the stored sequence it+                // would be replayed against is the part that is absent.+                throw invalid("Entry", id, "sequence extraction replay has no stored sequence")+            }             // `definition` decodes JSON on every access; read it once per Entry.             let definition = rule.definition             let replayed: ExactScalarString?@@ -587,7 +710,7 @@ public enum V4LibraryValidator {     private static func validateChapter(         _ entry: Entry,         site: Site,-        patterns: [String: TitlePattern]+        _ citation: CitationContext     ) throws {         let id = entry.id.uuidString         guard let provenance = FieldProvenanceKind(rawValue: entry.chapterTitleProvenanceRaw) else {@@ -604,11 +727,12 @@ public enum V4LibraryValidator {                 throw invalid("Entry", id, "manual chapter requires a nonblank value and no pattern")             }         case .pattern:+            // Cited id, resolved among the patterns this Entry's own Site owns+            // (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 = entry.chapterPatternID, let patternVersion = entry.chapterPatternVersion,-                  let pattern = patterns[patternID.uuidString], pattern.version == patternVersion,-                  // Cited id: any Site row for the hostname may own it (Decision 9).-                  CitedRuleResolution.resolves(pattern, forRecordsOn: site.hostname) else {+                  resolves(citedPattern: patternID, version: patternVersion, citation) else {                 throw invalid("Entry", id, "pattern chapter provenance does not resolve")             }         case .urlRule:@@ -620,7 +744,7 @@ public enum V4LibraryValidator {         _ entry: Entry,         site: Site,         work: Work?,-        rules: [String: URLRulePattern]+        _ citation: CitationContext     ) throws {         let id = entry.id.uuidString         guard let provenance = FieldProvenanceKind(rawValue: entry.workAssignmentProvenanceRaw) else {@@ -654,7 +778,7 @@ public enum V4LibraryValidator {             }             _ = try requiredReference(                 owner: "Entry", id: id, field: "assignment",-                referenceID: entry.workURLRuleID, version: entry.workURLRuleVersion, site: site, rules: rules)+                referenceID: entry.workURLRuleID, version: entry.workURLRuleVersion, citation)             switch kind {             case .identity:                 guard let value = entry.urlWorkIdentity, !M2Unicode.isBlank(value) else {@@ -674,27 +798,27 @@ public enum V4LibraryValidator {      private static func validatedOptionalReference(         owner: String, id: String, field: String,-        referenceID: UUID?, version: Int?, site: Site, rules: [String: URLRulePattern]+        referenceID: UUID?, version: Int?,+        _ citation: CitationContext     ) throws -> URLRuleReference? {         if referenceID == nil, version == nil { return nil }         return try requiredReference(             owner: owner, id: id, field: field, referenceID: referenceID, version: version,-            site: site, rules: rules)+            citation)     } -    /// Every reference resolved here is one the record **already cites**, so-    /// ownership is tested against the union of the hostname's Site rows rather-    /// than against the row that won `SiteResolutionOrder` (Decision 9). A-    /// winner-only test here is what made an Entry's provenance replay come and-    /// go as unrelated teaching flipped the winner.+    /// Every reference resolved here is one the record **already cites**, so it+    /// resolves among the rules the citing record's own Site owns (Req 3.2) — a+    /// fixed pointer rather than the `SiteResolutionOrder` winner, whose+    /// content-dependence is what made an Entry's provenance replay come and go+    /// as unrelated teaching flipped it.     private static func requiredReference(         owner: String, id: String, field: String,-        referenceID: UUID?, version: Int?, site: Site, rules: [String: URLRulePattern]+        referenceID: UUID?, version: Int?,+        _ citation: CitationContext     ) throws -> URLRuleReference {         guard let reference = completeReference(id: referenceID, version: version),-              let rule = rules[reference.id.uuidString],-              rule.version == reference.version,-              CitedRuleResolution.resolves(rule, forRecordsOn: site.hostname) else {+              resolves(citedRule: reference, citation) else {             throw unresolved(owner, id, "\(field) URL rule")         }         return reference@@ -748,17 +872,6 @@ public enum V4LibraryValidator {         Dictionary(uniqueKeysWithValues: groups.map { ($0.key, order($0.rows)) })     } -    /// The winner per key, for the reference lookups that resolve a cited id.-    private static func winners<T>(-        _ groups: [(key: String, rows: [T])], _ order: ([T]) -> [T]-    ) -> [String: T] {-        // `grouped` never emits an empty group, so `compactMap` drops nothing.-        Dictionary(-            uniqueKeysWithValues: groups.compactMap { group in-                order(group.rows).first.map { (group.key, $0) }-            })-    }-     /// One diagnosis per key held by more than one row. The hostname is the one     /// the rows agree on, so every diagnosis can name a site (Req 1.3); rows that     /// disagree resolve to none rather than to an arbitrary one of them.
Packages/AsterismCore/Sources/AsterismCore/V4Migration.swift Modified +9 / -4
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V4Migration.swift b/Packages/AsterismCore/Sources/AsterismCore/V4Migration.swiftindex 7180fef..82828e2 100644--- a/Packages/AsterismCore/Sources/AsterismCore/V4Migration.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/V4Migration.swift@@ -68,10 +68,15 @@ public enum V4Migration {         sidecar: MigrationSidecar,         now: Date     ) throws {-        var sitesByHost: [String: Site] = [:]-        for site in try context.fetch(FetchDescriptor<Site>()) {-            sitesByHost[site.hostname] = site-        }+        // Resolve each hostname through `SiteResolutionOrder`, the same+        // deterministic winner the rest of the app picks — not the+        // `sitesByHost[hostname] = site` last-write-wins map this pass used to+        // build over an unsorted fetch (Q16, Q37). Duplicate rows cannot exist+        // when this runs for real, so the behaviour is unchanged; what changes+        // is that the anti-pattern no longer sits two statements upstream of+        // the V5 pass that exists to avoid it.+        let sitesByHost = SiteResolutionOrder.winnersByHostname(+            try context.fetch(FetchDescriptor<Site>()))          for taughtSite in sidecar.taughtSites {             guard case .createWholeTitle(let patternID, let trimPrefix, let trimSuffix) = taughtSite.plan else {
Packages/AsterismCore/Sources/AsterismCore/V5RelationshipPass.swift Added +57 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V5RelationshipPass.swift b/Packages/AsterismCore/Sources/AsterismCore/V5RelationshipPass.swiftnew file mode 100644index 0000000..fdc1397--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/V5RelationshipPass.swift@@ -0,0 +1,57 @@+import Foundation+import SwiftData++/// The V4 → V5 relationship pass (Req 2.1, 2.2): populates `Entry.site` and+/// `Work.site` from their hostname strings, after the lightweight conversion+/// has added the columns and left every relationship nil.+///+/// Runs in `openV4ForApp` under the exclusive lock, app-only — never as a+/// SwiftData custom stage, which would also run inside the share extension,+/// and the extension must never migrate (Q10, Q14).+///+/// Row selection resolves each hostname through `SiteResolutionOrder` — the+/// same deterministic rule the rest of the app uses — not a last-write-wins+/// map over an unsorted fetch as the V4 completion pass built (Q16). Duplicate+/// Site rows cannot exist when this runs for real (they arise only from+/// mirroring, which ships after), so the determinism is about tests, fixtures,+/// and re-runs — but "arbitrary" would falsify the determinism the milestone+/// claims.+///+/// One save at the end, nothing batched: Req 2.4 rests on the atomicity of+/// this save, with the readiness marker published by the caller only after it+/// returns (Q15). An interruption leaves either no progress or all of it, and+/// the next launch runs the pass again — idempotent by construction (Q12).+enum V5RelationshipPass {+    /// Populates both relationships and saves once. A hostname matching no+    /// Site row leaves the relationship nil: that is the tolerated state this+    /// milestone exists to make survivable, not an error (Req 2.1). The caller+    /// publishes the `"5"` marker only after this returns.+    ///+    /// The save goes through the bootstrap's own `RepositorySaveStrategy` — the+    /// default is a plain `context.save()` — so the failure branch the callers+    /// wrap in `libraryUnavailable("running the relationship migration pass")`+    /// is reachable from a test rather than only from a real disk fault.+    static func run(+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+    ) throws {+        let winners = SiteResolutionOrder.winnersByHostname(+            try context.fetch(FetchDescriptor<Site>()))++        // Assign the winner even where a relationship is already set (Q33): a+        // record pinned to a row that is no longer the winner would otherwise+        // never converge, and skip-if-set would make the outcome depend on+        // prior state rather than store content. The identity check keeps the+        // already-converged case a true no-op — nothing dirtied, no+        // inverse-array churn.+        for entry in try context.fetch(FetchDescriptor<Entry>()) {+            let winner = winners[entry.hostname]+            if entry.site !== winner { entry.site = winner }+        }+        for work in try context.fetch(FetchDescriptor<Work>()) {+            let winner = winners[work.siteHostname]+            if work.site !== winner { work.site = winner }+        }+        try saveStrategy.save(context)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift Modified +6 / -6
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex 9b7f600..5f87bab 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -168,7 +168,7 @@ struct BackupImportTransactionTests {         // Library remains ready and empty (Req 1.17): the failed import wrote         // nothing, and readiness was already published when the store was created.         #expect(FileManager.default.fileExists(atPath: env.configuration.v4MarkerURL.path))-        let schema = Schema(versionedSchema: AsterismSchemaV4.self)+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)         let storeConfig = ModelConfiguration(             "AsterismV3",             schema: schema,@@ -177,7 +177,7 @@ struct BackupImportTransactionTests {         )         let container = try ModelContainer(             for: schema,-            migrationPlan: AsterismV4MigrationPlan.self,+            migrationPlan: AsterismV5MigrationPlan.self,             configurations: [storeConfig]         )         let context = ModelContext(container)@@ -514,7 +514,7 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr         at: configuration.v4StoreURL.deletingLastPathComponent(),         withIntermediateDirectories: true     )-    let schema = Schema(versionedSchema: AsterismSchemaV4.self)+    let schema = Schema(versionedSchema: AsterismSchemaV5.self)     let storeConfig = ModelConfiguration(         "AsterismV3",         schema: schema,@@ -523,7 +523,7 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr     )     let container = try ModelContainer(         for: schema,-        migrationPlan: AsterismV4MigrationPlan.self,+        migrationPlan: AsterismV5MigrationPlan.self,         configurations: [storeConfig]     )     let context = ModelContext(container)@@ -537,7 +537,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration)         at: configuration.v4StoreURL.deletingLastPathComponent(),         withIntermediateDirectories: true     )-    let schema = Schema(versionedSchema: AsterismSchemaV4.self)+    let schema = Schema(versionedSchema: AsterismSchemaV5.self)     let storeConfig = ModelConfiguration(         "AsterismV3",         schema: schema,@@ -546,7 +546,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration)     )     let container = try ModelContainer(         for: schema,-        migrationPlan: AsterismV4MigrationPlan.self,+        migrationPlan: AsterismV5MigrationPlan.self,         configurations: [storeConfig]     )     let context = ModelContext(container)
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift Modified +8 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swiftindex 52f07fb..1ae476d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift@@ -108,10 +108,17 @@ struct BackupV4ExportTests {             context.insert(good)              try context.save()+            // Task 19: the marker published below says the relationship pass has+            // run, so the seeded graph must look as though it did — every record+            // pinned to the row `SiteResolutionOrder` picks, and nil only where the+            // hostname carries no Site row at all.+            try V5RelationshipPass.run(context: context)             withExtendedLifetime(container) {}         } -        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        // Seeded at the current schema, so it is marked migrated (Q14, Q26);+        // nothing here simulates a library awaiting the relationship pass.+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)          let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4, saveStrategy: ModelContextSaveStrategy())
Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift Added +518 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swiftnew file mode 100644index 0000000..41d1f9b--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift@@ -0,0 +1,518 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Task 12, Req 1.2, 3.2, 4.1–4.3: resolution parity for the eight cited-rule+/// call sites. For each one, the relationship form — a lookup among the citing+/// record's own Site's rules — must resolve the same record the union form did.+/// These tests are written against the union implementation and must stay green+/// through task 14's conversion, so every case here is one on which the two+/// forms agree by construction: the graphs are linked the way the relationship+/// pass and the write sites link them.+///+/// The eight sites, and where each is asserted:+///+/// - `V4LibraryValidator` ×4, reached through two helpers —+///   `resolves(citedRule:…)` (Work rule identity, `requiredReference`) and+///   `citedPattern(id:version:…)` (v3 name contributor, chapter provenance).+///   The superseded and version-mismatch cases live in this file; the nil-site+///   arm — a tolerated diagnosis, no quarantine — is `V4ValidatorNilSiteToleranceTests`,+///   and the duplicate-row arm is `CitedPatternResolutionTests` (Decision 4).+/// - `+RecentPresentation` and `+EntryDetail`, which share `replayCitedPattern`:+///   superseded, version-mismatch and nil-relationship cases here; the+///   pinned-to-a-losing-row case is in their own tolerance suites.+/// - `+ReparseCapture`'s two (the tuple-validation search space in+///   `commitCapture`): the committed capture's citations resolve within the+///   Site row the Entry was assigned.+@Suite("Cited-rule resolution parity across the eight call sites", .serialized)+struct CitationResolutionParityTests {++    private static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    // 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")+    func supersededPatternResolvesAtChapterProvenance() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        fixture.titlePattern.isActive = false+        let v2 = try TitlePattern(+            version: 2, 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.+        #expect(fixture.entry.chapterPatternID == fixture.titlePattern.id)+        #expect(fixture.entry.chapterPatternVersion == 1)++        let diagnostics = try V4LibraryValidator.validate(graph: fixture.graph)+        #expect(diagnostics.quarantineMap().isEmpty)+        #expect(diagnostics.tupleDiagnoses.isEmpty)+    }++    /// 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")+    func supersededNameContributorResolvesAtV3Identity() throws {+        let fixture = try V4Fixtures.wholeTitleSequence()+        fixture.titlePattern.isActive = false+        let v2 = try TitlePattern(+            version: 2, isActive: true, createdAt: Self.epoch,+            definition: .wholeTitle, site: fixture.site)+        fixture.site.patterns = fixture.site.patternValues + [v2]+        #expect(fixture.entry.identityNameTitleRuleVersion == 1)++        let diagnostics = try V4LibraryValidator.validate(graph: fixture.graph)+        #expect(diagnostics.quarantineMap().isEmpty)+        #expect(diagnostics.tupleDiagnoses.isEmpty)+    }++    /// The Work's `.rule` identity and the Entry's identity/extraction+    /// 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`.+    @Test("A superseded URL rule keeps resolving at the Work identity and requiredReference")+    func supersededURLRuleResolvesAtBothRuleSites() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        fixture.rule.isCurrent = false+        let v3 = try URLRulePattern(+            version: 3, 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.work.urlIdentityRuleVersion == 2)+        #expect(fixture.entry.identityURLRuleVersion == 2)++        let diagnostics = try V4LibraryValidator.validate(graph: fixture.graph)+        #expect(diagnostics.quarantineMap().isEmpty)+        #expect(diagnostics.tupleDiagnoses.isEmpty)+    }++    // MARK: - Validator: the version-mismatch cases (Req 4.2)++    /// Id and version are tested together, as every site has always done. A+    /// right id at a version the site never retained fails to resolve under+    /// both forms — and with the relationship populated it is a diagnosis, not+    /// a tolerated state. The pattern-helper equivalents are pinned in+    /// `V4ValidatorNilSiteToleranceTests` (the "populated site still diagnoses"+    /// pair); these cover the rule helper's two sites.+    @Test("A version mismatch on the Work rule identity is diagnosed, not resolved")+    func ruleVersionMismatchDiagnosesAtWorkIdentity() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        fixture.work.urlIdentityRuleVersion = 99++        let diagnostics = try V4LibraryValidator.validate(graph: fixture.graph)+        #expect(diagnostics.quarantineMap()[fixture.site.hostname] != nil)+    }++    @Test("A version mismatch on the assignment reference is diagnosed, not resolved")+    func ruleVersionMismatchDiagnosesAtRequiredReference() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        fixture.entry.workURLRuleVersion = 99++        let diagnostics = try V4LibraryValidator.validate(graph: fixture.graph)+        #expect(diagnostics.quarantineMap()[fixture.site.hostname] != nil)+    }++    // MARK: - Recent's candidate replay (Req 4.1, 4.2)++    /// The replay resolves the cited `(id, version)`, 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+    /// one that resolved the citation.+    @Test("Recent replays the superseded cited pattern, not the site's active one")+    func recentReplaysTheSupersededPattern() 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: false, version: 1,+                definition: .segmented)+            try store.insertTitlePattern(site: site, isActive: true, version: 2)+            let entry = store.insertEntry(+                hostname: "cited.example", title: "A Cited Work - Chapter 3")+            entry.workAssignmentProvenance = .pattern+            entry.workPatternID = citedID+            entry.workPatternVersion = 1+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)++        let row = try #require(presentation.allRows.first)+        #expect(row.unresolvedCandidateTitle == "A Cited Work")+        #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.workAssignmentProvenance = .pattern+            entry.workPatternID = citedID+            entry.workPatternVersion = 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)+    }++    /// A record whose Site never arrived: resolution yields nothing, and the+    /// caller renders instead of throwing (Req 3.4). The citation's evidence is+    /// not lost — the row is emitted, identified by its capture title.+    @Test("Recent renders an Entry with no Site whose provenance cites a pattern")+    func recentRendersACitingEntryWithNoSite() async throws {+        let library = try ParityFixture()+        try library.seed { store in+            store.insertSite(hostname: "present.example")+            store.insertEntry(hostname: "present.example", title: "resolvable", offset: 10)+            let orphan = store.insertEntry(+                hostname: "orphan.example", title: "orphaned citer")+            orphan.workAssignmentProvenance = .pattern+            orphan.workPatternID = UUID()+            orphan.workPatternVersion = 1+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)++        let rows = presentation.allRows+        #expect(rows.count == 2)+        let orphan = try #require(rows.first { $0.captureTitle == "orphaned citer" })+        #expect(orphan.attention == .siteMissing)+        #expect(orphan.unresolvedCandidateTitle == nil)+        let resolvable = try #require(rows.first { $0.captureTitle == "resolvable" })+        #expect(resolvable.attention == nil)+    }++    // MARK: - Entry detail's provenance disclosure (Req 4.1, 4.3)++    @Test("Entry detail replays the superseded cited pattern, not the site's active one")+    func entryDetailReplaysTheSupersededPattern() async throws {+        let library = try ParityFixture()+        let entryID = UUID()+        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: false, version: 1,+                definition: .segmented)+            try store.insertTitlePattern(site: site, isActive: true, version: 2)+            let entry = store.insertEntry(+                id: entryID, hostname: "cited.example", title: "A Cited Work - Chapter 3")+            entry.workAssignmentProvenance = .pattern+            entry.workPatternID = citedID+            entry.workPatternVersion = 1+        }+        let repository = try await library.openForApp()++        let detail = try await repository.entryTeachingDetail(id: entryID)++        #expect(detail.unresolvedCandidateTitle == "A Cited Work")+    }++    /// Same state through the detail screen: no Site, a citation on record, no+    /// throw. The cited identity stays disclosed as evidence (Req 4.2).+    @Test("Entry detail renders an Entry with no Site whose provenance cites a pattern")+    func entryDetailRendersACitingEntryWithNoSite() async throws {+        let library = try ParityFixture()+        let entryID = UUID()+        let citedID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "present.example")+            let orphan = store.insertEntry(+                id: entryID, hostname: "orphan.example", title: "Orphaned Citer")+            orphan.workAssignmentProvenance = .pattern+            orphan.workPatternID = citedID+            orphan.workPatternVersion = 1+        }+        let repository = try await library.openForApp()++        let detail = try await repository.entryTeachingDetail(id: entryID)++        #expect(detail.siteMode == .untaught)+        #expect(detail.unresolvedCandidateTitle == nil)+        #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 {+            #expect(patternID == citedID)+            #expect(version == 1)+        } else {+            Issue.record("expected a pattern settlement carrying the cited identity, got \(detail.assignmentSettlement)")+        }+    }++    // MARK: - The capture commit's tuple-validation search space++    /// `commitCapture` validates the tuple it just wrote against a cited search+    /// space. The parity claim: every citation the committed Entry carries+    /// resolves within the Site row the Entry was assigned — which is what the+    /// relationship form searches, and a subset of what the union searched.+    @Test("A committed capture's citations resolve within the Site it was assigned")+    func captureCitationsResolveWithinTheAssignedRow() async throws {+        let library = try ParityFixture()+        try library.seed { store in+            let site = store.insertSite(hostname: "taught.example")+            site.mode = .untaught+            store.insertEntry(+                hostname: "taught.example", title: "Chapter 7 - Real Work",+                url: "https://taught.example/read?id=42&chapter=7")+        }+        let repository = try await library.openForApp()+        let teaching = try await repository.projectComposedTeaching(+            hostname: "taught.example",+            request: ComposedTeachingRequest(+                titleDefinition: .segment(+                    work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: []),+                urlDefinition: .workAndSequence(+                    work: URLFieldSelector(locator: .query(name: ExactScalarString("id"))),+                    sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter"))))))+        guard case .committed = try await repository.commitComposedTeaching(teaching) else {+            Issue.record("the teaching did not commit")+            return+        }++        let contract = try await repository.projectCapture(+            hostname: "taught.example", captureTitle: "Chapter 9 - Real Work",+            captureTitleSource: .safariDocument,+            rawURLString: "https://taught.example/read?id=42&chapter=9",+            canonicalURLString: nil, note: "", rating: nil)+        guard case .committed(let snapshot) = try await repository.commitCapture(contract) else {+            Issue.record("the capture did not commit")+            return+        }++        let context = try library.readContext()+        let entry = try #require(try context.fetch(FetchDescriptor<Entry>())+            .first { $0.id == snapshot.id })+        let assigned = try #require(entry.site, "the capture left its Entry unassigned")+        if let patternID = entry.chapterPatternID, let patternVersion = entry.chapterPatternVersion {+            #expect(assigned.patternValues.contains {+                $0.id == patternID && $0.version == patternVersion+            }, "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 ruleID = try #require(entry.identityURLRuleID)+        let ruleVersion = try #require(entry.identityURLRuleVersion)+        #expect(assigned.urlRuleValues.contains {+            $0.id == ruleID && $0.version == ruleVersion+        }, "the cited URL rule is not owned by the assigned Site row")+    }+}++// MARK: - Re-parse follows the Entry's own row (task 16)++/// The two re-parse entry points are reached from an existing Entry, so task 16+/// converted their Site resolution to `entry.site` (Req 3.1). The three+/// capture-shaped `fetchSites` calls in the same file keep the hostname lookup+/// (Req 3.3); their behaviour is pinned by the capture suites.+@Suite("Re-parse resolves the Entry's own Site row", .serialized)+struct ReparseSiteResolutionTests {++    /// On a duplicated hostname, the re-parse basis is built from the row the+    /// Entry points at — here the row that *loses* `SiteResolutionOrder` — not+    /// from the current winner, whose teaching says nothing about this Entry.+    @Test("Re-parse projects from the Entry's own non-winner row")+    func reparseProjectsFromTheEntrysOwnRow() async throws {+        let library = try ParityFixture()+        let entryID = UUID()+        let ids = [UUID(), UUID()].sorted()+        let winnerPatternID = ids[0]+        let loserPatternID = ids[1]+        try library.seed { store in+            let winner = store.insertSite(hostname: "dup.example", displayName: "win-row")+            winner.mode = .taught+            try store.insertTitlePattern(+                id: winnerPatternID, site: winner, isActive: true, version: 1,+                definition: .segmented)+            let loser = store.insertSite(hostname: "dup.example", displayName: "lose-row")+            loser.mode = .taught+            try store.insertTitlePattern(+                id: loserPatternID, site: loser, isActive: true, version: 1)+            store.insertEntry(id: entryID, hostname: "dup.example", title: "A Work - Chapter 1")+        }+        // Pin the Entry to the losing row — the sync-shaped graph (Decision 4);+        // the seed's pass pinned it to the winner.+        try library.mutate { context in+            let sites = try context.fetch(FetchDescriptor<Site>())+            let loser = try #require(sites.first { $0.displayName == "lose-row" })+            let entry = try #require(try context.fetch(FetchDescriptor<Entry>())+                .first { $0.id == entryID })+            entry.site = loser+        }+        let repository = try await library.openForApp()++        let contract = try await repository.projectReparse(entryID: entryID)++        #expect(contract.basis.activePattern?.id == loserPatternID,+                "the re-parse basis came from the hostname winner, not the Entry's own row")+    }++    /// An Entry with no Site relationship takes the refusal an untaught+    /// hostname always took — an action is legitimately refused; only+    /// rendering, quarantine and export must survive a nil relationship+    /// (Req 3.4).+    @Test("Re-parse refuses an Entry with no Site relationship, as it refuses an untaught hostname")+    func reparseRefusesANilRelationship() async throws {+        let library = try ParityFixture()+        let entryID = UUID()+        try library.seed { store in+            store.insertEntry(id: entryID, hostname: "orphan.example", title: "Orphaned")+        }+        let repository = try await library.openForApp()++        do {+            _ = try await repository.projectReparse(entryID: entryID)+            Issue.record("expected the projection to refuse")+        } catch let error as LibraryRepositoryError {+            guard case .invalidInput = error else {+                Issue.record("expected invalidInput, got \(error)")+                return+            }+        }+    }+}++// MARK: - Fixture++/// A fixed-path V4 library seeded through plain `insert`/`save`, linked by+/// `V5RelationshipPass` (Q42) and opened the way the app opens it.+private final class ParityFixture {+    static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++    let directory: URL+    let configuration: LibraryConfiguration++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismCitationParity-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+        try FileManager.default.createDirectory(+            at: configuration.v4StoreURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)+    }++    func seed(_ body: (ParitySeedStore) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let store = ParitySeedStore(context: ModelContext(container))+        try body(store)+        try store.context.save()+        try V5RelationshipPass.run(context: store.context)+        withExtendedLifetime(container) {}+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)+    }++    func readContext() throws -> ModelContext {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        containers.append(container)+        return ModelContext(container)+    }++    /// Mutates the seeded store *without* re-running the relationship pass —+    /// for the one shape the pass cannot express (Decision 4): a record pinned+    /// to a duplicate row other than the hostname winner.+    func mutate(_ body: (ModelContext) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let context = ModelContext(container)+        try body(context)+        try context.save()+        withExtendedLifetime(container) {}+    }++    func openForApp() async throws -> LibraryRepository {+        let (_, repository) = try await LibraryRepository.openV4ForApp(+            configuration, capabilities: .m4,+            clock: FixedRepositoryClock(Self.epoch),+            saveStrategy: ModelContextSaveStrategy())+        return repository+    }++    /// A `ModelContext` does not retain its container, so every container this+    /// fixture hands out has to outlive the test using it.+    private var containers: [ModelContainer] = []++    deinit {+        try? FileManager.default.removeItem(at: directory)+    }+}++private extension PatternDefinition {+    /// Names the Work from the first separator-delimited segment, so a replay of+    /// "A Cited Work - Chapter 3" produces "A Cited Work" — distinguishable from+    /// the whole-title form used as the active pattern in the superseded tests.+    static var segmented: PatternDefinition {+        get throws {+            .segment(work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1), ignored: [])+        }+    }+}++private final class ParitySeedStore {+    let context: ModelContext++    init(context: ModelContext) {+        self.context = context+    }++    @discardableResult+    func insertSite(hostname: String, displayName: String? = nil) -> Site {+        let site = Site(hostname: hostname, displayName: displayName)+        context.insert(site)+        return site+    }++    @discardableResult+    func insertEntry(+        id: UUID = UUID(), hostname: String, title: String, offset: TimeInterval = 0,+        url: String? = nil+    ) -> Entry {+        let rawURL = url ?? "https://\(hostname)/read/\(UUID().uuidString)"+        let entry = Entry(+            id: id, captureTitle: title, captureTitleSource: .host, rawURLString: rawURL,+            hostname: hostname, entryIdentityKey: rawURL,+            timestamp: ParityFixture.epoch.addingTimeInterval(offset))+        entry.conservativeIdentityKey = rawURL+        context.insert(entry)+        return entry+    }++    @discardableResult+    func insertTitlePattern(+        id: UUID = UUID(), site: Site, isActive: Bool = false, version: Int = 1,+        offset: TimeInterval = 0, definition: PatternDefinition = .wholeTitle+    ) throws -> TitlePattern {+        let pattern = try TitlePattern(+            id: id, version: version, isActive: isActive,+            createdAt: ParityFixture.epoch.addingTimeInterval(offset),+            definition: definition, site: site)+        context.insert(pattern)+        site.patterns = site.patternValues + [pattern]+        return pattern+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift Modified +1 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swiftindex 0e4c806..f64b199 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift@@ -37,8 +37,7 @@ struct ComposedCaptureTests {         let context = fixture.freshContext()         let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first { $0.id == snapshot.id })         try V4LibraryValidator.validateEntryTuple(-            entry: entry, site: fixture.site(context), works: entry.work.map { [$0] } ?? [],-            patterns: fixture.site(context).patternValues, rules: fixture.site(context).urlRuleValues)+            entry: entry, site: fixture.site(context), works: entry.work.map { [$0] } ?? [])         #expect(entry.identityKeyVersion == 2)         #expect(entry.conservativeIdentityKey == entry.rawURLString)         #expect(entry.chapterTitle == "Chapter 9")
Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift Modified +231 / -7
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swiftindex 4721643..31391b0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift@@ -100,12 +100,16 @@ struct EntryDetailAndMergeToleranceTests {         #expect(!detail.hasCurrentURLRule)     } -    /// Decision 9 end to end through the detail screen. The Entry cites a pattern-    /// owned by the Site row that *lost* the tiebreak; the disclosure replays it.-    /// This code has been in place since task 13 and unreachable until now, because-    /// the `sites.count == 1` guard threw two lines above it.-    @Test("Entry detail replays a cited pattern owned by the losing Site row")-    func entryDetailReplaysAcrossTheUnionOfRows() async throws {+    /// The provenance half of Decision 5 through the detail screen, in the shape+    /// Decision 4 preserves — what Decision 9 of+    /// `specs/library-integrity-tolerance` used the hostname-wide union for. The+    /// Entry cites a pattern owned by the Site row that *lost* the tiebreak, and it is pinned to that row — the sync-shaped graph, since only+    /// mirroring can produce two rows with split citation ownership. The+    /// disclosure replays the citation; a winner-only lookup would find nothing,+    /// which is the failure the union removed and the record's own relationship+    /// must keep removed (task 14).+    @Test("Entry detail replays a cited pattern owned by the Entry's own non-winner row")+    func entryDetailReplayFollowsTheEntrysOwnRow() async throws {         let library = try ToleranceFixture()         let entryID = UUID()         let ids = [UUID(), UUID()].sorted()@@ -126,6 +130,17 @@ struct EntryDetailAndMergeToleranceTests {             entry.workPatternID = losingPatternID             entry.workPatternVersion = 1         }+        // Pin the Entry to the row that owns its citation, undoing the seed's+        // winner-pinning pass for this one record (Decision 4): a synced+        // relationship arrives as a pointer to its originating row, never+        // through hostname resolution.+        try library.mutate { context in+            let sites = try context.fetch(FetchDescriptor<Site>())+            let loser = try #require(sites.first { $0.displayName == "lose-row" })+            let entry = try #require(try context.fetch(FetchDescriptor<Entry>())+                .first { $0.id == entryID })+            entry.site = loser+        }         let repository = try await library.openForApp()          let detail = try await repository.entryTeachingDetail(id: entryID)@@ -133,6 +148,96 @@ struct EntryDetailAndMergeToleranceTests {         #expect(detail.unresolvedCandidateTitle == "A Cited Work")     } +    /// 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")+    func entryDetailRendersAnUnresolvableCitation() async throws {+        let library = try ToleranceFixture()+        let entryID = UUID()+        let patternID = 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)+            let entry = store.insertEntry(+                id: entryID, hostname: "taught.example", title: "A Cited Work - Chapter 3")+            entry.workAssignmentProvenance = .pattern+            // The right id at a version the Site never retained.+            entry.workPatternID = patternID+            entry.workPatternVersion = 2+        }+        let repository = try await library.openForApp()++        let detail = try await repository.entryTeachingDetail(id: entryID)++        #expect(detail.unresolvedCandidateTitle == nil)+        // Everything the screen *could* resolve is still disclosed.+        #expect(detail.siteMode == .taught)+        #expect(detail.activePatternSummary?.id == patternID)+        // 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,+                    reason: "Cited title pattern does not resolve"))+    }++    /// The split itself, on one screen (Decision 5). The Entry is pinned to the+    /// row that lost the tiebreak, and the two rows teach *different* active+    /// patterns — so a screen reading everything from one row would be visibly+    /// wrong whichever row it picked. Presentation is the hostname's current+    /// teaching (the winner's active pattern); provenance is the Entry's own+    /// row's pattern, replayed to produce the candidate title. Both halves are+    /// asserted, because either one alone passes for the wrong reason.+    @Test("Entry detail presents the winner's teaching and replays its own row's citation")+    func entryDetailSplitsPresentationFromProvenance() async throws {+        let library = try ToleranceFixture()+        let entryID = UUID()+        let ids = [UUID(), UUID()].sorted()+        let winningPatternID = ids[0]+        let losingPatternID = ids[1]+        try library.seed { store in+            let winner = store.insertSite(hostname: "dup.example", displayName: "win-row")+            winner.mode = .taught+            // Names the Work from the *first* segment: "A Cited Work".+            try store.insertTitlePattern(+                id: winningPatternID, site: winner, isActive: true, definition: .segmented)+            let loser = store.insertSite(hostname: "dup.example", displayName: "lose-row")+            loser.mode = .taught+            // Names it from the *last* segment: "Chapter 3". Same title, other answer.+            try store.insertTitlePattern(+                id: losingPatternID, site: loser, isActive: true,+                definition: .segmentedFromTheEnd)++            let entry = store.insertEntry(+                id: entryID, hostname: "dup.example", title: "A Cited Work - Chapter 3")+            entry.workAssignmentProvenance = .pattern+            entry.workPatternID = losingPatternID+            entry.workPatternVersion = 1+        }+        try library.mutate { context in+            let sites = try context.fetch(FetchDescriptor<Site>())+            let loser = try #require(sites.first { $0.displayName == "lose-row" })+            let entry = try #require(try context.fetch(FetchDescriptor<Entry>())+                .first { $0.id == entryID })+            entry.site = loser+        }+        let repository = try await library.openForApp()++        let detail = try await repository.entryTeachingDetail(id: entryID)++        // Presentation: the hostname's winner, the same row Recent presents.+        #expect(detail.siteMode == .taught)+        #expect(detail.activePatternSummary?.id == winningPatternID)+        // Provenance: the Entry's own row, whose pattern names the last segment.+        #expect(detail.unresolvedCandidateTitle == "Chapter 3")+    }+     @Test("Entry detail resolves the earliest of two Entries sharing an application UUID")     func entryDetailResolvesADuplicateEntryUUID() async throws {         let library = try ToleranceFixture()@@ -291,6 +396,80 @@ struct EntryDetailAndMergeToleranceTests {         #expect(reason.lowercased().contains("resolve"))     } +    /// The Work URL basis derives its current rule from the Work's Site. A Work+    /// on a hostname with no Site row simply has no current rule — the same+    /// answer the Merge basis gives — and the projection still renders.+    @Test("Work URL projects when the hostname has no Site row")+    func workURLBasisResolvesWithNoSiteRow() async throws {+        let library = try ToleranceFixture()+        let orphanWorkID = UUID()+        try library.seed { store in+            store.insertSite(hostname: "present.example")+            let work = store.insertWork(+                id: orphanWorkID, hostname: "orphan.example", title: "Orphaned Work")+            let entry = store.insertEntry(+                hostname: "orphan.example", title: "chapter",+                url: "https://orphan.example/read/1")+            entry.work = work+            entry.workAssignmentProvenance = .manual+        }+        let repository = try await library.openForApp()++        let contract = try await repository.projectWorkURL(+            workID: orphanWorkID, request: WorkURLRequest.clear)++        #expect(contract.basis.workID == orphanWorkID)+        #expect(contract.basis.currentRule == nil)+    }++    /// The Work URL basis is the other half of Decision 5: a Work is in hand, so+    /// its current rule comes from the row it points at. Both rows here own a+    /// current URL rule and they differ, so the winner's rule and the Work's own+    /// row's rule are distinguishable — which is what makes this a test rather+    /// than a restatement of the code.+    @Test("Work URL projects the rule of the Work's own row, not the winner's")+    func workURLBasisFollowsTheWorksOwnRow() async throws {+        let library = try ToleranceFixture()+        let workID = UUID()+        let patternIDs = [UUID(), UUID()].sorted()+        let losingRuleID = UUID()+        try library.seed { store in+            // Step 3 of `SiteResolutionOrder` decides: lowest owned pattern id.+            let winner = store.insertSite(hostname: "dup.example", displayName: "win-row")+            winner.mode = .taught+            try store.insertTitlePattern(id: patternIDs[0], site: winner, isActive: true)+            try store.insertURLRule(site: winner, work: "s", sequence: "c")++            let loser = store.insertSite(hostname: "dup.example", displayName: "lose-row")+            loser.mode = .taught+            try store.insertTitlePattern(id: patternIDs[1], site: loser, isActive: true)+            try store.insertURLRule(+                id: losingRuleID, site: loser, work: "series", sequence: "chapter")++            let work = store.insertWork(+                id: workID, hostname: "dup.example", title: "On The Losing Row")+            let entry = store.insertEntry(+                hostname: "dup.example", title: "chapter", url: "https://dup.example/read/1")+            entry.work = work+            entry.workAssignmentProvenance = .manual+        }+        // The pass pins every record on a hostname to the winner; this Work+        // arrived from the other row, which only mirroring can produce (Decision 4).+        try library.mutate { context in+            let sites = try context.fetch(FetchDescriptor<Site>())+            let loser = try #require(sites.first { $0.displayName == "lose-row" })+            let work = try #require(try context.fetch(FetchDescriptor<Work>())+                .first { $0.id == workID })+            work.site = loser+        }+        let repository = try await library.openForApp()++        let contract = try await repository.projectWorkURL(+            workID: workID, request: WorkURLRequest.clear)++        #expect(contract.basis.currentRule?.id == losingRuleID)+    }+     // MARK: - Merge destinations      /// Not named in the throw-demotion inventory, but it carries the same@@ -325,6 +504,14 @@ private extension PatternDefinition {             .segment(work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1), ignored: [])         }     }++    /// The same form reading from the other end, so two rows teaching the same+    /// hostname produce different Work names from one capture title.+    static var segmentedFromTheEnd: PatternDefinition {+        get throws {+            .segment(work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: [])+        }+    } }  /// A fixed-path V4 library seeded through plain `insert`/`save` and then opened@@ -351,8 +538,15 @@ private final class ToleranceFixture {         let store = ToleranceSeedStore(context: ModelContext(container))         try body(store)         try store.context.save()+        // Task 19: the marker published below says the relationship pass has+        // run, so the seeded graph must look as though it did — every record+        // pinned to the row `SiteResolutionOrder` picks, and nil only where the+        // hostname carries no Site row at all.+        try V5RelationshipPass.run(context: store.context)         withExtendedLifetime(container) {}-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        // Seeded at the current schema, so it is marked migrated (Q14, Q26);+        // nothing here simulates a library awaiting the relationship pass.+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)     }      func readContext() throws -> ModelContext {@@ -361,6 +555,17 @@ private final class ToleranceFixture {         return ModelContext(container)     } +    /// Mutates the seeded store *without* re-running the relationship pass —+    /// for the one shape the pass cannot express (Decision 4): a record pinned+    /// to the duplicate row that owns its citations rather than to the winner.+    func mutate(_ body: (ModelContext) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let context = ModelContext(container)+        try body(context)+        try context.save()+        withExtendedLifetime(container) {}+    }+     func openForApp() async throws -> LibraryRepository {         let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4,@@ -445,6 +650,25 @@ private final class ToleranceSeedStore {         }     } +    /// A current `.workAndSequence` URL rule owned by `site`. The query names+    /// are the caller's, so two rows can own rules that are distinguishable in a+    /// basis.+    @discardableResult+    func insertURLRule(+        id: UUID = UUID(), site: Site, work: String, sequence: String, version: Int = 1+    ) throws -> URLRulePattern {+        let rule = try URLRulePattern(+            id: id, version: version, isCurrent: true, createdAt: ToleranceFixture.epoch,+            origin: .readerTaught,+            definition: .workAndSequence(+                work: URLFieldSelector(locator: .query(name: ExactScalarString(work))),+                sequence: URLFieldSelector(locator: .query(name: ExactScalarString(sequence)))),+            site: site)+        context.insert(rule)+        site.urlRules = site.urlRuleValues + [rule]+        return rule+    }+     @discardableResult     func insertTitlePattern(         id: UUID = UUID(), site: Site, isActive: Bool = false, version: Int = 1,
Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift Modified +9 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swiftindex 864e405..b5ffb6a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift@@ -175,8 +175,15 @@ private final class FailClosedFixture {         let store = FailClosedSeedStore(context: ModelContext(container))         try body(store)         try store.context.save()+        // Task 19: the marker published below says the relationship pass has+        // run, so the seeded graph must look as though it did — every record+        // pinned to the row `SiteResolutionOrder` picks, and nil only where the+        // hostname carries no Site row at all.+        try V5RelationshipPass.run(context: store.context)         withExtendedLifetime(container) {}-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        // Marked migrated, or the extension would decline on the marker alone+        // and never reach the state under test (Q14).+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)     }      /// A store file that is not a store, plus a readiness marker claiming it is.@@ -184,7 +191,7 @@ private final class FailClosedFixture {     func writeUnreadableStore() throws -> Data {         let evidence = Data("this is not a sqlite store".utf8)         try evidence.write(to: configuration.v4StoreURL)-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)         return evidence     } 
Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/v4-recorded-4.0.0-scale.sqlite Added binary
(binary file — no textual diff)
Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/v4-recorded-4.0.0.sqlite Added binary
(binary file — no textual diff)
Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift Modified +20 / -7
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swiftindex 7ade51b..c7a6c1a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift@@ -91,8 +91,13 @@ struct IdentityLookupToleranceTests {         #expect(work.displayTitle == "earliest")     } -    @Test("titlePattern(id:) resolves the earliest of three rows sharing an application UUID")-    func titlePatternResolvesAWinner() async throws {+    /// The pattern half of the same ordering, read where it is actually used.+    /// `RecordResolutionOrder.sortedPatterns` is what `validate(graph:)` indexes+    /// duplicate patterns through; the `titlePattern(id:)` accessor that used to+    /// stand in for it here was a global id-only fetch with no production caller+    /// and was deleted (Q55).+    @Test("Pattern ordering resolves the earliest of three rows sharing an application UUID")+    func titlePatternResolvesAWinner() throws {         let library = try LibraryFixture()         let shared = UUID()         try library.seed { store in@@ -101,11 +106,13 @@ struct IdentityLookupToleranceTests {             try store.insertTitlePattern(id: shared, site: site, version: 1, offset: 0)             try store.insertTitlePattern(id: shared, site: site, version: 9, offset: 40)         }-        let repository = try await library.openForApp() -        let snapshot = try await repository.titlePattern(id: shared)+        let rows = try library.readContext().fetch(FetchDescriptor<TitlePattern>())+            .filter { $0.id == shared }+        let winner = RecordResolutionOrder.sortedPatterns(rows).first -        #expect(snapshot.version == 1)+        #expect(rows.count == 3)+        #expect(winner?.version == 1)     }      // MARK: - entriesByID and worksByID@@ -312,14 +319,20 @@ private final class LibraryFixture {     }      /// Seeds the store in a scoped container, releases it, and publishes-    /// readiness so both bootstrap paths accept the library.+    /// migrated readiness so both bootstrap paths accept the library — the+    /// extension accepts no other version (Q14).     func seed(_ body: (SeedStore) throws -> Void) throws {         let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)         let store = SeedStore(context: ModelContext(container))         try body(store)         try store.context.save()+        // Task 19: the marker published below says the relationship pass has+        // run, so the seeded graph must look as though it did — every record+        // pinned to the row `SiteResolutionOrder` picks, and nil only where the+        // hostname carries no Site row at all.+        try V5RelationshipPass.run(context: store.context)         withExtendedLifetime(container) {}-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)     }      /// A fresh container and context over the seeded file — the offline stand-in
Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swiftindex 39957d5..25896c7 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift@@ -429,12 +429,12 @@ private final class ResolutionStore {     }      private static func makeContainer(at directory: URL) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV4.self)+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         return try ModelContainer(-            for: schema, migrationPlan: AsterismV4MigrationPlan.self,+            for: schema, migrationPlan: AsterismV5MigrationPlan.self,             configurations: [configuration])     } 
Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swiftindex 09f3ce5..2788a93 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift@@ -349,12 +349,12 @@ private final class ToleranceScanStore {     }      private static func makeContainer(at directory: URL) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV4.self)+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         return try ModelContainer(-            for: schema, migrationPlan: AsterismV4MigrationPlan.self,+            for: schema, migrationPlan: AsterismV5MigrationPlan.self,             configurations: [configuration])     } 
Packages/AsterismCore/Tests/AsterismCoreTests/M4MigrationScalePerformanceTests.swift Added +404 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4MigrationScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4MigrationScalePerformanceTests.swiftnew file mode 100644index 0000000..fb9808e--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4MigrationScalePerformanceTests.swift@@ -0,0 +1,404 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// MARK: - Req 2.6 — the relationship pass at scale++/// The V4 → V5 relationship migration against its 10 s budget+/// (relational-references Req 2.6), over the same 5,000-Entry composed fixture+/// the rest of the M4 budgets use.+///+/// **What is being bounded is inverse-array maintenance.** Setting `entry.site`+/// 5,000 times populates one `Site.entries` array, because the M4 fixture is a+/// *single* Site (`LibraryRepository.m4FixtureHostname`, 1,000 Works × 5+/// chapters on one hostname) rather than the ~40 the design first assumed. That+/// is the most expensive shape available for this pass, which is why it is the+/// right one to budget against — do not spread the Entries across hostnames to+/// meet the number.+///+/// **Four ways this measurement can silently measure nothing**, all guarded+/// below rather than trusted:+///+/// 1. *The pass is skipped when the marker reads `"5"`.* `openV4ForApp` runs it+///    only on a `"4"` marker (Q31), and every seeded fixture publishes `"5"`.+///    `passOverTheScaleFixture` therefore calls `V5RelationshipPass.run`+///    directly, and `certificationOverTheScaleFixture` writes a `"4"` marker+///    before every timed open.+/// 2. *Nulling the relationships must be committed and the store reopened.*+///    `M4PerformanceFixture` sets both halves inline (Q43) and the pass skips an+///    assignment whose winner is already identical (Q33), so run straight over+///    the seeded fixture it dirties nothing, maintains no inverse array, and+///    clears 10 s by doing no work. `strip()` nulls both relationships in its+///    own container, saves, and releases it — nulling in the timing context+///    would leave the inverse arrays warm and the objects registered, which is+///    not the state certification runs in.+/// 3. *`.siteMissing` is not a pre-pass graph.* That tolerated state deletes the+///    Site row, so the pass correctly assigns nothing: a fast, meaningless+///    number. The coherent fixture is used, stripped.+/// 4. *A pre-count alone cannot tell a pass that did the work from one that+///    iterated 5,000 records and assigned none.* Both the pre-timing nil count+///    and the post-pass non-nil count are asserted, the latter from a fresh+///    container so it is the persisted state.+///+/// Same statistical protocol as the suites next door (Decision 10 of+/// `library-integrity-tolerance`): median asserted every run, p95 asserted only+/// under `CONTROLLED=1`, whole distribution reported either way. Opt-in through+/// `ASTERISM_RUN_PHYSICAL_PERFORMANCE=1`, so `make test-core` never runs it;+/// drive it with `make test-performance-m4`.+@Suite(+    "M4 migration scale budget", .serialized,+    .enabled(if: ProcessInfo.processInfo.environment["ASTERISM_RUN_PHYSICAL_PERFORMANCE"] == "1"))+struct M4MigrationScalePerformanceTests {+    /// Req 2.6. The same budget bounds both measurements below: the pass is what+    /// the requirement names, and the certification open is the interval a+    /// reader actually waits through, which contains it.+    private let migrationBudget = Duration.seconds(10)+    /// Fewer samples than the 20 the read paths use. Every sample here needs a+    /// full strip-and-reopen cycle of its own — the setup costs about as much as+    /// the measurement — and each sample is ~17 s, so 10 is what a run can carry.+    /// The measurement's within-run spread is ≤ 1.03×, so it is not the sample+    /// count that limits what can be claimed here.+    private let iterations = 10+    /// Half that again for the diagnostic, which is recorded rather than+    /// asserted and whose Entry half repeats the same 17 s as the test above.+    private let diagnosticIterations = 5++    // MARK: - Req 2.6 does not hold on this host++    /// **The 10 s budget is breached, by 1.75×, and these tests record that+    /// rather than hide it or move the budget.**+    ///+    /// Measured on an M1 Max in release over the 5,000-Entry fixture: the pass+    /// alone is **17.31 – 17.75 s** (median over five runs, n=10 each, within-run+    /// spread ≤ 1.03×) and the whole certification open **17.94 – 18.40 s**, so+    /// validation contributes ~0.6–0.9 s and the pass is essentially the entire+    /// cost. Five runs agreed to within 2.5%. This is a measurement, not a+    /// hiccup.+    ///+    /// **Where the time goes**, from `passSplitByRecordType` below: 5,000+    /// `entry.site` assignments into one `Site.entries` cost 16.55–17.00 s, while+    /// 1,000 `work.site` assignments into one `Site.works` cost 1.17–1.20 s —+    /// 14.2× the cost for 5× the records, where a linear cost would predict 5×.+    /// The cost is+    /// **superlinear in the size of the inverse array being maintained**, which+    /// is the unknown the design named and the reason the single-Site fixture is+    /// the worst shape available (design.md, "The pass necessarily populates+    /// `Site.entries`").+    ///+    /// **What is deliberately NOT done about it.** Batching the save would+    /// bring the interval down and is refused: Req 2.4's all-or-nothing rests on+    /// the single save, and Q15 rejected batching for exactly that trade. A+    /// breach reopens that decision as a decision — it does not authorise the+    /// change from inside a measurement task.+    ///+    /// **Host, not device.** Req 2.6's protocol is the physical-device protocol,+    /// and the `AsterismCore` package test target is in no scheme's test action,+    /// so it cannot run there at all (Decision 10 of `library-integrity-+    /// tolerance`). The one calibration point that exists — `recentPresentation`+    /// at 0.713 s host against 0.305 s device — says the device runs *read*+    /// workloads ~2.3× faster, which would put this near 7.5 s. That is an+    /// inference about a write-and-save workload from a read measurement, and it+    /// is not evidence. The device number is listed as pending in+    /// `specs/relational-references/implementation.md`.+    private static let requirement26KnownIssue: Comment = """+        Req 2.6 (10 s) is exceeded on the host: the relationship pass measures \+        ~17.3 s and the whole certification open ~17.9 s over the 5,000-Entry \+        single-Site fixture. Host-only measurement of a write workload; the \+        breach is recorded rather than fixed, because the only fix on the table \+        is batching, which Q15 rejected and Req 2.4 rests on. See the comment \+        above these tests and implementation.md, task 21.+        """+    /// The regression floor under a known breach, asserted *outside* the+    /// `withKnownIssue` block — the same construction Decision 11 of+    /// `library-integrity-tolerance` arrived at for Req 5.5. `withKnownIssue`+    /// swallows a failure at 17 s and at 170 s alike, so on its own it would+    /// give the test back the property it exists to remove. These sit ~1.3×+    /// above the measured medians: clear of the ≤ 1.03× within-run spread and the+    /// 2.5% run-to-run band, well under a doubling. Raising them to make a run+    /// pass would be the mistake.+    private let passRegressionCeiling = Duration.seconds(22)+    private let certificationRegressionCeiling = Duration.seconds(23)++    // MARK: - The pass alone (Req 2.6)++    @Test("Relationship pass ≤ 10 s over the 5,000-Entry fixture (Req 2.6)")+    func passOverTheScaleFixture() async throws {+        let store = try await M4MigrationStore()+        var samples: [Duration] = []+        let clock = ContinuousClock()++        // One warm-up cycle before the first recorded sample, as the read-path+        // suites do: the first open of a freshly written store pays page-cache+        // costs the later ones do not.+        for iteration in 0..<(iterations + 1) {+            try store.strip()+            try store.expectUnlinked()++            let container = try LibraryRepository.openV4Container(at: store.storeURL)+            let context = ModelContext(container)+            let start = clock.now+            try V5RelationshipPass.run(context: context)+            let elapsed = clock.now - start+            withExtendedLifetime(container) {}++            try store.expectLinked()+            if iteration > 0 { samples.append(elapsed) }+        }+        let measured = PerformanceDistribution(samples)+        withKnownIssue(Self.requirement26KnownIssue) {+            expectWithinBudget("v5-relationship-pass", measured, migrationBudget)+        }+        expectWithinCeiling("v5-relationship-pass", measured, passRegressionCeiling)+    }++    // MARK: - The whole certification open++    @Test("Certification open ≤ 10 s over the 5,000-Entry fixture (Req 2.6)")+    func certificationOverTheScaleFixture() async throws {+        // What Req 2.6's "migration completes" actually costs a reader: the+        // V4-marker branch of `openV4ForApp` runs the pass, then+        // `validateV4Store` over the now-linked graph, then publishes `"5"`.+        // Recorded beside the pass so the split between the two is visible+        // rather than inferred.+        let store = try await M4MigrationStore()+        var samples: [Duration] = []+        let clock = ContinuousClock()++        for iteration in 0..<(iterations + 1) {+            try store.strip()+            try store.markUnmigrated()+            try store.expectUnlinked()++            let start = clock.now+            let (result, repository) = try await LibraryRepository.openV4ForApp(+                store.configuration, capabilities: .m4)+            let elapsed = clock.now - start+            withExtendedLifetime(repository) {}++            guard case .ready = result else {+                Issue.record("the certification open must return a ready library, got \(result)")+                return+            }+            try store.expectLinked()+            try store.expectMigrated()+            if iteration > 0 { samples.append(elapsed) }+        }+        let measured = PerformanceDistribution(samples)+        withKnownIssue(Self.requirement26KnownIssue) {+            expectWithinBudget("v5-certification-open", measured, migrationBudget)+        }+        expectWithinCeiling(+            "v5-certification-open", measured, certificationRegressionCeiling)+    }++    // MARK: - Where the pass spends its time (recorded, not asserted)++    /// The same pass, run once with only the Entry half to do and once with only+    /// the Work half, so the breach above has a cause rather than a hypothesis.+    ///+    /// Q33's identity check is what makes this expressible: the pass assigns the+    /// winner to every record but skips the write where the relationship already+    /// holds it, dirtying nothing. Leaving one half linked therefore leaves that+    /// half a true no-op, and what is timed is the other half alone — 5,000+    /// appends into one `Site.entries`, or 1,000 into one `Site.works`.+    ///+    /// Neither number is asserted against a budget. Req 2.6 bounds the whole+    /// pass, and half of it is not a requirement; these exist so the ratio+    /// between them is on the record.+    @Test("Where the pass spends its time: Entries against Works (recorded)")+    func passSplitByRecordType() async throws {+        let store = try await M4MigrationStore()+        let entriesOnly = try measurePass(+            store, stripping: (entries: true, works: false),+            iterations: diagnosticIterations)+        reportPerformance("v5-relationship-pass-entries-only", entriesOnly)++        let worksOnly = try measurePass(+            store, stripping: (entries: false, works: true),+            iterations: diagnosticIterations)+        reportPerformance("v5-relationship-pass-works-only", worksOnly)++        let ratio = PerformanceDistribution.seconds(entriesOnly.median)+            / PerformanceDistribution.seconds(worksOnly.median)+        FileHandle.standardError.write(+            Data(+                """+                ASTERISM-PERF v5-relationship-pass entries/works ratio=\+                \(String(format: "%.2f", ratio))x over a 5.00x record-count ratio++                """.utf8))+    }++    // MARK: - Helpers++    /// Times `V5RelationshipPass.run` over a store stripped in one half or both,+    /// with the strip, the reopen and the verification all outside the timer.+    private func measurePass(+        _ store: M4MigrationStore,+        stripping halves: (entries: Bool, works: Bool),+        iterations: Int,+        sourceLocation: SourceLocation = #_sourceLocation+    ) throws -> PerformanceDistribution {+        var samples: [Duration] = []+        let clock = ContinuousClock()+        for iteration in 0..<(iterations + 1) {+            try store.relink()+            try store.strip(entries: halves.entries, works: halves.works)+            try store.expectUnlinked(+                entries: halves.entries, works: halves.works, sourceLocation: sourceLocation)++            let container = try LibraryRepository.openV4Container(at: store.storeURL)+            let context = ModelContext(container)+            let start = clock.now+            try V5RelationshipPass.run(context: context)+            let elapsed = clock.now - start+            withExtendedLifetime(container) {}++            try store.expectLinked(sourceLocation: sourceLocation)+            if iteration > 0 { samples.append(elapsed) }+        }+        return PerformanceDistribution(samples)+    }++    /// The regression floor under a known breach; see `passRegressionCeiling`.+    private func expectWithinCeiling(+        _ label: String,+        _ measured: PerformanceDistribution,+        _ ceiling: Duration,+        sourceLocation: SourceLocation = #_sourceLocation+    ) {+        #expect(+            measured.median <= ceiling,+            """+            \(label) median \(measured.median) exceeded the \(ceiling) regression \+            ceiling (p95 \(measured.p95)) — this is not Req 2.6's 10 s budget, \+            which is separately asserted and known to be breached on the host; \+            something has made the migration materially slower+            """,+            sourceLocation: sourceLocation)+    }+}++// MARK: - Fixture++/// The coherent 5,000-Entry composed fixture on disk, with the two operations a+/// migration measurement needs: put it back into the pre-pass state, and check+/// which state it is actually in.+///+/// Every check opens a container of its own and releases it, so what it reports+/// is the persisted store rather than objects some other context has registered.+private final class M4MigrationStore {+    let root: URL+    let configuration: LibraryConfiguration+    var storeURL: URL { configuration.v4StoreURL }++    /// 1,000 Works — the fixture's 5,000 Entries at 5 chapters each.+    private let workCount =+        LibraryRepository.m4FixtureEntryCount / LibraryRepository.m4FixtureEntriesPerWork++    init() async throws {+        root = FileManager.default.temporaryDirectory+            .appending(+                path: "asterism-m4-migration-perf-\(UUID().uuidString)", directoryHint: .isDirectory)+        configuration = LibraryConfiguration(rootDirectory: root, environment: .development)+        try FileManager.default.createDirectory(+            at: configuration.v4StoreURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)++        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let seeder = LibraryRepository.makeRepository(+            configuration, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy())+        try await seeder.seedM4PerformanceFixture()+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)+        withExtendedLifetime(container) {}+    }++    /// Back to the pre-pass graph: the named relationships nil across the whole+    /// store, **saved**, and the container released before anything is timed.+    /// Nulling in the timing context would leave the inverse arrays warm and the+    /// objects registered, which is not the state certification runs in.+    func strip(entries: Bool = true, works: Bool = true) throws {+        let container = try LibraryRepository.openV4Container(at: storeURL)+        let context = ModelContext(container)+        if entries {+            for entry in try context.fetch(FetchDescriptor<Entry>()) { entry.site = nil }+        }+        if works {+            for work in try context.fetch(FetchDescriptor<Work>()) { work.site = nil }+        }+        try context.save()+        withExtendedLifetime(container) {}+    }++    /// Restores the fully linked graph, so a half-strip starts from a known+    /// whole rather than from whatever the previous sample left behind.+    func relink() throws {+        let container = try LibraryRepository.openV4Container(at: storeURL)+        let context = ModelContext(container)+        try V5RelationshipPass.run(context: context)+        withExtendedLifetime(container) {}+    }++    /// Writes the `"4"` marker the V4-marker branch reads as "the pass has not+    /// run over this library" (Q31).+    func markUnmigrated() throws {+        try Data("4\n".utf8).write(to: configuration.v4MarkerURL, options: .atomic)+    }++    /// The pre-timing half of trap (d): a pass that iterated 5,000 records and+    /// assigned none measures a graph that was already linked, and only the+    /// count taken *before* the timer can rule that out.+    func expectUnlinked(+        entries strippedEntries: Bool = true,+        works strippedWorks: Bool = true,+        sourceLocation: SourceLocation = #_sourceLocation+    ) throws {+        let (entries, works) = try linkedCounts()+        let expectedEntries = strippedEntries ? 0 : LibraryRepository.m4FixtureEntryCount+        let expectedWorks = strippedWorks ? 0 : workCount+        #expect(+            entries == expectedEntries && works == expectedWorks,+            """+            the timed pass must start from the intended pre-pass graph: expected \+            \(expectedEntries) linked Entries and \(expectedWorks) linked Works, found \+            \(entries) and \(works) — a pass with nothing to assign clears the budget \+            by doing no work+            """,+            sourceLocation: sourceLocation)+    }++    func expectLinked(sourceLocation: SourceLocation = #_sourceLocation) throws {+        let (entries, works) = try linkedCounts()+        #expect(+            entries == LibraryRepository.m4FixtureEntryCount && works == workCount,+            """+            the pass must have linked the whole graph: expected \+            \(LibraryRepository.m4FixtureEntryCount) Entries and \(workCount) Works, \+            found \(entries) and \(works)+            """,+            sourceLocation: sourceLocation)+    }++    func expectMigrated(sourceLocation: SourceLocation = #_sourceLocation) throws {+        let marker = try String(contentsOf: configuration.v4MarkerURL, encoding: .utf8)+        #expect(+            marker.trimmingCharacters(in: .whitespacesAndNewlines) == "5",+            "the certification open must republish the marker as \"5\", found \(marker)",+            sourceLocation: sourceLocation)+    }++    private func linkedCounts() throws -> (entries: Int, works: Int) {+        let container = try LibraryRepository.openV4Container(at: storeURL)+        defer { withExtendedLifetime(container) {} }+        let context = ModelContext(container)+        let entries = try context.fetch(FetchDescriptor<Entry>()).filter { $0.site != nil }.count+        let works = try context.fetch(FetchDescriptor<Work>()).filter { $0.site != nil }.count+        return (entries, works)+    }++    deinit {+        try? FileManager.default.removeItem(at: root)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift Modified +14 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swiftindex 1016eaf..4ed4a27 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift@@ -93,8 +93,20 @@ struct M4ScaleFixtureTests {         #expect(counts.sites == 1)         #expect(counts.works == 1_000) -        // The seeded store certifies as a ready V4 library the extension can open.-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        // Task 19: the fixture builds the graph the app itself produces, so+        // every Entry and Work carries its Site relationship. A fixture with+        // nil relationships would put the scale budgets on a shape no library+        // can reach, and the pass never runs over it — the marker below says it+        // already has.+        let seeded = ModelContext(container)+        let unlinkedEntries = try seeded.fetch(FetchDescriptor<Entry>()).count { $0.site == nil }+        let unlinkedWorks = try seeded.fetch(FetchDescriptor<Work>()).count { $0.site == nil }+        #expect(unlinkedEntries == 0)+        #expect(unlinkedWorks == 0)++        // The seeded store certifies as a ready library the extension can open —+        // which since Q14 means one marked migrated, not one marked "4".+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)         let (result, _) = try await LibraryRepository.openV4ForExtension(configuration, capabilities: .m4)         guard case .ready(let readyCounts) = result else {             Issue.record("expected a ready V4 library, got \(result)"); return
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift Modified +63 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swiftindex 4306a7f..20b7f56 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift@@ -19,6 +19,9 @@ import Testing ///   collapsed measurement here is the title-only preview. /// - Capture rule-application step ≤ 100 ms against the composed fixture. /// - Extension open + validate (with title-derivation replay) ≤ 1 s.+/// - Store-level validation alone ≤ 1 s, reported so relational-references+///   Req 5.3 can state whether following relationships made it faster or slower+///   than resolving strings. /// /// **The statistic is split by purpose (Decision 10, task 36).** Every test /// records a whole `PerformanceDistribution` — min, median, p95 and max over the@@ -132,6 +135,61 @@ struct M4ScalePerformanceTests {             "extension-open-and-validate", PerformanceDistribution(samples), extensionOpenBudget)     } +    // MARK: - Store-level validation (relational-references Req 5.3)++    /// The validator on its own, with the container open kept outside the timer.+    ///+    /// `openV4ForExtension` above is container open + `validateV4Store` ++    /// `v3Counts`, so it bounds this path but cannot say how much of the second+    /// is the validator. Req 5.3 asks for the direction of change after+    /// resolution moved from hostname-keyed string lookup to+    /// `entry.site` / `work.site`, and that question is about+    /// `V4LibraryValidator.validate(context:)` specifically: it now reads+    /// `work.site` (~:445) and `entry.site` (~:495) unconditionally, once per+    /// record, and every cited-rule read scans the citing Site's+    /// `urlRuleValues` / `patternValues` per reference instead of a prebuilt+    /// dictionary.+    ///+    /// **A fresh container and context per sample**, because that is the state+    /// the extension's open is in: reusing one context would leave every+    /// to-one relationship already faulted and every rule array already+    /// materialised, which is precisely the cost being measured.+    @Test("Store-level validation ≤ 1 s, reported for Req 5.3")+    func storeLevelValidation() async throws {+        let (configuration, root) = try await seedReadyStore()+        defer { try? FileManager.default.removeItem(at: root) }++        var samples: [Duration] = []+        let clock = ContinuousClock()+        // One warm-up pass primes the page cache, as the extension test does.+        for iteration in 0..<(iterations + 1) {+            let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+            let context = ModelContext(container)+            let start = clock.now+            let diagnostics = try V4LibraryValidator.validate(context: context)+            let elapsed = clock.now - start+            if iteration > 0 { samples.append(elapsed) }+            if iteration == 0 {+                // The fixture is coherent, so a diagnosis here would mean the+                // number below was measured over a graph in some other state.+                #expect(+                    diagnostics.isEmpty,+                    "the coherent M4 fixture must validate clean: \(diagnostics.diagnoses)")+                // And the work actually happened over the whole graph: 5,000+                // Entries, every one carrying the relationship the validator+                // reads. A fixture regression that left them nil would make this+                // measurement one of the `.siteMissing` path, which skips+                // per-Entry replay entirely and is roughly half the work.+                let linked = try context.fetch(FetchDescriptor<Entry>())+                    .filter { $0.site != nil }.count+                #expect(linked == LibraryRepository.m4FixtureEntryCount)+            }+            withExtendedLifetime(container) {}+        }+        expectWithinBudget(+            "store-level-validation", PerformanceDistribution(samples), extensionOpenBudget)+    }+     // MARK: - Helpers      /// Seeds a fresh V4-valid 5,000-Entry composed store on disk and certifies it@@ -147,7 +205,11 @@ struct M4ScalePerformanceTests {         let repository = LibraryRepository.makeRepository(             configuration, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy())         try await repository.seedM4PerformanceFixture()-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        // Task 19: no relationship wiring here — `seedM4PerformanceFixture`+        // assigns both halves itself (Q43), so this store already carries the+        // graph certification leaves behind.+        // Marked migrated: the extension opens no other version (Q14).+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)         return (configuration, root)     } }
Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift Modified +6 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swiftindex 828393e..da326cc 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift@@ -118,7 +118,12 @@ private final class M4ToleratedFixtureLibrary {         let seeder = LibraryRepository.makeRepository(             configuration, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy())         try await seeder.seedM4PerformanceFixture(toleratedState: state)-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        // Task 19: the relationships come from the fixture itself (Q43) —+        // populated for every state except `.siteMissing`, whose deletion of the+        // taught row takes all 5,000 to nil through the `.nullify` inverses.+        // Seeded at the current schema, so it is marked migrated (Q14, Q26);+        // nothing here simulates a library awaiting the relationship pass.+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)         withExtendedLifetime(container) {}          let (_, opened) = try await LibraryRepository.openV4ForApp(
Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift Modified +5 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swiftindex 77104e7..bdc86fd 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift@@ -413,7 +413,11 @@ private final class M4PerformanceStore {         let seeder = LibraryRepository.makeRepository(             configuration, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy())         try await seeder.seedM4PerformanceFixture(toleratedState: state)-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        // Task 19: the relationships come from the fixture itself (Q43), so the+        // budgets are measured over the graph the app produces rather than one+        // with every relationship nil.+        // Marked migrated: the extension opens no other version (Q14).+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)         withExtendedLifetime(container) {}     } 
Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift Added +175 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swiftnew file mode 100644index 0000000..d137151--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift@@ -0,0 +1,175 @@+import Foundation+import SwiftData+import Testing+@testable import AsterismCore++/// Task 4: the two-process marker contract (Q14, Req 2.3).+///+/// `openV4Container` is shared by both processes, so `ModelContainer.init`+/// performs the lightweight conversion in whichever process opens first — and+/// the extension takes only a *shared* lock, so nothing serialises it against+/// the app. The defence is that the two processes read the readiness marker+/// differently: the app opens a library marked `"4"` or `"5"`, the extension+/// only one marked `"5"`, and it decides *before* constructing a container.+@Suite("Marker contract", .serialized)+struct MarkerContractTests {++    // MARK: - Helpers++    private final class TempDir {+        let url: URL+        init() throws {+            url = FileManager.default.temporaryDirectory.appending(+                path: "MarkerContract-\(UUID())", directoryHint: .isDirectory)+            try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+        }+        deinit { try? FileManager.default.removeItem(at: url) }+    }++    private func config() throws -> (TempDir, LibraryConfiguration) {+        let dir = try TempDir()+        return (dir, LibraryConfiguration(rootDirectory: dir.url, environment: .development))+    }++    /// A first run: creates an empty store and marks it ready at birth. An+    /// empty store has nothing to migrate, so mark-at-birth certifies it at+    /// `"5"` directly (Q26).+    private func makeReadyLibrary(_ configuration: LibraryConfiguration) async throws {+        _ = try await LibraryRepository.openV4ForApp(configuration)+    }++    /// A library in the state this milestone exists for: a store a pre-freeze+    /// build actually recorded at 4.0.0, with the `"4"` marker beside it. The+    /// store is *convertible* — `ModelContainer.init` would happily migrate it —+    /// which is exactly the hazard the extension-side check has to stop.+    private func makeUnmigratedLibrary(_ configuration: LibraryConfiguration) throws {+        try V4RecordedStoreFixture.install(at: configuration.v4StoreURL)+        try writeMarker(configuration, "4\n")+    }++    private func writeMarker(_ configuration: LibraryConfiguration, _ content: String) throws {+        try Data(content.utf8).write(to: configuration.v4MarkerURL, options: .atomic)+    }++    private func markerContent(_ configuration: LibraryConfiguration) throws -> String {+        try String(contentsOf: configuration.v4MarkerURL, encoding: .utf8)+            .trimmingCharacters(in: .whitespacesAndNewlines)+    }++    /// The extension's shipped refusal (Req 2.3) — the message a reader sees+    /// when the containing app has not brought the library up to date.+    private static let declined = LibraryRepositoryError.libraryUnavailable(+        operation: "opening V4 library from extension",+        reason: "the containing app has not initialized the current library")++    // MARK: - App side accepts both versions++    @Test("The app opens a library marked \"4\" and one marked \"5\"")+    func appAcceptsBothMarkerVersions() async throws {+        let (_, cfg) = try config()+        try await makeReadyLibrary(cfg)+        #expect(try markerContent(cfg) == "5",+                "an empty store has nothing to migrate, so it is certified migrated (Q26)")++        let (atFive, _) = try await LibraryRepository.openV4ForApp(cfg)+        #expect(atFive == .ready(.zero), "a migrated library opens in the app")++        // Deliberate pre-migration state: the marker a pre-freeze build's+        // library still carries. The app opens it, runs the relationship pass+        // over it, and republishes "5" (Q31).+        try writeMarker(cfg, "4\n")+        let (atFour, _) = try await LibraryRepository.openV4ForApp(cfg)+        #expect(atFour == .ready(.zero), "the migration exists for libraries still marked \"4\"")+        #expect(try markerContent(cfg) == "5", "the open republishes readiness at \"5\"")+    }++    @Test("The app fails closed on a marker version it does not open",+          arguments: ["3\n", "6\n", "45\n", "", "four\n"])+    func appRejectsUnknownMarkerVersions(content: String) async throws {+        let (_, cfg) = try config()+        try await makeReadyLibrary(cfg)+        try writeMarker(cfg, content)++        await #expect(throws: LibraryRepositoryError.self) {+            try await LibraryRepository.openV4ForApp(cfg)+        }+    }++    // MARK: - Extension side requires the migrated version++    @Test("The extension opens a library marked \"5\"")+    func extensionAcceptsTheMigratedVersion() async throws {+        let (_, cfg) = try config()+        try await makeReadyLibrary(cfg)+        #expect(try markerContent(cfg) == "5")++        let (result, _) = try await LibraryRepository.openV4ForExtension(cfg)+        #expect(result == .ready(.zero))+    }++    @Test("The extension declines a library still marked \"4\", with the shipped message")+    func extensionDeclinesTheUnmigratedVersion() async throws {+        let (_, cfg) = try config()+        try makeUnmigratedLibrary(cfg)++        await #expect(throws: Self.declined) {+            try await LibraryRepository.openV4ForExtension(cfg)+        }+    }++    @Test("The extension declines a \"4\" marker before it constructs a ModelContainer")+    func extensionDeclinesBeforeOpeningAContainer() async throws {+        let (_, cfg) = try config()+        // A genuinely 4.0.0-recorded store, not a corrupt one: the container+        // *would* open it, converting it to 5.0.0 in a process holding only a+        // shared lock. That is the hazard (Q14) — a store that cannot be opened+        // at all would prove nothing about the ordering.+        try makeUnmigratedLibrary(cfg)++        await #expect(throws: Self.declined) {+            try await LibraryRepository.openV4ForExtension(cfg)+        }+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.v4StoreURL) == ["4.0.0"],+                "the marker check must decide before ModelContainer.init converts anything")++        // Control: with a "5" marker the same store is reached, opened, and+        // converted. Without this the assertion above could hold because the+        // store was unopenable rather than because the marker was read first.+        try writeMarker(cfg, "5\n")+        _ = try await LibraryRepository.openV4ForExtension(cfg)+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.v4StoreURL) == ["5.0.0"],+                "the same store converts once the marker check passes")+    }++    @Test("The extension fails closed on a marker version no build understands")+    func extensionRejectsUnknownMarkerVersions() async throws {+        let (_, cfg) = try config()+        try await makeReadyLibrary(cfg)+        try writeMarker(cfg, "6\n")++        await #expect(throws: LibraryRepositoryError.self) {+            try await LibraryRepository.openV4ForExtension(cfg)+        }+    }++    @Test("The extension never migrates a library it declined")+    func extensionLeavesTheDeclinedLibraryUntouched() async throws {+        let (_, cfg) = try config()+        try makeUnmigratedLibrary(cfg)+        let before = try Data(contentsOf: cfg.v4StoreURL)++        _ = try? await LibraryRepository.openV4ForExtension(cfg)++        // The marker is the cheap half of the claim. The half that matters is+        // that the *store* is untouched: still recorded at 4.0.0, byte for+        // byte the file the declined library had.+        #expect(try markerContent(cfg) == "4", "the extension may not republish readiness")+        #expect(try Data(contentsOf: cfg.v4StoreURL) == before)+        for suffix in ["-wal", "-shm"] {+            #expect(!FileManager.default.fileExists(atPath: cfg.v4StoreURL.path + suffix),+                    "declining must not have opened the store at all")+        }+        // Last, because reading the metadata opens the file itself.+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.v4StoreURL) == ["4.0.0"])+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift Modified +8 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swiftindex 152bc6a..0e87333 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift@@ -45,10 +45,17 @@ struct QuarantineScopingTests {             context.insert(good)              try context.save()+            // Task 19: the marker published below says the relationship pass has+            // run, so the seeded graph must look as though it did — every record+            // pinned to the row `SiteResolutionOrder` picks, and nil only where the+            // hostname carries no Site row at all.+            try V5RelationshipPass.run(context: context)             withExtendedLifetime(container) {}         } -        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        // Seeded at the current schema, so it is marked migrated (Q14, Q26);+        // nothing here simulates a library awaiting the relationship pass.+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)          let clock = QuarantineClock(Date(timeIntervalSince1970: 1_800_000_000))         let (_, repository) = try await LibraryRepository.openV4ForApp(
Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift Modified +144 / -9
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swiftindex 2fb4b78..f16449a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift@@ -102,14 +102,20 @@ struct RecentPresentationToleranceTests {         #expect(row.attention == .siteDuplicated)     } -    /// Decision 9 end to end. The Entry cites a pattern owned by the Site row that-    /// *lost* the tiebreak; Recent replays that citation to produce the unresolved-    /// candidate title. A winner-only lookup finds nothing and throws, which is the-    /// failure the union exists to remove — and this is the first phase in which-    /// that code is reachable at all.-    @Test("A candidate replay resolves a pattern owned by the losing Site row")-    func candidateReplayResolvesAcrossTheUnionOfRows() async throws {+    /// The provenance half of Decision 5, in the shape Decision 4 preserves —+    /// what Decision 9 of `specs/library-integrity-tolerance` used the+    /// hostname-wide union for. Recent's *presentation* still follows the winner+    /// (the row's mode, title cleaning and pill), which is the other half of the+    /// same decision. The Entry cites a pattern owned by the Site row that+    /// *lost* the tiebreak, and it is pinned to that row — the sync-shaped graph, since only mirroring can produce two+    /// rows with split citation ownership. Recent replays the citation to produce+    /// the unresolved candidate title. A winner-only lookup finds nothing, which+    /// is the failure the union removed and the record's own relationship must+    /// keep removed (task 14).+    @Test("A candidate replay resolves a pattern owned by the Entry's own non-winner row")+    func candidateReplayFollowsTheEntrysOwnRow() async throws {         let library = try RecentToleranceFixture()+        let entryID = UUID()         let ids = [UUID(), UUID()].sorted()         let winningPatternID = ids[0]         let losingPatternID = ids[1]@@ -126,11 +132,22 @@ struct RecentPresentationToleranceTests {                 id: losingPatternID, site: loser, isActive: true, definition: definition)              let entry = store.insertEntry(-                hostname: "dup.example", title: "A Cited Work - Chapter 3", offset: 0)+                id: entryID, hostname: "dup.example", title: "A Cited Work - Chapter 3", offset: 0)             entry.workAssignmentProvenance = .pattern             entry.workPatternID = losingPatternID             entry.workPatternVersion = 1         }+        // Pin the Entry to the row that owns its citation, undoing the seed's+        // winner-pinning pass for this one record (Decision 4): the fixture+        // models a mirrored graph, and a synced relationship arrives as a+        // pointer to its originating row, never through hostname resolution.+        try library.mutate { context in+            let sites = try context.fetch(FetchDescriptor<Site>())+            let loser = try #require(sites.first { $0.displayName == "lose-row" })+            let entry = try #require(try context.fetch(FetchDescriptor<Entry>())+                .first { $0.id == entryID })+            entry.site = loser+        }         let repository = try await library.openForApp()          let presentation = try await repository.recentPresentation(calendar: .current)@@ -140,6 +157,106 @@ struct RecentPresentationToleranceTests {         #expect(row.unresolvedCandidateTitle == "A Cited Work")     } +    // MARK: - A cited pattern that resolves nowhere (relational-references Req 3.4, Q13)++    /// The regression Q13 orders *before* the cited search space narrows to the+    /// Entry's own Site. `replayRecentCandidate` threw `corruptLibrary` and the+    /// throw was not caught locally, so a single Entry citing a pattern no Site+    /// row owns failed the whole publication and took every unrelated row with+    /// it. Once resolution follows `entry.site`, a nil relationship produces+    /// exactly this shape — 2,995 of 3,000 at the CloudKit probe's peak — so the+    /// throw has to be gone first.+    @Test("An Entry citing a pattern that resolves nowhere still gets a row")+    func entryCitingAnUnresolvablePatternIsEmittedWithAttention() async throws {+        let library = try RecentToleranceFixture()+        let definition = PatternDefinition.segment(+            work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1), ignored: [])+        try library.seed { store in+            let site = store.insertSite(hostname: "taught.example")+            site.mode = .taught+            try store.insertTitlePattern(site: site, isActive: true, definition: definition)+            let entry = store.insertEntry(+                hostname: "taught.example", title: "A Cited Work - Chapter 3", offset: 0)+            entry.workAssignmentProvenance = .pattern+            entry.workPatternID = UUID()+            entry.workPatternVersion = 1+            store.insertEntry(+                hostname: "taught.example", title: "Unrelated Capture", offset: 10)+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)++        let rows = presentation.allRows+        #expect(rows.count == 2)+        let cited = try #require(rows.first { $0.captureTitle == "A Cited Work - Chapter 3" })+        #expect(cited.attention == .citationUnresolved)+        #expect(cited.unresolvedCandidateTitle == nil)+        // The Site itself resolved, so the row keeps its mode and its evidence.+        #expect(cited.siteMode == .taught)+        // What the throw used to cost: every other row on the screen.+        let unrelated = try #require(rows.first { $0.captureTitle == "Unrelated Capture" })+        #expect(unrelated.attention == nil)+    }++    /// Decision 5's stated consequence, and the disagreement that made it worth+    /// pinning. The applicability guard was written twice and the two copies+    /// differed on exactly this record — pattern provenance, no Work, and its+    /// own Site relationship not yet arrived: Recent marked it+    /// `.citationUnresolved` while Entry detail rendered it as healthy. A nil+    /// Site means the citation's evidence is *absent*, not broken, and teaching+    /// the hostname is the repair, so both surfaces now report the citation as+    /// not applicable. The cited pattern exists and is owned by the hostname's+    /// row, so a replay that searched the hostname instead of the record's own+    /// relationship would resolve it and this test would fail either way.+    @Test("An Entry whose own Site relationship never arrived is not marked for its citation")+    func citingEntryWithNoSiteRelationshipIsNotMarked() async throws {+        let library = try RecentToleranceFixture()+        let entryID = UUID()+        let citedID = UUID()+        let definition = PatternDefinition.segment(+            work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1), ignored: [])+        try library.seed { store in+            let site = store.insertSite(hostname: "taught.example")+            site.mode = .taught+            try store.insertTitlePattern(+                id: citedID, site: site, isActive: true, definition: definition)+            let entry = store.insertEntry(+                id: entryID, hostname: "taught.example",+                title: "A Cited Work - Chapter 3", offset: 0)+            entry.workAssignmentProvenance = .pattern+            entry.workPatternID = citedID+            entry.workPatternVersion = 1+        }+        // Undo the seed's relationship pass for this one record: the shape a+        // first CloudKit hydration produces at scale — 2,995 of 3,000 Entries+        // at the probe's peak.+        try library.mutate { context in+            let entry = try #require(try context.fetch(FetchDescriptor<Entry>())+                .first { $0.id == entryID })+            entry.site = nil+        }+        let repository = try await library.openForApp()++        let presentation = try await repository.recentPresentation(calendar: .current)++        let row = try #require(presentation.allRows.first)+        #expect(row.attention == nil)+        #expect(row.unresolvedCandidateTitle == nil)+        // The hostname's own teaching still resolves, so the row keeps its mode+        // and its repair route.+        #expect(row.siteMode == .taught)++        // Entry detail answers identically for the same record, which is the+        // 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 {+            #expect(reason != "Cited title pattern does not resolve",+                    "an absent relationship is not an unresolvable citation")+        }+    }+     // MARK: - Two records of one type sharing an application UUID (Req 1.1)      @Test("Duplicate Work UUIDs resolve to the earliest row rather than throwing")@@ -436,8 +553,15 @@ private final class RecentToleranceFixture {         let store = RecentSeedStore(context: ModelContext(container))         try body(store)         try store.context.save()+        // Task 19: the marker published below says the relationship pass has+        // run, so the seeded graph must look as though it did — every record+        // pinned to the row `SiteResolutionOrder` picks, and nil only where the+        // hostname carries no Site row at all.+        try V5RelationshipPass.run(context: store.context)         withExtendedLifetime(container) {}-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        // Seeded at the current schema, so it is marked migrated (Q14, Q26);+        // nothing here simulates a library awaiting the relationship pass.+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)     }      func readContext() throws -> ModelContext {@@ -446,6 +570,17 @@ private final class RecentToleranceFixture {         return ModelContext(container)     } +    /// Mutates the seeded store *without* re-running the relationship pass —+    /// for the one shape the pass cannot express (Decision 4): a record pinned+    /// to the duplicate row that owns its citations rather than to the winner.+    func mutate(_ body: (ModelContext) throws -> Void) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let context = ModelContext(container)+        try body(context)+        try context.save()+        withExtendedLifetime(container) {}+    }+     func openForApp() async throws -> LibraryRepository {         let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4,
Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift Modified +9 / -3
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swiftindex cb9d374..eae38fd 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift@@ -373,15 +373,21 @@ private final class RefreshFixture {         }     } -    /// Seeds in a scoped container, releases it, and publishes readiness so both-    /// bootstrap paths accept the library.+    /// Seeds in a scoped container, releases it, and publishes migrated+    /// readiness so both bootstrap paths accept the library — the extension+    /// accepts no other version (Q14).     func seed(_ body: (SeedStore) throws -> Void) throws {         let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)         let store = SeedStore(context: ModelContext(container))         try body(store)         try store.context.save()+        // Task 19: the marker published below says the relationship pass has+        // run, so the seeded graph must look as though it did — every record+        // pinned to the row `SiteResolutionOrder` picks, and nil only where the+        // hostname carries no Site row at all.+        try V5RelationshipPass.run(context: store.context)         withExtendedLifetime(container) {}-        try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)+        try LibraryRepository.publishV5Readiness(at: configuration.v4MarkerURL)     }      /// A fresh container and context over the seeded file — the offline stand-in
Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift Modified +38 / -10
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swiftindex 6f14fdc..29ec12d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift@@ -731,8 +731,15 @@ struct RepositoryActionableRecentTests {         #expect(row.actionType == .reteach)     } -    @Test("Recent fails closed when retained assignment pattern cannot replay")-    func unparseableHistoricalReplayFails() async throws {+    /// **Q13 (relational-references) reverses this test.** It used to assert that+    /// a retained pattern which cannot reproduce its candidate fails the whole+    /// publication. That was affordable only while a citation could not go+    /// unresolved for any tolerated reason; once cited rules resolve through+    /// `entry.site`, an unarrived relationship reaches this same branch during+    /// ordinary first hydration, and one Entry would blank the screen the reader+    /// lands on. The row is marked instead (Req 3.4).+    @Test("Recent marks the row when a retained assignment pattern cannot replay")+    func unparseableHistoricalReplayIsMarkedNotThrown() async throws {         let fixture = try await TeachingFixture()         _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")         let definition = PatternDefinition.segment(@@ -758,9 +765,18 @@ struct RepositoryActionableRecentTests {             patternVersion: patternVersion         ) -        await #expect(throws: LibraryRepositoryError.self) {-            try await fixture.repository.recentPresentation(calendar: .current)-        }+        let presentation = try await fixture.repository.recentPresentation(calendar: .current)++        // Both rows are published: losing the unrelated one is what the throw cost.+        #expect(presentation.allRows.count == 2)+        let row = try #require(+            presentation.allRows.first { $0.captureTitle == "NoDelimiterHere" })+        #expect(row.attention == .citationUnresolved)+        #expect(row.unresolvedCandidateTitle == nil)+        // The Site resolved and its hostname is teachable, so the repair the+        // reader needs is still offered on the row that needs it.+        #expect(row.siteMode == .taught)+        #expect(row.actionType == .reteach)     } } @@ -1094,8 +1110,12 @@ struct RepositoryEntryTeachingDetailTests {         #expect(detail.unresolvedCandidateTitle == "Fiction")     } -    @Test("Replay failure on retained producing pattern throws corruptLibrary")-    func replayFailureIntegrity() async throws {+    /// 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).+    @Test("Replay failure on a retained producing pattern is disclosed, not thrown")+    func replayFailureIsDisclosed() async throws {         let fixture = try await TeachingFixture()         let seed = try await fixture.capture(             title: "Ch1 - Fiction | Site",@@ -1125,9 +1145,17 @@ struct RepositoryEntryTeachingDetailTests {             patternVersion: patternVersion         ) -        await #expect(throws: LibraryRepositoryError.self) {-            try await fixture.repository.entryTeachingDetail(id: unparseable.id)-        }+        let detail = try await fixture.repository.entryTeachingDetail(id: unparseable.id)++        #expect(detail.unresolvedCandidateTitle == nil)+        #expect(+            detail.assignmentSettlement+                == .patternUnsettled(+                    patternID: patternID, version: patternVersion,+                    reason: "Cited title pattern does not resolve"))+        // Everything the screen could resolve is still disclosed.+        #expect(detail.siteMode == .taught)+        #expect(detail.activePatternSummary?.id == patternID)         #expect(try await fixture.repository.entry(id: seed.id).captureTitle == "Ch1 - Fiction | Site")     } 
Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV2Tests.swift Modified +0 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV2Tests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV2Tests.swiftindex 2841c89..a514743 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV2Tests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV2Tests.swift@@ -197,7 +197,6 @@ struct SchemaV2Tests {         requireSendable(EntrySnapshot.self)         requireSendable(WorkSnapshot.self)         requireSendable(SiteSnapshot.self)-        requireSendable(TitlePatternSnapshot.self)         requireSendable(WorksSnapshot.self)         requireSendable(DatedEntryGroup.self)     }
Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swift Added +84 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swiftnew file mode 100644index 0000000..fe18ea9--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swift@@ -0,0 +1,84 @@+import Foundation+import Testing++/// Q17 keeps `Site.entries` and `Site.works` out of reach. Access control is+/// **not** what does it: both are `internal`, and every traversal Q17 was+/// arguing about — the five `Work.entries` reads, two of them inside the+/// validator's per-Entry loop on the extension's 1 s path — lives *inside*+/// AsterismCore, where `internal` is no barrier at all.+///+/// What actually protects the fan-out is two things: no `entryValues` /+/// `workValues` convenience accessor exists beside `patternValues` /+/// `urlRuleValues`, so the traversal has to be written out longhand; and this+/// test, which fails when someone writes it out longhand.+///+/// It is a **grep with a compiler around it**, and it is shaped like one: it+/// matches member accesses whose receiver chain mentions a site, so+/// `site.entries`, `entry.site?.entries` and `siteRows.first?.works` are caught+/// while `work.entries` — legitimate, and the shape Q17 compares against — is+/// not. A receiver that mentions a site without being one (`compositeX.entries`)+/// would be a false positive; none exists, and renaming out of the way is a+/// cheaper answer than a real parser.+@Suite("The Site inverses stay unreached")+struct SiteInverseReachTests {++    /// Where the relationships are declared. The declaration is the one place+    /// the names may legitimately appear.+    private static let declarationFiles: Set<String> = ["Models.swift"]++    private static var sourcesDirectory: URL {+        URL(fileURLWithPath: #filePath)          // …/Tests/AsterismCoreTests/<this file>+            .deletingLastPathComponent()          // …/Tests/AsterismCoreTests+            .deletingLastPathComponent()          // …/Tests+            .deletingLastPathComponent()          // …/AsterismCore+            .appending(path: "Sources")+    }++    /// `<receiver chain>.entries` / `.works`, capturing the chain.+    private static let access = try! NSRegularExpression(+        pattern: "([A-Za-z_][A-Za-z0-9_?!.\\[\\]]*)\\.(entries|works)\\b")++    @Test("No file outside the schema declaration traverses a Site's inverses")+    func noSiteInverseTraversals() throws {+        let sources = Self.sourcesDirectory+        let enumerator = try #require(+            FileManager.default.enumerator(at: sources, includingPropertiesForKeys: nil))++        var scannedFiles = 0+        var offences: [String] = []+        for case let url as URL in enumerator where url.pathExtension == "swift" {+            guard !Self.declarationFiles.contains(url.lastPathComponent) else { continue }+            scannedFiles += 1+            let text = try String(contentsOf: url, encoding: .utf8)+            for (number, line) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {+                let code = Self.strippingComments(String(line))+                guard !code.isEmpty else { continue }+                let range = NSRange(code.startIndex..<code.endIndex, in: code)+                for match in Self.access.matches(in: code, range: range) {+                    guard let chainRange = Range(match.range(at: 1), in: code) else { continue }+                    let chain = String(code[chainRange])+                    guard chain.lowercased().contains("site") else { continue }+                    offences.append("\(url.lastPathComponent):\(number + 1): \(code.trimmingCharacters(in: .whitespaces))")+                }+            }+        }++        #expect(scannedFiles > 20, "the scan found almost no sources — check the path")+        #expect(offences.isEmpty, """+            A Site inverse is being traversed. `Site.entries` faults every Entry \+            for a hostname — roughly 125× the fan-out of `Work.entries` — which \+            the Req 5.1 budgets would pay for (Q17). Resolve from the record \+            instead: `entry.site`, or a hostname lookup where no record \+            identifies a Site yet (Req 3.3).+            \(offences.joined(separator: "\n"))+            """)+    }++    /// Drops `//` comments so a doc comment naming `Site.entries` — several do,+    /// deliberately — is not read as a traversal. String literals are not+    /// handled; none in this package contains such an access.+    private static func strippingComments(_ line: String) -> String {+        guard let marker = line.range(of: "//") else { return line }+        return String(line[line.startIndex..<marker.lowerBound])+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/V4Fixtures.swift Modified +23 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4Fixtures.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4Fixtures.swiftindex dff38a1..24358f5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4Fixtures.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4Fixtures.swift@@ -2,8 +2,14 @@ import Foundation  @testable import AsterismCore -/// A coherent, valid V4 Site graph for validator tests, mutated per-test to+/// A coherent, valid Site graph for validator tests, mutated per-test to /// exercise the closed tuple table and Entry-state enumeration.+///+/// The builders set `entry.site` and `work.site` (task 19): every write site+/// links both halves in the same save (Req 1.4) and cited-rule resolution+/// follows the relationship (Req 3.2), so an unlinked graph is one the app+/// cannot produce. A test about the nil relationship itself unlinks explicitly+/// — see `V4ValidatorNilSiteToleranceTests`. struct V4Fixture {     let timestamp: Date     let site: Site@@ -12,6 +18,16 @@ struct V4Fixture {     let work: Work     let entry: Entry +    /// Moves the Entry onto a hostname that carries no Site row. Both halves of+    /// the reference move together (Req 1.4): leaving `entry.site` pointed at+    /// the old row would build a graph no write site and no migration can+    /// produce, and the citation resolution that follows the relationship would+    /// then be answering about a row the test says the Entry has left.+    func moveEntryToUnknownHostname(_ hostname: String) {+        entry.hostname = hostname+        entry.site = nil+    }+     var graph: V4LibraryGraph {         V4LibraryGraph(             entries: [entry], works: [work], sites: [site],@@ -77,6 +93,8 @@ enum V4Fixtures {         entry.workURLRuleID = rule.id         entry.workURLRuleVersion = rule.version         work.entries = [entry]+        entry.site = site+        work.site = site          return V4Fixture(             timestamp: timestamp, site: site, titlePattern: titlePattern,@@ -123,6 +141,8 @@ enum V4Fixtures {         entry.workPatternID = titlePattern.id         entry.workPatternVersion = titlePattern.version         work.entries = [entry]+        entry.site = site+        work.site = site          return V4Fixture(             timestamp: timestamp, site: site, titlePattern: titlePattern,@@ -151,6 +171,8 @@ enum V4Fixtures {         entry.workPatternID = titlePattern.id         entry.workPatternVersion = titlePattern.version         work.entries = [entry]+        entry.site = site+        work.site = site          return V4LibraryGraph(             entries: [entry], works: [work], sites: [site],
Packages/AsterismCore/Tests/AsterismCoreTests/V4LibraryValidatorTests.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4LibraryValidatorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4LibraryValidatorTests.swiftindex ba508c1..12ce0ae 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4LibraryValidatorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4LibraryValidatorTests.swift@@ -146,7 +146,7 @@ struct V4LibraryValidatorTests {     @Test("An Entry referencing a missing Site fails store-level")     func unresolvedSiteThrows() throws {         let fixture = try V4Fixtures.wcSegmentIdentitySequence()-        fixture.entry.hostname = "nowhere.example"+        fixture.moveEntryToUnknownHostname("nowhere.example")         #expect(throws: V4ValidationError.self) {             _ = try V4LibraryValidator.validateStrict(graph: fixture.graph)         }
Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift Modified +20 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swiftindex 0049faf..b12b600 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift@@ -258,7 +258,9 @@ struct V4MigrationBootstrapTests {     func futureV4MarkerFailsClosed() async throws {         let (_, cfg) = try config()         try makeV3Store(at: cfg) { _ in }-        try writeV4Marker(cfg, content: "5\n")+        // "5" is the migrated library the app also opens (Q14), so the future+        // version this asserts on is the one after it.+        try writeV4Marker(cfg, content: "6\n")         await #expect(throws: LibraryRepositoryError.self) {             try await LibraryRepository.openV4ForApp(cfg)         }@@ -522,6 +524,9 @@ struct V4MigrationBootstrapTests {         let (_, cfg) = try config()         try makeV3Store(at: cfg) { ctx in try self.ordinaryPattern(ctx, host: "ext.example", interpretationRaw: "pattern") }         try writeV3Marker(cfg)+        // Certification runs the relationship pass and publishes "5" itself —+        // the only version the extension opens (Q14) — so the app→extension+        // handoff is proved end-to-end, with no stand-in marker write (Q26).         _ = try await LibraryRepository.openV4ForApp(cfg)          let (result, _) = try await LibraryRepository.openV4ForExtension(cfg)@@ -556,7 +561,20 @@ struct V4MigrationBootstrapTests {         do {             let (_, cfg) = try config()             try makeV3Store(at: cfg) { _ in }-            try writeV4Marker(cfg, content: "5\n")+            try writeV4Marker(cfg, content: "6\n")+            await #expect(throws: LibraryRepositoryError.self) { try await LibraryRepository.openV4ForExtension(cfg) }+        }+        // (e) A certified but unmigrated library: the marker still reads "4",+        // so the extension declines rather than converting under a shared lock+        // (Q14). No production path leaves this state any more — certification+        // runs the relationship pass and publishes "5" — so it is constructed+        // the way it really arises: a store a pre-freeze build recorded, with+        // the "4" marker that build published beside it. MarkerContractTests+        // pins the message and the ordering.+        do {+            let (_, cfg) = try config()+            try V4RecordedStoreFixture.install(at: cfg.v4StoreURL)+            try writeV4Marker(cfg)             await #expect(throws: LibraryRepositoryError.self) { try await LibraryRepository.openV4ForExtension(cfg) }         }     }
Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift Added +304 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swiftnew file mode 100644index 0000000..8e69371--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift@@ -0,0 +1,304 @@+import Foundation+import SQLite3+import SwiftData+import Testing++@testable import AsterismCore++/// The one store in the repository actually **recorded at 4.0.0**.+///+/// Nothing on this branch can write one any more: `AsterismV4MigrationPlan` is+/// gone and the live classes are V5's, so every store a test creates today is+/// recorded at 5.0.0. Without this file the faithfulness of the frozen+/// `AsterismSchemaV4` snapshot — the thing Q20 says a real library's openability+/// depends on — would be verified only by reading the two declarations side by+/// side.+///+/// It was generated in a detached worktree at 8f5695a (the commit before the+/// freeze, where the live classes *are* V4) by seeding one row of each of the+/// five models through the then-live `openV4Container`, running+/// `V4LibraryValidator.validate` over the result, checkpointing the WAL with+/// `PRAGMA wal_checkpoint(TRUNCATE)` and copying the single `.sqlite` here.+enum V4RecordedStoreFixture {+    static let hostname = "frozen.example"+    static let siteDisplayName = "Frozen Example"+    static let patternID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!+    static let rulePatternID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")!+    static let workID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")!+    static let entryID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")!+    static let captureTitle = "Chapter 7 — A Frozen Work"+    static let note = "Recorded at 4.0.0 ✓"+    static let rawURLString = "https://frozen.example/read?series=42&chapter=7"++    static var sourceURL: URL {+        URL(fileURLWithPath: #filePath)+            .deletingLastPathComponent()+            .appending(path: "Fixtures/v4-recorded-4.0.0.sqlite")+    }++    /// Copies the fixture to `storeURL`. Opening it converts it in place, so+    /// every caller works on its own copy and the resource stays at 4.0.0.+    static func install(at storeURL: URL) throws {+        try FileManager.default.createDirectory(+            at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)+        for suffix in ["", "-wal", "-shm"] {+            try? FileManager.default.removeItem(+                at: URL(fileURLWithPath: storeURL.path + suffix))+        }+        try FileManager.default.copyItem(at: sourceURL, to: storeURL)+    }++    /// The schema versions Core Data recorded into the store's own metadata —+    /// `["4.0.0"]` until something converts it, `["5.0.0"]` afterwards. Read+    /// straight out of `Z_METADATA` rather than through SwiftData, so asking+    /// the question cannot itself perform the conversion.+    static func recordedModelVersions(at storeURL: URL) throws -> [String] {+        var handle: OpaquePointer?+        // Read-*write*, despite only ever running a SELECT: a read-only+        // connection cannot create the `-shm` a WAL-mode store needs, and a+        // store Core Data has just converted keeps that conversion in its WAL.+        // SQLite removes both sidecars again when the last connection closes.+        guard sqlite3_open_v2(storeURL.path, &handle, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK,+              let database = handle else {+            throw FixtureError.unreadable("could not open \(storeURL.lastPathComponent)")+        }+        defer { sqlite3_close(database) }++        var statement: OpaquePointer?+        guard sqlite3_prepare_v2(database, "SELECT Z_PLIST FROM Z_METADATA", -1, &statement, nil) == SQLITE_OK,+              let query = statement else {+            throw FixtureError.unreadable(+                "Z_METADATA unreadable: \(String(cString: sqlite3_errmsg(database)))")+        }+        defer { sqlite3_finalize(query) }++        guard sqlite3_step(query) == SQLITE_ROW,+              let bytes = sqlite3_column_blob(query, 0) else {+            throw FixtureError.unreadable("no Z_METADATA row")+        }+        let plist = Data(bytes: bytes, count: Int(sqlite3_column_bytes(query, 0)))+        let decoded = try PropertyListSerialization.propertyList(+            from: plist, options: [], format: nil)+        guard let metadata = decoded as? [String: Any],+              let identifiers = metadata["NSStoreModelVersionIdentifiers"] as? [String] else {+            throw FixtureError.unreadable("no NSStoreModelVersionIdentifiers")+        }+        return identifiers+    }++    enum FixtureError: Error { case unreadable(String) }+}++/// The second genuinely **4.0.0-recorded** store: 432 Entries, 36 Works and 4+/// Site rows across three hostnames, one of which carries a duplicate row.+///+/// `V4RecordedStoreFixture` holds one row of each model, which is enough to+/// prove the frozen snapshot hash-matches but not enough to say the 4.0.0 →+/// 5.0.0 conversion is lossless over a real graph: a single row cannot exercise+/// citation provenance across several Sites, and with one Site row there is no+/// wrong row for the relationship pass to pick.+///+/// Generated the same way as its sibling — a detached worktree at 8f5695a (the+/// commit before the freeze, where the live classes *are* V4), a throwaway test+/// seeding the shape below through the then-live `openV4Container`, `PRAGMA+/// wal_checkpoint(TRUNCATE)`, and the single `.sqlite` copied into `Fixtures/`.+/// Every value is a pure function of its indices, so the expectations below are+/// the seeded values rather than a re-read of the store.+enum V4RecordedScaleStoreFixture {+    static let hostnames = ["alpha.example", "beta.example", "dup.example"]+    /// The hostname carrying two Site rows: a taught row owning both rules and+    /// an untaught duplicate owning none.+    static let duplicatedHostname = "dup.example"+    static let duplicateRowDisplayName = "Duplicate"+    static let worksPerHost = 12+    static let entriesPerWork = 12+    static let ts = Date(timeIntervalSince1970: 1_800_000_000)++    static var entryCount: Int { hostnames.count * worksPerHost * entriesPerWork }+    static var workCount: Int { hostnames.count * worksPerHost }+    static var siteCount: Int { hostnames.count + 1 }++    static func uuid(_ namespace: Int, _ index: Int) -> UUID {+        guard let id = UUID(+            uuidString: String(format: "%08X-0000-4000-8000-%012X", namespace, index)+        ) else { preconditionFailure("deterministic fixture UUID format") }+        return id+    }++    static func siteDisplayName(hostIndex: Int) -> String { "Site \(hostIndex)" }+    static func patternID(hostIndex: Int) -> UUID { uuid(1, hostIndex) }+    static let patternVersion = 3+    static func urlRuleID(hostIndex: Int) -> UUID { uuid(2, hostIndex) }+    static let urlRuleVersion = 2+    static func workID(hostIndex: Int, workIndex: Int) -> UUID {+        uuid(3, hostIndex * 1_000 + workIndex)+    }+    static func workTitle(hostIndex: Int, workIndex: Int) -> String {+        "Story \(hostIndex)-\(workIndex)"+    }+    static func entryID(hostIndex: Int, workIndex: Int, entryIndex: Int) -> UUID {+        uuid(4, hostIndex * 1_000_000 + workIndex * 1_000 + entryIndex)+    }+    static func rawURL(hostname: String, workIndex: Int, entryIndex: Int) -> String {+        "https://\(hostname)/read?chapter=\(entryIndex + 1)&w=\(workIndex)"+    }+    static func captureTitle(hostIndex: Int, workIndex: Int, entryIndex: Int) -> String {+        "[Chapter \(entryIndex + 1) of Story \(hostIndex)-\(workIndex).]"+    }++    static var sourceURL: URL {+        URL(fileURLWithPath: #filePath)+            .deletingLastPathComponent()+            .appending(path: "Fixtures/v4-recorded-4.0.0-scale.sqlite")+    }++    /// Copies the fixture to `storeURL`. Opening it converts it in place, so+    /// every caller works on its own copy and the resource stays at 4.0.0.+    static func install(at storeURL: URL) throws {+        try FileManager.default.createDirectory(+            at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)+        for suffix in ["", "-wal", "-shm"] {+            try? FileManager.default.removeItem(+                at: URL(fileURLWithPath: storeURL.path + suffix))+        }+        try FileManager.default.copyItem(at: sourceURL, to: storeURL)+    }+}++/// Proof that the frozen `AsterismSchemaV4` snapshot hash-matches what shipped:+/// a store a *pre-freeze* build wrote still opens under the V5 plan, and every+/// field comes back as it was written.+@Suite("A 4.0.0-recorded store under the V5 plan", .serialized)+struct V4RecordedStoreTests {++    private final class TempDir {+        let url: URL+        init() throws {+            url = FileManager.default.temporaryDirectory.appending(+                path: "V4Recorded-\(UUID())", directoryHint: .isDirectory)+            try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+        }+        deinit { try? FileManager.default.removeItem(at: url) }+    }++    @Test("The fixture really is recorded at 4.0.0")+    func fixtureIsRecordedAtFourZeroZero() throws {+        // Read a copy, never the resource: opening a WAL-mode store — even to+        // run one SELECT — creates `-wal` / `-shm` beside it, and the tracked+        // file must stay a single self-contained `.sqlite`.+        let dir = try TempDir()+        let copy = dir.url.appending(path: "fixture.sqlite")+        try V4RecordedStoreFixture.install(at: copy)++        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: copy) == ["4.0.0"],+                "the resource must never be converted in place")+    }++    @Test("openV4Container converts it and reads every seeded row back intact")+    func opensAndReadsBackIntact() throws {+        let dir = try TempDir()+        let storeURL = dir.url.appending(path: "store.sqlite")+        try V4RecordedStoreFixture.install(at: storeURL)++        let container = try LibraryRepository.openV4Container(at: storeURL)+        let context = ModelContext(container)+        defer { withExtendedLifetime(container) {} }++        let sites = try context.fetch(FetchDescriptor<Site>())+        let entries = try context.fetch(FetchDescriptor<Entry>())+        let works = try context.fetch(FetchDescriptor<Work>())+        let patterns = try context.fetch(FetchDescriptor<TitlePattern>())+        let rules = try context.fetch(FetchDescriptor<URLRulePattern>())+        let counts: [Int] = [sites.count, entries.count, works.count, patterns.count, rules.count]+        #expect(counts == [1, 1, 1, 1, 1])++        let site = try #require(sites.first)+        #expect(site.hostname == V4RecordedStoreFixture.hostname)+        #expect(site.displayName == V4RecordedStoreFixture.siteDisplayName)+        #expect(site.mode == .taught)++        let pattern = try #require(patterns.first)+        #expect(pattern.id == V4RecordedStoreFixture.patternID)+        #expect(pattern.version == 3)+        #expect(pattern.isActive)+        #expect(pattern.trimPrefix == "[")+        #expect(pattern.trimSuffix == "]")+        #expect(try pattern.definition+                == .phrase(prefix: "", separator: " — ", suffix: "", order: .chapterThenWork))+        #expect(pattern.site?.hostname == V4RecordedStoreFixture.hostname)++        let rule = try #require(rules.first)+        #expect(rule.id == V4RecordedStoreFixture.rulePatternID)+        #expect(rule.version == 2)+        #expect(rule.isCurrent)+        #expect(rule.origin == .readerTaught)++        let work = try #require(works.first)+        #expect(work.id == V4RecordedStoreFixture.workID)+        #expect(work.displayTitle == "A Frozen Work")+        #expect(work.siteHostname == V4RecordedStoreFixture.hostname)+        #expect(work.urlIdentity == "42")+        #expect(work.urlIdentityState == .rule)+        #expect(work.urlIdentityRuleID == V4RecordedStoreFixture.rulePatternID)+        #expect(work.genreTags == ["frozen", "fixture"])++        let entry = try #require(entries.first)+        #expect(entry.id == V4RecordedStoreFixture.entryID)+        #expect(entry.captureTitle == V4RecordedStoreFixture.captureTitle)+        #expect(entry.note == V4RecordedStoreFixture.note)+        #expect(entry.rating == .up)+        #expect(entry.rawURLString == V4RecordedStoreFixture.rawURLString)+        #expect(entry.hostname == V4RecordedStoreFixture.hostname)+        #expect(entry.identityKeyVersion == 2)+        #expect(entry.identityBasis == .urlRule)+        // Citation provenance: the tuples this milestone resolves through the+        // Site relationship must survive the conversion unchanged (Req 2.2).+        #expect(entry.chapterTitle == "Chapter 7")+        #expect(entry.chapterTitleProvenance == .pattern)+        #expect(entry.chapterPatternID == V4RecordedStoreFixture.patternID)+        #expect(entry.chapterPatternVersion == 3)+        #expect(entry.chapterSequence == "7")+        #expect(entry.chapterSequenceRuleID == V4RecordedStoreFixture.rulePatternID)+        #expect(entry.chapterSequenceRuleVersion == 2)+        #expect(entry.workAssignmentProvenance == .urlRule)+        #expect(entry.workURLRuleID == V4RecordedStoreFixture.rulePatternID)+        #expect(entry.work?.id == V4RecordedStoreFixture.workID)+        #expect(entry.firstCapturedAt == Date(timeIntervalSince1970: 1_800_000_000))++        // The V4 → V5 stage adds the relationships and leaves them nil. That is+        // the state the relationship pass (tasks 8-11) exists to repair, and a+        // 4.0.0 store is the only place it can be observed.+        #expect(entry.site == nil)+        #expect(work.site == nil)+    }++    @Test("The store is left recorded at 5.0.0 once it has been opened")+    func openingRecordsTheNewVersion() throws {+        let dir = try TempDir()+        let storeURL = dir.url.appending(path: "store.sqlite")+        try V4RecordedStoreFixture.install(at: storeURL)+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["4.0.0"])++        let container = try LibraryRepository.openV4Container(at: storeURL)+        _ = ModelContext(container)+        withExtendedLifetime(container) {}++        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["5.0.0"],+                "ModelContainer.init is what performs the conversion (Q14)")+    }++    @Test("A validator run over the converted store still finds it legal")+    func convertedStoreValidates() throws {+        let dir = try TempDir()+        let storeURL = dir.url.appending(path: "store.sqlite")+        try V4RecordedStoreFixture.install(at: storeURL)++        let container = try LibraryRepository.openV4Container(at: storeURL)+        let context = ModelContext(container)+        defer { withExtendedLifetime(container) {} }++        let diagnostics = try LibraryRepository.validateV4Store(context: context)+        #expect(diagnostics.quarantineMap().isEmpty,+                "the graph was legal when it was written at 4.0.0 and nothing was dropped")+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/V4ValidatorNilSiteToleranceTests.swift Added +330 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4ValidatorNilSiteToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4ValidatorNilSiteToleranceTests.swiftnew file mode 100644index 0000000..a8608eb--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4ValidatorNilSiteToleranceTests.swift@@ -0,0 +1,330 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Task 13, Req 3.4, Q27: the four cited-rule sites in `V4LibraryValidator`+/// tolerate a **nil Site relationship**.+///+/// Each of `:389` (Work rule identity), `:528` (v3 identity name contributor),+/// `:611` (pattern chapter provenance) and `:697` (`requiredReference`) throws+/// when the citation does not resolve; the throw becomes a per-Site diagnosis+/// and the diagnosis quarantines the hostname. Task 14 narrows resolution to+/// `entry.site`, at which point every record whose relationship is nil — a+/// state Req 2.1 explicitly permits — would quarantine its hostname. Req 3.4+/// forbids that by name: a nil relationship SHALL NOT quarantine a hostname,+/// fail a screen, or prevent export.+///+/// So the resolution clause is demoted rather than the whole check: a citation+/// 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+/// 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 V4ValidatorNilSiteToleranceTests {++    /// Unlinks the fixture's records from their Site row. `V4Fixtures` builds+    /// the linked graph — the one every write site and the relationship pass+    /// produce — so the nil-relationship state this suite is about has to be+    /// constructed deliberately. Reversing this is the whole experiment: same+    /// store, same citation, relationship present or absent.+    private func unlink(_ fixture: V4Fixture) {+        fixture.entry.site = nil+        fixture.work.site = nil+    }++    private func diagnostics(_ fixture: V4Fixture) throws -> LibraryDiagnostics {+        try V4LibraryValidator.validate(graph: fixture.graph)+    }++    // MARK: - :389 — Work rule identity++    @Test("Work rule identity: a nil work.site tolerates an unresolvable rule instead of quarantining")+    func workRuleIdentityToleratesNilSite() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        fixture.work.urlIdentityRuleID = UUID()  // cites a rule no Site owns++        let tolerated = try diagnostics(fixture)+        #expect(tolerated.quarantineMap().isEmpty,+                "a nil relationship must not quarantine the hostname (Req 3.4)")+        #expect(tolerated.tupleDiagnoses.isEmpty)+    }++    @Test("Work rule identity: a populated work.site still diagnoses an unresolvable rule")+    func workRuleIdentityStillFailsWithAPopulatedSite() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        fixture.work.urlIdentityRuleID = UUID()++        let diagnosed = try diagnostics(fixture)+        #expect(diagnosed.quarantineMap()[fixture.site.hostname] != nil,+                "with a Site to resolve within, the closed tuple table is unchanged")+    }++    /// The demotion is of the *resolution* clause only. A `.rule` identity with+    /// a blank value is illegal whatever the relationship holds.+    @Test("Work rule identity: a blank identity still fails closed with a nil work.site")+    func workRuleIdentityBlankValueStillFailsClosed() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        fixture.work.urlIdentity = "   "++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    // MARK: - :528 — v3 identity name contributor++    @Test("v3 name contributor: a nil entry.site tolerates an unresolvable pattern instead of quarantining")+    func v3NameContributorToleratesNilSite() throws {+        let fixture = try V4Fixtures.wholeTitleSequence()+        unlink(fixture)+        fixture.entry.identityNameTitleRuleVersion = 99  // version mismatch++        let tolerated = try diagnostics(fixture)+        #expect(tolerated.quarantineMap().isEmpty)+        #expect(tolerated.tupleDiagnoses.isEmpty)+    }++    @Test("v3 name contributor: a populated entry.site still diagnoses an unresolvable pattern")+    func v3NameContributorStillFailsWithAPopulatedSite() throws {+        let fixture = try V4Fixtures.wholeTitleSequence()+        fixture.entry.identityNameTitleRuleVersion = 99++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    /// The v3 arm's non-citation clauses — no Work identity, a sequence rule+    /// equal to the identity rule — are not part of the demotion.+    @Test("v3 identity: a stray Work identity still fails closed with a nil entry.site")+    func v3StrayWorkIdentityStillFailsClosed() throws {+        let fixture = try V4Fixtures.wholeTitleSequence()+        unlink(fixture)+        fixture.entry.urlWorkIdentity = "42"++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    /// The name replay (Req 4.2) is what the contributor is cited *for*. A+    /// contributor that resolves — within the Entry's own linked Site — still+    /// has its replay enforced; only an unresolvable one is tolerated, and with+    /// a nil relationship nothing can resolve in the first place.+    @Test("v3 identity: a resolving contributor that replays a different name still fails closed")+    func v3NameReplayStillFailsClosed() throws {+        let fixture = try V4Fixtures.wholeTitleSequence()+        fixture.entry.captureTitle = "A Different Title Entirely"++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    // MARK: - :611 — pattern chapter provenance++    @Test("Chapter provenance: a nil entry.site tolerates an unresolvable pattern instead of quarantining")+    func chapterProvenanceToleratesNilSite() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        fixture.entry.chapterPatternVersion = 99++        let tolerated = try diagnostics(fixture)+        #expect(tolerated.quarantineMap().isEmpty)+        #expect(tolerated.tupleDiagnoses.isEmpty)+    }++    @Test("Chapter provenance: a populated entry.site still diagnoses an unresolvable pattern")+    func chapterProvenanceStillFailsWithAPopulatedSite() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        fixture.entry.chapterPatternVersion = 99++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    @Test("Chapter provenance: a blank chapter title still fails closed with a nil entry.site")+    func chapterBlankTitleStillFailsClosed() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        fixture.entry.chapterTitle = "  "++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    // MARK: - :697 — requiredReference++    @Test("Required reference: a nil entry.site tolerates an unresolvable assignment rule")+    func requiredReferenceToleratesNilSite() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        fixture.entry.workURLRuleID = UUID()  // assignment cites a rule no Site owns++        let tolerated = try diagnostics(fixture)+        #expect(tolerated.quarantineMap().isEmpty)+        #expect(tolerated.tupleDiagnoses.isEmpty)+    }++    @Test("Required reference: a populated entry.site still diagnoses an unresolvable assignment rule")+    func requiredReferenceStillFailsWithAPopulatedSite() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        fixture.entry.workURLRuleID = UUID()++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    /// An incomplete reference — an id with no version — is malformed rather+    /// than unresolvable, and stays a diagnosis whatever the relationship holds.+    @Test("Required reference: a half-written reference still fails closed with a nil entry.site")+    func requiredReferenceIncompleteStillFailsClosed() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        fixture.entry.workURLRuleVersion = nil++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    // MARK: - validateExtractionReplay (Q40)++    /// `validateExtractionReplay` is not one of the four sites, but it replays a+    /// reference `requiredReference` has just tolerated. Left alone it would+    /// re-throw one call later and quarantine the hostname anyway, undoing the+    /// demotion for every Entry carrying a URL extraction — which is most of+    /// them.+    ///+    /// Reaching it takes all three references moving together: the tuple table+    /// requires the Work and sequence rules to be the same rule, and the v2 key+    /// requires the identity rule to be that rule too. With one bogus id in all+    /// three, every guard above the replay passes and the replay is the only+    /// thing left that can fail.+    @Test("Work extraction replay: a nil entry.site tolerates a retained rule that does not resolve")+    func extractionReplayToleratesNilSite() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        let bogus = UUID()+        fixture.entry.urlWorkRuleID = bogus+        fixture.entry.chapterSequenceRuleID = bogus+        fixture.entry.identityURLRuleID = bogus++        let tolerated = try diagnostics(fixture)+        #expect(tolerated.quarantineMap().isEmpty,+                "the replay must tolerate what requiredReference already tolerated")+        #expect(tolerated.tupleDiagnoses.isEmpty)+    }++    @Test("Work extraction replay: a populated entry.site still diagnoses it")+    func extractionReplayStillFailsWithAPopulatedSite() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        let bogus = UUID()+        fixture.entry.urlWorkRuleID = bogus+        fixture.entry.chapterSequenceRuleID = bogus+        fixture.entry.identityURLRuleID = bogus++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    /// The sequence-only arm of the same routine, on the v3 fixture: no Work+    /// extraction, so the `else if let sequenceReference` branch is the one that+    /// has to tolerate.+    @Test("Sequence extraction replay: a nil entry.site tolerates a retained rule that does not resolve")+    func sequenceReplayToleratesNilSite() throws {+        let fixture = try V4Fixtures.wholeTitleSequence()+        unlink(fixture)+        let bogus = UUID()+        fixture.entry.chapterSequenceRuleID = bogus+        fixture.entry.identityURLRuleID = bogus++        let tolerated = try diagnostics(fixture)+        #expect(tolerated.quarantineMap().isEmpty)+        #expect(tolerated.tupleDiagnoses.isEmpty)+    }++    @Test("Sequence extraction replay: a populated entry.site still diagnoses it")+    func sequenceReplayStillFailsWithAPopulatedSite() throws {+        let fixture = try V4Fixtures.wholeTitleSequence()+        let bogus = UUID()+        fixture.entry.chapterSequenceRuleID = bogus+        fixture.entry.identityURLRuleID = bogus++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    /// The residual path Decision 3 left open, and Decision 5 closes. The replay+    /// used to resolve its retained rule in a **library-global** winners index,+    /// so an Entry with a nil relationship whose cited rule id exists anywhere in+    /// the store still replayed — and quarantined its hostname when the replay+    /// disagreed, which is the outcome Req 3.4 forbids for exactly this state.+    /// The rule here is the fixture's own, still owned by the Site row and still+    /// in the graph; only the Entry's pointer to that row is gone.+    @Test("Work extraction replay: a nil entry.site tolerates a rule that resolves elsewhere in the store")+    func extractionReplayToleratesARuleResolvingOnlyGlobally() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        // The cited rule still exists and still matches by id and version; what+        // fails is the replay, because the raw URL no longer carries the fields+        // the rule reads.+        fixture.entry.rawURLString = "https://example.com/read?other=1"+        fixture.entry.conservativeIdentityKey = fixture.entry.rawURLString++        let tolerated = try diagnostics(fixture)+        #expect(tolerated.quarantineMap().isEmpty,+                "a rule the Entry's own Site no longer owns is not a rule to replay against")+        #expect(tolerated.tupleDiagnoses.isEmpty)+    }++    /// The other half: the same store shape with the relationship present. The+    /// rule is in the Entry's own Site, so the replay is enforced exactly as it+    /// always was — narrowing the lookup weakens nothing for a linked record.+    @Test("Work extraction replay: a linked entry.site still enforces the replay")+    func extractionReplayStillEnforcedForALinkedEntry() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        fixture.entry.rawURLString = "https://example.com/read?other=1"+        fixture.entry.conservativeIdentityKey = fixture.entry.rawURLString++        #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)+    }++    // MARK: - The record survives++    /// Req 3.4's other half: the record is left renderable. A tolerated+    /// citation leaves the Entry in the graph, snapshot-able, and its hostname+    /// unquarantined, so no screen and no export gate refuses it.+    @Test("A tolerated citation leaves the record renderable and the hostname open")+    func toleratedRecordStillRenders() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        fixture.entry.chapterPatternVersion = 99++        let tolerated = try diagnostics(fixture)+        #expect(tolerated.quarantineMap().isEmpty)+        #expect(tolerated.unresolvedRecordCount == 0, "a nil relationship must not block export")+        #expect(try LibraryRepository.snapshot(fixture.entry).id == fixture.entry.id)+    }++    /// The import gates stay strict (Decision 3): tolerance is a property of the+    /// two open paths, and an archive must still be wholly legal.+    @Test("validateStrict is unchanged by the demotion")+    func strictValidationIsUnchanged() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        fixture.entry.chapterPatternVersion = 99++        let diagnoses = try V4LibraryValidator.validateStrict(graph: fixture.graph)+        #expect(diagnoses[fixture.site.hostname] != nil,+                "strict validation diagnoses the tuple whatever the relationship holds")+    }++    /// The other half of Q39. `validateEntryTuple` validates the tuple a commit+    /// is *about to write*, and Req 1.4 makes every write site set both halves+    /// in the same save — so an unlinked citation reaching this gate is a bug in+    /// the writer, not a state to survive. It throws where the open path+    /// tolerates.+    @Test("validateEntryTuple never tolerates: a nil entry.site still throws")+    func entryTupleGateNeverTolerates() throws {+        let fixture = try V4Fixtures.wcSegmentIdentitySequence()+        unlink(fixture)+        fixture.entry.chapterPatternVersion = 99+        #expect(fixture.entry.site == nil)++        #expect(throws: V4ValidationError.self) {+            try V4LibraryValidator.validateEntryTuple(+                entry: fixture.entry, site: fixture.site, works: [fixture.work])+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/V4ValidatorToleranceTests.swift Modified +3 / -3
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4ValidatorToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4ValidatorToleranceTests.swiftindex 2027e4a..8f72da5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4ValidatorToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4ValidatorToleranceTests.swift@@ -301,7 +301,7 @@ struct V4ValidatorToleranceTests {     @Test("The graph entry points carry the same split as the context ones")     func graphEntryPoints() throws {         let fixture = try V4Fixtures.wcSegmentIdentitySequence()-        fixture.entry.hostname = "nowhere.example"+        fixture.moveEntryToUnknownHostname("nowhere.example")          let diagnostics = try V4LibraryValidator.validate(graph: fixture.graph)         #expect(diagnostics.diagnoses == [@@ -329,12 +329,12 @@ private final class ValidatorStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismValidatorTolerance-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV4.self)+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV4MigrationPlan.self,+            for: schema, migrationPlan: AsterismV5MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift Added +365 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swiftnew file mode 100644index 0000000..fe29891--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift@@ -0,0 +1,365 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Task 10: all three certification paths run the relationship pass before+/// publishing readiness (Req 2.1, 2.3, 2.4) — V4-marker, V3-marker, and+/// sidecar-resume. The latter two reach the marker through `certifyMigration`,+/// which would otherwise stamp `"5"` on a library whose relationships were+/// never populated: an M3-era library upgrading in one launch would certify+/// with every relationship nil. Each test asserts the marker reads `"5"` only+/// once relationships are populated. The fourth readiness-publishing path,+/// mark-at-birth for an empty store, publishes `"5"` directly and runs no pass+/// (Q26) — pinned here so it does not grow one.+@Suite("Certification paths run the relationship pass", .serialized)+struct V5CertificationPathTests {++    // MARK: - Helpers++    private final class TempDir {+        let url: URL+        init() throws {+            url = FileManager.default.temporaryDirectory.appending(+                path: "V5Certify-\(UUID())", directoryHint: .isDirectory)+            try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+        }+        deinit { try? FileManager.default.removeItem(at: url) }+    }++    private func config() throws -> (TempDir, LibraryConfiguration) {+        let dir = try TempDir()+        return (dir, LibraryConfiguration(rootDirectory: dir.url, environment: .development))+    }++    private static let ts = Date(timeIntervalSince1970: 1_800_000_000)++    private func markerContent(_ configuration: LibraryConfiguration) throws -> String {+        try String(contentsOf: configuration.v4MarkerURL, encoding: .utf8)+            .trimmingCharacters(in: .whitespacesAndNewlines)+    }++    /// A V3 store carrying one taught Site, one Work, and one Entry citing the+    /// Site's pattern — the graph an M3-era library brings to a one-launch+    /// upgrade. Same shape as `V4MigrationBootstrapTests.ordinaryPattern`.+    private func makeV3Store(at configuration: LibraryConfiguration, host: String) throws {+        try FileManager.default.createDirectory(+            at: configuration.v3StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)+        let container = try LibraryRepository.openV3Container(at: configuration.v3StoreURL)+        let context = ModelContext(container)++        let site = AsterismSchemaV3.Site()+        site.hostname = host+        site.modeRaw = SiteMode.taught.rawValue+        site.titleInterpretationRaw = "pattern"+        let pattern = AsterismSchemaV3.TitlePattern()+        pattern.id = UUID()+        pattern.version = 1+        pattern.isActive = true+        pattern.createdAt = Self.ts+        pattern.formRaw = PatternForm.segment.rawValue+        pattern.segmentWorkAnchor = try SegmentRangeSpec(origin: .start, offset: 0, length: 1)+        pattern.segmentIgnoredAnchors = []+        pattern.site = site+        site.patterns = [pattern]+        let raw = "https://\(host)/read/1"+        let work = AsterismSchemaV3.Work()+        work.displayTitle = "A Work"+        work.siteHostname = host+        work.createdAt = Self.ts+        work.modifiedAt = Self.ts+        let entry = AsterismSchemaV3.Entry()+        entry.captureTitle = "A Work"+        entry.captureTitleSourceRaw = CaptureTitleSource.host.rawValue+        entry.rawURLString = raw+        entry.hostname = host+        entry.entryIdentityKey = raw+        entry.firstCapturedAt = Self.ts+        entry.lastSharedAt = Self.ts+        entry.modifiedAt = Self.ts+        entry.work = work+        entry.chapterTitle = "Chapter"+        entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue+        entry.chapterPatternID = pattern.id+        entry.chapterPatternVersion = pattern.version+        entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue+        entry.workPatternID = pattern.id+        entry.workPatternVersion = pattern.version+        work.entries = [entry]+        context.insert(site)+        context.insert(work)+        context.insert(entry)+        try context.save()+        withExtendedLifetime(container) {}+    }++    /// Every Entry and Work in the store points at the Site row carrying its+    /// hostname — the state certification must produce before it may say "5".+    private func expectRelationshipsPopulated(+        _ configuration: LibraryConfiguration, sourceLocation: SourceLocation = #_sourceLocation+    ) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let context = ModelContext(container)+        let entries = try context.fetch(FetchDescriptor<Entry>())+        let works = try context.fetch(FetchDescriptor<Work>())+        #expect(!entries.isEmpty, "a populated graph is the premise of these paths",+                sourceLocation: sourceLocation)+        for entry in entries {+            #expect(entry.site?.hostname == entry.hostname,+                    "\(entry.rawURLString): relationship populated by the pass",+                    sourceLocation: sourceLocation)+        }+        for work in works {+            #expect(work.site?.hostname == work.siteHostname,+                    "\(work.displayTitle): relationship populated by the pass",+                    sourceLocation: sourceLocation)+        }+        withExtendedLifetime(container) {}+    }++    private func stripRelationships(_ configuration: LibraryConfiguration) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let context = ModelContext(container)+        for entry in try context.fetch(FetchDescriptor<Entry>()) { entry.site = nil }+        for work in try context.fetch(FetchDescriptor<Work>()) { work.site = nil }+        try context.save()+        withExtendedLifetime(container) {}+    }++    // MARK: - V4-marker path++    @Test("V4-marker: a pre-freeze library marked \"4\" opens with relationships populated and is republished at \"5\"")+    func v4MarkerPathRunsThePass() async throws {+        let (dir, cfg) = try config()+        try V4RecordedStoreFixture.install(at: cfg.v4StoreURL)+        try Data("4\n".utf8).write(to: cfg.v4MarkerURL, options: .atomic)++        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        guard case .ready = result else {+            Issue.record("expected ready, got \(result)")+            return+        }++        #expect(try markerContent(cfg) == "5",+                "the app republishes readiness at \"5\" after the pass (Q14)")+        try expectRelationshipsPopulated(cfg)+        withExtendedLifetime(dir) {}+    }++    @Test("Interrupted mid-pass: a store already at 5.0.0 with the marker still \"4\" converges and publishes \"5\" only afterwards")+    func interruptedStateConvergesAndRepublishes() async throws {+        let (dir, cfg) = try config()+        try V4RecordedStoreFixture.install(at: cfg.v4StoreURL)+        try Data("4\n".utf8).write(to: cfg.v4MarkerURL, options: .atomic)++        // Construct the exact state an interruption leaves (Q28): the schema+        // conversion committed by ModelContainer.init on the way in, the pass+        // not yet run, the marker untouched. A test starting from an+        // unconverted store would test a state this path cannot be+        // interrupted in.+        do {+            let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+            _ = ModelContext(container)+            withExtendedLifetime(container) {}+        }+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.v4StoreURL) == ["5.0.0"])+        #expect(try markerContent(cfg) == "4",+                "the interrupted attempt must not have certified partway (Req 2.4)")++        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        guard case .ready = result else {+            Issue.record("expected the re-run to converge, got \(result)")+            return+        }+        #expect(try markerContent(cfg) == "5", "\"5\" is published only after the pass converges")+        try expectRelationshipsPopulated(cfg)+        withExtendedLifetime(dir) {}+    }++    // MARK: - V3-marker path++    @Test("V3-marker: an M3-era library upgrading in one launch certifies at \"5\" with relationships populated")+    func v3MarkerPathRunsThePass() async throws {+        let (dir, cfg) = try config()+        try makeV3Store(at: cfg, host: "m3era.example")+        try Data("3\n".utf8).write(to: cfg.v3MarkerURL, options: .atomic)++        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        guard case .ready = result else {+            Issue.record("expected ready, got \(result)")+            return+        }++        #expect(try markerContent(cfg) == "5",+                "certifyMigration must not stamp \"5\" on a library whose relationships were never populated")+        try expectRelationshipsPopulated(cfg)+        #expect(!FileManager.default.fileExists(atPath: cfg.v3MarkerURL.path))+        #expect(!MigrationSidecarCodec.exists(at: cfg.migrationSidecarURL))+        withExtendedLifetime(dir) {}+    }++    // MARK: - Sidecar-resume path++    @Test("Sidecar-resume: a resumed migration runs the pass before publishing \"5\"")+    func sidecarResumePathRunsThePass() async throws {+        let (dir, cfg) = try config()+        try makeV3Store(at: cfg, host: "resume.example")+        try Data("3\n".utf8).write(to: cfg.v3MarkerURL, options: .atomic)+        _ = try await LibraryRepository.openV4ForApp(cfg)++        // Reconstruct the interrupted handoff: the durable sidecar back beside+        // the store, the marker gone, and the relationships stripped so the+        // resume can only certify by running the pass itself.+        try MigrationSidecarCodec.write(+            MigrationSidecar(taughtSites: [.init(hostname: "resume.example", plan: .retainPattern)]),+            to: cfg.migrationSidecarURL)+        try FileManager.default.removeItem(at: cfg.v4MarkerURL)+        try stripRelationships(cfg)++        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        guard case .ready = result else {+            Issue.record("expected ready on resume, got \(result)")+            return+        }+        #expect(try markerContent(cfg) == "5")+        try expectRelationshipsPopulated(cfg)+        #expect(!MigrationSidecarCodec.exists(at: cfg.migrationSidecarURL))+        withExtendedLifetime(dir) {}+    }++    // MARK: - Already-migrated and mark-at-birth paths++    @Test("An ordinary open of a library already marked \"5\" does not re-run the pass")+    func ordinaryOpenDoesNotReRunThePass() async throws {+        // The pass runs once, at certification (Q29, Q31): a "5" marker means+        // it already ran, and the V4-marker branch must not sweep every Entry+        // and Work on every app launch. This pins that cost, nothing more.+        //+        // It is NOT a statement that no repair path exists or should exist.+        // Stripping the relationships here is the only way to make "the pass+        // did not run" observable; the state it constructs is one production+        // reaches only through the import path, and Decision 2 closes that by+        // ordering — task 18 sets the relationship at every write site before+        // task 14 lets any read follow it — not by repairing it here.+        let (dir, cfg) = try config()+        try V4RecordedStoreFixture.install(at: cfg.v4StoreURL)+        try Data("4\n".utf8).write(to: cfg.v4MarkerURL, options: .atomic)+        _ = try await LibraryRepository.openV4ForApp(cfg)+        #expect(try markerContent(cfg) == "5")+        try stripRelationships(cfg)++        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        guard case .ready = result else {+            Issue.record("expected ready, got \(result)")+            return+        }+        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        let context = ModelContext(container)+        let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)+        #expect(entry.site == nil, "a \"5\" library does not re-run the pass on open")+        withExtendedLifetime((dir, container)) {}+    }++    // MARK: - The pass's failure branch++    /// A save strategy that refuses, so the `libraryUnavailable("running the+    /// relationship migration pass")` branch is reachable without a disk fault.+    private struct RefusingSaveStrategy: RepositorySaveStrategy {+        func save(_ context: ModelContext) throws {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "saving", reason: "refused by the test")+        }+    }++    @Test("A failing save inside the pass surfaces the named error and leaves the marker at \"4\"")+    func failedPassDoesNotPublishTheMarker() async throws {+        let (dir, cfg) = try config()+        try V4RecordedStoreFixture.install(at: cfg.v4StoreURL)+        try Data("4\n".utf8).write(to: cfg.v4MarkerURL, options: .atomic)++        do {+            _ = try await LibraryRepository.openV4ForApp(cfg, saveStrategy: RefusingSaveStrategy())+            Issue.record("expected the failing save to abort the open")+        } catch let error as LibraryRepositoryError {+            guard case .libraryUnavailable(let operation, _) = error else {+                Issue.record("expected libraryUnavailable, got \(error)")+                return+            }+            #expect(operation == "running the relationship migration pass")+        }++        #expect(try markerContent(cfg) == "4",+                "the marker is the commit point: a pass that did not save must not publish \"5\"")+        withExtendedLifetime(dir) {}+    }++    // MARK: - Pass before validate (Q35, task 13)++    /// Task 11 settled that `V5RelationshipPass.run` precedes `validateV4Store`+    /// and could not pin it: with every cited-rule site resolving through the+    /// hostname-union lookup this milestone deleted, no store existed in which+    /// swapping the two produced a different diagnosis. Task 13 made the four+    /// sites read the record's Site relationship, so the test is constructible.+    ///+    /// The store carries one taught Site whose Entry cites a chapter pattern+    /// version that does not exist. Whether that is a tuple diagnosis or a+    /// tolerated state depends entirely on whether the relationship is+    /// populated when the diagnostics are computed:+    ///+    /// - pass first (correct): `entry.site` is the taught row, the citation is+    ///   resolved within it, fails, and the hostname is diagnosed.+    /// - validate first: `entry.site` is still nil, the failure is tolerated+    ///   (Req 3.4), and the session opens on a quarantine map describing the+    ///   pre-pass graph — every relationship nil — which is exactly the state+    ///   the ordering exists to prevent.+    ///+    /// So the assertion is that the diagnostics describe the **post-pass**+    /// graph. Reversing the two calls in the V4-marker branch fails it.+    @Test("The relationship pass runs before validation, so diagnostics describe the post-pass graph")+    func passRunsBeforeValidation() async throws {+        let (dir, cfg) = try config()+        let host = "ordering.example"+        do {+            let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+            let context = ModelContext(container)+            let fixture = try V4Fixtures.wcSegmentIdentitySequence(hostname: host)+            // Cites a version of the Site's own chapter pattern that was never+            // written. Nothing else about the graph is illegal.+            fixture.entry.chapterPatternVersion = 99+            context.insert(fixture.site)+            context.insert(fixture.titlePattern)+            context.insert(fixture.rule)+            context.insert(fixture.work)+            context.insert(fixture.entry)+            // Relationships nil, as a pre-freeze certification leaves them —+            // V4Fixtures links by default, so the pre-pass state is constructed.+            fixture.entry.site = nil+            fixture.work.site = nil+            try context.save()+            withExtendedLifetime(container) {}+        }+        try Data("4\n".utf8).write(to: cfg.v4MarkerURL, options: .atomic)++        let (result, repository) = try await LibraryRepository.openV4ForApp(cfg)+        guard case .ready = result else {+            Issue.record("expected ready, got \(result)")+            return+        }+        try expectRelationshipsPopulated(cfg)+        let diagnostics = await repository.diagnostics+        #expect(diagnostics.quarantineMap()[host] != nil,+                "diagnostics computed before the pass would have tolerated this citation")+        withExtendedLifetime(dir) {}+    }++    @Test("Mark-at-birth still publishes \"5\" directly for an empty store and runs no pass")+    func markAtBirthStillPublishesFiveDirectly() async throws {+        let (dir, cfg) = try config()+        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        #expect(result == .ready(.zero))+        #expect(try markerContent(cfg) == "5",+                "an empty store has nothing to migrate and is certified migrated at birth (Q26)")+        withExtendedLifetime(dir) {}+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/V5RelationshipPassTests.swift Added +769 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V5RelationshipPassTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V5RelationshipPassTests.swiftnew file mode 100644index 0000000..57ccc03--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V5RelationshipPassTests.swift@@ -0,0 +1,769 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Task 8: the V4 → V5 relationship pass in isolation (Req 2.1, 2.2, 2.4).+///+/// Losslessness is asserted over the 5,000-Entry composed fixture, paired with+/// the assertion that every record's cited rules resolve *within the Site it+/// was assigned* — counts and tuples are identical whichever row is chosen, so+/// losslessness alone cannot catch a wrong-row assignment. That fixture is+/// seeded with `.duplicateSiteRows`, because with one Site row there is no+/// wrong row to assign and the citation half of the assertion cannot fail.+/// Determinism is+/// asserted over duplicate Site rows via `SiteResolutionOrder` (Q16), and+/// convergence over the exact state an interruption leaves: a store already+/// converted to 5.0.0 with every relationship nil (Q28). The bootstrap-level+/// half of interruption — the marker still reading "4" and republished "5"+/// only after the pass — is `V5CertificationPathTests`.+@Suite("V5 relationship pass", .serialized)+struct V5RelationshipPassTests {++    // MARK: - Helpers++    private final class TempDir {+        let url: URL+        init() throws {+            url = FileManager.default.temporaryDirectory.appending(+                path: "V5Pass-\(UUID())", directoryHint: .isDirectory)+            try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+        }+        deinit { try? FileManager.default.removeItem(at: url) }+    }++    private func config() throws -> (TempDir, LibraryConfiguration) {+        let dir = try TempDir()+        let configuration = LibraryConfiguration(rootDirectory: dir.url, environment: .development)+        try FileManager.default.createDirectory(+            at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)+        return (dir, configuration)+    }++    private static let ts = Date(timeIntervalSince1970: 1_800_000_000)++    /// Runs the pass over the store in its own container, released afterwards,+    /// so every observation before and after comes from a fresh context and+    /// asserts *persisted* state rather than in-memory objects.+    private func runPass(at storeURL: URL) throws {+        let container = try LibraryRepository.openV4Container(at: storeURL)+        let context = ModelContext(container)+        try V5RelationshipPass.run(context: context)+        withExtendedLifetime(container) {}+    }++    /// Sets every `Entry.site` and `Work.site` in the store back to nil.+    private func stripRelationships(at storeURL: URL) throws {+        let container = try LibraryRepository.openV4Container(at: storeURL)+        let context = ModelContext(container)+        for entry in try context.fetch(FetchDescriptor<Entry>()) { entry.site = nil }+        for work in try context.fetch(FetchDescriptor<Work>()) { work.site = nil }+        try context.save()+        withExtendedLifetime(container) {}+    }++    /// How many Entries and Works carry a populated `site`, read from a fresh+    /// container so the answer is persisted state rather than in-memory objects.+    private func linkedRecordCounts(at storeURL: URL) throws -> (entries: Int, works: Int) {+        let container = try LibraryRepository.openV4Container(at: storeURL)+        let context = ModelContext(container)+        defer { withExtendedLifetime(container) {} }+        return (+            try context.fetch(FetchDescriptor<Entry>()).count { $0.site != nil },+            try context.fetch(FetchDescriptor<Work>()).count { $0.site != nil }+        )+    }++    // MARK: - Snapshots (Req 2.2: every field, provenance tuple, timestamp)++    private struct EntrySnapshot: Equatable {+        let captureTitle: String+        let captureTitleSourceRaw: String+        let rawURLString: String+        let canonicalURLString: String?+        let hostname: String+        let entryIdentityKey: String+        let identityKeyVersion: Int+        let conservativeIdentityKey: String+        let identityBasisRaw: String+        let identityURLRuleID: UUID?+        let identityURLRuleVersion: Int?+        let identityNameTitleRuleID: UUID?+        let identityNameTitleRuleVersion: Int?+        let urlWorkIdentity: String?+        let urlWorkRuleID: UUID?+        let urlWorkRuleVersion: Int?+        let chapterSequence: String?+        let chapterSequenceRuleID: UUID?+        let chapterSequenceRuleVersion: Int?+        let chapterTitle: String?+        let chapterTitleProvenanceRaw: String+        let chapterPatternID: UUID?+        let chapterPatternVersion: Int?+        let note: String+        let ratingRaw: String?+        let firstCapturedAt: Date+        let lastSharedAt: Date+        let modifiedAt: Date+        let workID: UUID?+        let workAssignmentProvenanceRaw: String+        let workPatternID: UUID?+        let workPatternVersion: Int?+        let workURLRuleID: UUID?+        let workURLRuleVersion: Int?+        let workURLAssignmentKindRaw: String?+        let intentionallyUnattached: Bool++        init(_ entry: Entry) {+            captureTitle = entry.captureTitle+            captureTitleSourceRaw = entry.captureTitleSourceRaw+            rawURLString = entry.rawURLString+            canonicalURLString = entry.canonicalURLString+            hostname = entry.hostname+            entryIdentityKey = entry.entryIdentityKey+            identityKeyVersion = entry.identityKeyVersion+            conservativeIdentityKey = entry.conservativeIdentityKey+            identityBasisRaw = entry.identityBasisRaw+            identityURLRuleID = entry.identityURLRuleID+            identityURLRuleVersion = entry.identityURLRuleVersion+            identityNameTitleRuleID = entry.identityNameTitleRuleID+            identityNameTitleRuleVersion = entry.identityNameTitleRuleVersion+            urlWorkIdentity = entry.urlWorkIdentity+            urlWorkRuleID = entry.urlWorkRuleID+            urlWorkRuleVersion = entry.urlWorkRuleVersion+            chapterSequence = entry.chapterSequence+            chapterSequenceRuleID = entry.chapterSequenceRuleID+            chapterSequenceRuleVersion = entry.chapterSequenceRuleVersion+            chapterTitle = entry.chapterTitle+            chapterTitleProvenanceRaw = entry.chapterTitleProvenanceRaw+            chapterPatternID = entry.chapterPatternID+            chapterPatternVersion = entry.chapterPatternVersion+            note = entry.note+            ratingRaw = entry.ratingRaw+            firstCapturedAt = entry.firstCapturedAt+            lastSharedAt = entry.lastSharedAt+            modifiedAt = entry.modifiedAt+            workID = entry.work?.id+            workAssignmentProvenanceRaw = entry.workAssignmentProvenanceRaw+            workPatternID = entry.workPatternID+            workPatternVersion = entry.workPatternVersion+            workURLRuleID = entry.workURLRuleID+            workURLRuleVersion = entry.workURLRuleVersion+            workURLAssignmentKindRaw = entry.workURLAssignmentKindRaw+            intentionallyUnattached = entry.intentionallyUnattached+        }+    }++    private struct WorkSnapshot: Equatable {+        let displayTitle: String+        let lastParsedTitle: String?+        let siteHostname: String+        let urlIdentity: String?+        let urlIdentityStateRaw: String+        let urlIdentityRuleID: UUID?+        let urlIdentityRuleVersion: Int?+        let workURLString: String?+        let genericNotes: String+        let typeRaw: String+        let genreTags: [String]+        let titleProvenanceRaw: String+        let createdAt: Date+        let modifiedAt: Date+        let entryIDs: Set<UUID>++        init(_ work: Work) {+            displayTitle = work.displayTitle+            lastParsedTitle = work.lastParsedTitle+            siteHostname = work.siteHostname+            urlIdentity = work.urlIdentity+            urlIdentityStateRaw = work.urlIdentityStateRaw+            urlIdentityRuleID = work.urlIdentityRuleID+            urlIdentityRuleVersion = work.urlIdentityRuleVersion+            workURLString = work.workURLString+            genericNotes = work.genericNotes+            typeRaw = work.typeRaw+            genreTags = work.genreTags+            titleProvenanceRaw = work.titleProvenanceRaw+            createdAt = work.createdAt+            modifiedAt = work.modifiedAt+            entryIDs = Set(work.entryValues.map(\.id))+        }+    }++    /// Every stored column of `TitlePattern` bar the owning `site` relationship+    /// (Models.swift:230-251). An abbreviated snapshot would let the assertion+    /// message "every Site and its rules unchanged" claim more than it checks.+    private struct TitleRuleSnapshot: Equatable, Hashable {+        let id: UUID+        let version: Int+        let isActive: Bool+        let createdAt: Date+        let formRaw: String+        let segmentWorkAnchor: SegmentRangeSpec?+        let segmentIgnoredAnchors: [SegmentPositionSpec]?+        let phrasePrefix: String?+        let phraseSeparator: String?+        let phraseSuffix: String?+        let fieldOrderRaw: String?+        let trimPrefix: String?+        let trimSuffix: String?+        let chapterless: Bool++        init(_ pattern: TitlePattern) {+            id = pattern.id+            version = pattern.version+            isActive = pattern.isActive+            createdAt = pattern.createdAt+            formRaw = pattern.formRaw+            segmentWorkAnchor = pattern.segmentWorkAnchor+            segmentIgnoredAnchors = pattern.segmentIgnoredAnchors+            phrasePrefix = pattern.phrasePrefix+            phraseSeparator = pattern.phraseSeparator+            phraseSuffix = pattern.phraseSuffix+            fieldOrderRaw = pattern.fieldOrderRaw+            trimPrefix = pattern.trimPrefix+            trimSuffix = pattern.trimSuffix+            chapterless = pattern.chapterless+        }+    }++    /// Every stored column of `URLRulePattern` bar the owning `site`+    /// relationship — including the encoded definition, which is where the+    /// whole rule body lives.+    private struct URLRuleSnapshot: Equatable, Hashable {+        let id: UUID+        let version: Int+        let isCurrent: Bool+        let createdAt: Date+        let originRaw: String+        let definitionData: Data++        init(_ rule: URLRulePattern) {+            id = rule.id+            version = rule.version+            isCurrent = rule.isCurrent+            createdAt = rule.createdAt+            originRaw = rule.originRaw+            definitionData = rule.definitionData+        }+    }++    private struct SiteSnapshot: Equatable {+        let hostname: String+        let displayName: String+        let modeRaw: String+        let urlIdentityRule: URLIdentityRule?+        let junkSuffixRule: JunkSuffixRule?+        let patterns: Set<TitleRuleSnapshot>+        let urlRules: Set<URLRuleSnapshot>++        init(_ site: Site) {+            hostname = site.hostname+            displayName = site.displayName+            modeRaw = site.modeRaw+            urlIdentityRule = site.urlIdentityRule+            junkSuffixRule = site.junkSuffixRule+            patterns = Set(site.patternValues.map(TitleRuleSnapshot.init))+            urlRules = Set(site.urlRuleValues.map(URLRuleSnapshot.init))+        }+    }++    private struct GraphSnapshot: Equatable {+        let entries: [String: EntrySnapshot]+        let works: [String: WorkSnapshot]+        let sites: [String: SiteSnapshot]+        let counts: [Int]++        init(storeURL: URL) throws {+            let container = try LibraryRepository.openV4Container(at: storeURL)+            let context = ModelContext(container)+            let fetchedEntries = try context.fetch(FetchDescriptor<Entry>())+            let fetchedWorks = try context.fetch(FetchDescriptor<Work>())+            let fetchedSites = try context.fetch(FetchDescriptor<Site>())+            let patternCount = try context.fetchCount(FetchDescriptor<TitlePattern>())+            let ruleCount = try context.fetchCount(FetchDescriptor<URLRulePattern>())+            // Never `Dictionary(uniqueKeysWithValues:)`, and never a key that a+            // *tolerated* state can duplicate. Two Entries may share one+            // application UUID (`.duplicateIdentity`) and two Site rows may+            // share a hostname and display name (`.duplicateSiteRows`), so+            // keying on those alone would trap and take the whole test process+            // down rather than fail a test. Keys therefore carry the+            // discriminator each tolerated duplicate necessarily differs in —+            // the immutable raw URL, and the Site's rule ownership — and the+            // residual collision is *recorded*, not trapped.+            entries = Self.keyed(+                fetchedEntries.map { ("\($0.id)#\($0.rawURLString)", EntrySnapshot($0)) },+                kind: "Entry")+            works = Self.keyed(+                fetchedWorks.map { ("\($0.id)#\($0.displayTitle)", WorkSnapshot($0)) },+                kind: "Work")+            sites = Self.keyed(+                fetchedSites.map { (Self.siteKey($0), SiteSnapshot($0)) },+                kind: "Site")+            counts = [fetchedEntries.count, fetchedWorks.count, fetchedSites.count, patternCount, ruleCount]+            withExtendedLifetime(container) {}+        }++        /// Hostname, display name, mode and the ids of the rules the row owns.+        /// Duplicate rows for one hostname are a tolerated state and the+        /// duplicate is untaught, owning no rules, so the rule ids are what+        /// separate it from the taught row it shadows.+        private static func siteKey(_ site: Site) -> String {+            let patterns = site.patternValues.map { "\($0.id)v\($0.version)" }.sorted()+            let rules = site.urlRuleValues.map { "\($0.id)v\($0.version)" }.sorted()+            return "\(site.hostname)#\(site.displayName)#\(site.modeRaw)"+                + "#[\(patterns.joined(separator: ","))]#[\(rules.joined(separator: ","))]"+        }++        private static func keyed<Key: Hashable, Value>(+            _ pairs: [(Key, Value)], kind: String+        ) -> [Key: Value] {+            var result: [Key: Value] = [:]+            result.reserveCapacity(pairs.count)+            for (key, value) in pairs {+                guard result[key] == nil else {+                    let message = "\(kind) snapshot key \(key) is not unique — a tolerated "+                        + "duplicate the snapshot cannot represent; the later row was dropped"+                    Issue.record(Comment(rawValue: message))+                    continue+                }+                result[key] = value+            }+            return result+        }+    }++    /// Every citation pair the Entry carries must resolve — id *and* version —+    /// among the rules owned by the Site the pass assigned. This is what+    /// catches a wrong-row assignment that losslessness cannot.+    private func expectCitationsResolve(entry: Entry, in site: Site) {+        let titleCitations: [(UUID?, Int?, String)] = [+            (entry.chapterPatternID, entry.chapterPatternVersion, "chapter pattern"),+            (entry.workPatternID, entry.workPatternVersion, "work pattern"),+            (entry.identityNameTitleRuleID, entry.identityNameTitleRuleVersion, "identity name title rule"),+        ]+        let urlCitations: [(UUID?, Int?, String)] = [+            (entry.identityURLRuleID, entry.identityURLRuleVersion, "identity URL rule"),+            (entry.urlWorkRuleID, entry.urlWorkRuleVersion, "url-work rule"),+            (entry.chapterSequenceRuleID, entry.chapterSequenceRuleVersion, "chapter sequence rule"),+            (entry.workURLRuleID, entry.workURLRuleVersion, "work URL rule"),+        ]+        for (id, version, label) in titleCitations {+            guard let id else { continue }+            #expect(+                site.patternValues.contains { $0.id == id && $0.version == version },+                "\(entry.rawURLString): cited \(label) \(id) v\(String(describing: version)) must resolve within the assigned Site")+        }+        for (id, version, label) in urlCitations {+            guard let id else { continue }+            #expect(+                site.urlRuleValues.contains { $0.id == id && $0.version == version },+                "\(entry.rawURLString): cited \(label) \(id) v\(String(describing: version)) must resolve within the assigned Site")+        }+    }++    // MARK: - Losslessness over the 5,000-Entry fixture (Req 2.2)++    @Test("The pass is lossless over the 5,000-Entry fixture, which carries a duplicate Site row so a wrong-row assignment fails every citation")+    func losslessOverComposedFixture() async throws {+        let (dir, cfg) = try config()+        do {+            let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+            let repository = LibraryRepository.makeRepository(+                cfg, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy())+            // Seeded with the duplicate-row perturbation on purpose. With a+            // single Site row the citation half of this test cannot fail: the+            // only row that exists owns every rule, so "cited rules resolve+            // within the assigned Site" is true whatever the pass assigns. The+            // second row is untaught and owns no rules, so a wrong-row+            // assignment fails every citation assertion at once — which is the+            // failure losslessness provably cannot see.+            try await repository.seedM4PerformanceFixture(toleratedState: .duplicateSiteRows)+            withExtendedLifetime(container) {}+        }++        // Snapshotted *before* the strip below, which is what makes the+        // comparison after the pass a losslessness claim about the pass: the+        // snapshot carries no relationship, the strip changes nothing else, so+        // an equal snapshot afterwards means the pass moved no field, no+        // provenance tuple, no timestamp and no count.+        let before = try GraphSnapshot(storeURL: cfg.v4StoreURL)+        let expectedWorks = LibraryRepository.m4FixtureEntryCount+            / LibraryRepository.m4FixtureEntriesPerWork+        #expect(before.counts[0] == LibraryRepository.m4FixtureEntryCount)+        #expect(before.counts[1] == expectedWorks)++        // The fixture's write paths set both halves of every Site reference, so+        // it arrives fully linked and the pass over it would be a no-op —+        // leaving the assertions below testing the fixture's own writes rather+        // than the pass. Stripping the relationships reconstructs the state the+        // pass exists for: a store converted to 5.0.0 with every relationship+        // nil.+        try stripRelationships(at: cfg.v4StoreURL)+        #expect(try linkedRecordCounts(at: cfg.v4StoreURL) == (0, 0),+                "the pass must start from an all-nil graph or it proves nothing")++        try runPass(at: cfg.v4StoreURL)++        #expect(try linkedRecordCounts(at: cfg.v4StoreURL)+                == (LibraryRepository.m4FixtureEntryCount, expectedWorks),+                "the pass populates every relationship whose hostname carries a Site row (Req 2.1)")++        let after = try GraphSnapshot(storeURL: cfg.v4StoreURL)+        #expect(after.counts == before.counts, "per-type counts unchanged (Req 2.2)")+        #expect(after.entries == before.entries, "every Entry field, provenance tuple and timestamp unchanged")+        #expect(after.works == before.works, "every Work field unchanged")+        #expect(after.sites == before.sites, "every Site and its rules unchanged")++        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        let context = ModelContext(container)+        for entry in try context.fetch(FetchDescriptor<Entry>()) {+            let site = try #require(entry.site, "\(entry.rawURLString): a matching Site row exists, so the relationship must be populated (Req 2.1)")+            #expect(site.hostname == entry.hostname)+            expectCitationsResolve(entry: entry, in: site)+        }+        for work in try context.fetch(FetchDescriptor<Work>()) {+            let site = try #require(work.site)+            #expect(site.hostname == work.siteHostname)+            if let ruleID = work.urlIdentityRuleID {+                #expect(site.urlRuleValues.contains {+                    $0.id == ruleID && $0.version == work.urlIdentityRuleVersion+                }, "\(work.displayTitle): cited URL-identity rule must resolve within the assigned Site")+            }+        }+        withExtendedLifetime((dir, container)) {}+    }++    // MARK: - Determinism over duplicate Site rows (Q16)++    @Test("Duplicate Site rows pin every record to the SiteResolutionOrder winner, not the last row written")+    func duplicateRowsPinToTheWinner() throws {+        let (dir, cfg) = try config()+        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        let context = ModelContext(container)++        // The taught row is inserted FIRST and the untaught duplicate LAST, so+        // a last-write-wins map over fetch order — the V4 pass's shape — would+        // pick the untaught row. SiteResolutionOrder picks the taught one on+        // step 1 regardless of order.+        let taught = Site(hostname: "dup.example", displayName: "taught")+        taught.mode = .taught+        context.insert(taught)+        let pattern = try TitlePattern(+            version: 1, isActive: true, createdAt: Self.ts, definition: .wholeTitle, site: taught)+        context.insert(pattern)+        let untaught = Site(hostname: "dup.example", displayName: "untaught")+        context.insert(untaught)++        let raw = "https://dup.example/read/1"+        let entry = Entry(+            captureTitle: "One", captureTitleSource: .host, rawURLString: raw,+            hostname: "dup.example", entryIdentityKey: raw, timestamp: Self.ts)+        context.insert(entry)+        let work = Work(displayTitle: "A Work", siteHostname: "dup.example", timestamp: Self.ts)+        context.insert(work)+        try context.save()++        try V5RelationshipPass.run(context: context)+        #expect(entry.site === taught)+        #expect(work.site === taught)+        // The winner is the row the rest of the app would choose.+        let rows = try LibraryRepository.fetchSites(hostname: "dup.example", context: context)+        #expect(entry.site === rows.first)++        // Q33 discriminator: pin both records to the row that is NOT the+        // winner and re-run. Skip-if-set would leave them on the untaught row+        // — the outcome would depend on prior state rather than on store+        // content, and a record pinned to a losing row would never converge.+        // Without this the whole suite passes against a skip-if-set pass.+        entry.site = untaught+        work.site = untaught+        try context.save()+        try V5RelationshipPass.run(context: context)+        #expect(entry.site === taught, "a record pinned to a losing row is reassigned to the winner (Q33)")+        #expect(work.site === taught, "a record pinned to a losing row is reassigned to the winner (Q33)")++        // And from the other partly-assigned state — one half nil — the re-run+        // pins to the same row again (Q16).+        entry.site = nil+        try context.save()+        try V5RelationshipPass.run(context: context)+        #expect(entry.site === taught)+        #expect(work.site === taught)+        withExtendedLifetime((dir, container)) {}+    }++    // MARK: - Property-based over generated graph shapes++    /// Deterministic generator, so a failing seed reproduces exactly.+    private struct SplitMix64: RandomNumberGenerator {+        var state: UInt64+        init(seed: UInt64) { state = seed &+ 0x9E37_79B9_7F4A_7C15 }+        mutating func next() -> UInt64 {+            state &+= 0x9E37_79B9_7F4A_7C15+            var mixed = state+            mixed = (mixed ^ (mixed >> 30)) &* 0xBF58_476D_1CE4_E5B9+            mixed = (mixed ^ (mixed >> 27)) &* 0x94D0_49BB_1331_11EB+            return mixed ^ (mixed >> 31)+        }+    }++    @Test(+        "Generated graph shapes: lossless, winner-correct, nil only where no row matches, and re-run stable",+        arguments: UInt64(1)...UInt64(12))+    func generatedGraphShapes(seed: UInt64) throws {+        var rng = SplitMix64(seed: seed)+        let (dir, cfg) = try config()+        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        let context = ModelContext(container)+        defer { withExtendedLifetime((dir, container)) {} }++        // Sites: 1–4 hostnames, 1–3 rows each, taught rows carrying an active+        // pattern with a deterministic UUID. Ties between untaught rows fall+        // through to the identifier tiebreak, which is stable for one store.+        let hostnames = (0..<Int.random(in: 1...4, using: &rng)).map { "host\($0).example" }+        for (hostIndex, hostname) in hostnames.enumerated() {+            for rowIndex in 0..<Int.random(in: 1...3, using: &rng) {+                let site = Site(hostname: hostname, displayName: "\(hostname)#\(rowIndex)")+                context.insert(site)+                if Bool.random(using: &rng) {+                    site.mode = .taught+                    let id = UUID(uuidString: String(+                        format: "%08X-0000-4000-8000-%012X", hostIndex, rowIndex))!+                    let pattern = try TitlePattern(+                        id: id, version: 1, isActive: true, createdAt: Self.ts,+                        definition: .wholeTitle, site: site)+                    context.insert(pattern)+                }+            }+        }+        // Records: some name a hostname no Site row carries — the tolerated+        // state, whose relationship must stay nil (Req 2.1).+        let absentHostname = "absent.example"+        for index in 0..<Int.random(in: 0...15, using: &rng) {+            let hostname = Int.random(in: 0..<4, using: &rng) == 0+                ? absentHostname : hostnames.randomElement(using: &rng)!+            let raw = "https://\(hostname)/e/\(index)"+            let entry = Entry(+                captureTitle: "Entry \(index)", captureTitleSource: .host, rawURLString: raw,+                hostname: hostname, entryIdentityKey: raw,+                timestamp: Self.ts.addingTimeInterval(Double(index)))+            entry.note = "note \(index)"+            context.insert(entry)+        }+        for index in 0..<Int.random(in: 0...8, using: &rng) {+            let hostname = Int.random(in: 0..<4, using: &rng) == 0+                ? absentHostname : hostnames.randomElement(using: &rng)!+            context.insert(Work(+                displayTitle: "Work \(index)", siteHostname: hostname,+                timestamp: Self.ts.addingTimeInterval(Double(index))))+        }+        try context.save()+        withExtendedLifetime(container) {}++        let before = try GraphSnapshot(storeURL: cfg.v4StoreURL)+        try runPass(at: cfg.v4StoreURL)+        let after = try GraphSnapshot(storeURL: cfg.v4StoreURL)+        #expect(after == before, "seed \(seed): the pass must change no field, tuple, timestamp or count")++        let assignments = try assignmentsByRecord(at: cfg.v4StoreURL, seed: seed)++        // Re-run over the same store from a stripped state: same rows again.+        try stripRelationships(at: cfg.v4StoreURL)+        try runPass(at: cfg.v4StoreURL)+        let rerun = try assignmentsByRecord(at: cfg.v4StoreURL, seed: seed)+        #expect(rerun == assignments, "seed \(seed): a re-run must pin every record to the same row (Q16)")+    }++    /// Asserts winner-correctness and returns each record's assigned row (by+    /// its unique display name) keyed by record identity.+    private func assignmentsByRecord(at storeURL: URL, seed: UInt64) throws -> [String: String?] {+        let container = try LibraryRepository.openV4Container(at: storeURL)+        let context = ModelContext(container)+        defer { withExtendedLifetime(container) {} }+        var assignments: [String: String?] = [:]+        for entry in try context.fetch(FetchDescriptor<Entry>()) {+            let rows = try LibraryRepository.fetchSites(hostname: entry.hostname, context: context)+            if rows.isEmpty {+                #expect(entry.site == nil, "seed \(seed): \(entry.rawURLString) names no Site row, so its relationship stays nil")+            } else {+                #expect(entry.site === rows.first, "seed \(seed): \(entry.rawURLString) must pin to the SiteResolutionOrder winner")+            }+            assignments["entry:\(entry.rawURLString)"] = entry.site?.displayName+        }+        for work in try context.fetch(FetchDescriptor<Work>()) {+            let rows = try LibraryRepository.fetchSites(hostname: work.siteHostname, context: context)+            if rows.isEmpty {+                #expect(work.site == nil, "seed \(seed): \(work.displayTitle) names no Site row, so its relationship stays nil")+            } else {+                #expect(work.site === rows.first, "seed \(seed): \(work.displayTitle) must pin to the SiteResolutionOrder winner")+            }+            assignments["work:\(work.displayTitle)"] = work.site?.displayName+        }+        return assignments+    }++    // MARK: - 4.0.0 → 5.0.0 conversion losslessness at scale (Req 2.2)++    /// The only genuine pre-conversion coverage the branch had was the+    /// one-row-per-model `V4RecordedStoreFixture`. Everything else is built at+    /// the current schema, so it never crosses the 4.0.0 → 5.0.0 boundary at+    /// all. This runs the whole thing over a 432-Entry store a pre-freeze build+    /// wrote: every field read back as seeded, every citation resolving inside+    /// the assigned Site, and the duplicated hostname pinning to the+    /// `SiteResolutionOrder` winner rather than to whichever row the fetch+    /// happened to return first.+    @Test("A 432-Entry 4.0.0-recorded store converts and migrates with every field intact")+    func scaleRecordedStoreConvertsLosslessly() throws {+        typealias Fixture = V4RecordedScaleStoreFixture+        let (dir, cfg) = try config()+        try Fixture.install(at: cfg.v4StoreURL)+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.v4StoreURL) == ["4.0.0"],+                "the resource must never be converted in place")++        try runPass(at: cfg.v4StoreURL)+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.v4StoreURL) == ["5.0.0"])++        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        let context = ModelContext(container)+        defer { withExtendedLifetime((dir, container)) {} }++        let sites = try context.fetch(FetchDescriptor<Site>())+        let works = try context.fetch(FetchDescriptor<Work>())+        let entries = try context.fetch(FetchDescriptor<Entry>())+        let observedCounts: [Int] = [sites.count, works.count, entries.count]+        let seededCounts: [Int] = [Fixture.siteCount, Fixture.workCount, Fixture.entryCount]+        #expect(observedCounts == seededCounts)++        // Sites and their rules, against the seeded values.+        for (hostIndex, hostname) in Fixture.hostnames.enumerated() {+            let rows = try LibraryRepository.fetchSites(hostname: hostname, context: context)+            let taught = try #require(rows.first(where: { $0.mode == .taught }))+            #expect(taught.displayName == Fixture.siteDisplayName(hostIndex: hostIndex))+            let pattern = try #require(taught.patternValues.first)+            #expect(pattern.id == Fixture.patternID(hostIndex: hostIndex))+            #expect(pattern.version == Fixture.patternVersion)+            #expect(pattern.isActive)+            #expect(pattern.trimPrefix == "[")+            #expect(pattern.trimSuffix == "]")+            #expect(try pattern.definition == .phrase(+                prefix: "Chapter ", separator: " of ", suffix: ".", order: .chapterThenWork))+            let rule = try #require(taught.urlRuleValues.first)+            #expect(rule.id == Fixture.urlRuleID(hostIndex: hostIndex))+            #expect(rule.version == Fixture.urlRuleVersion)+            #expect(rule.isCurrent)+            #expect(rule.origin == .readerTaught)+            #expect(rule.definition == .sequence(locator: .query(name: ExactScalarString("chapter"))))++            if hostname == Fixture.duplicatedHostname {+                #expect(rows.count == 2, "the tolerated duplicate row must survive the conversion")+                let duplicate = try #require(rows.first(where: { $0.mode == .untaught }))+                #expect(duplicate.displayName == Fixture.duplicateRowDisplayName)+                #expect(duplicate.patternValues.isEmpty && duplicate.urlRuleValues.isEmpty)+                // Taught beats untaught on step 1 of SiteResolutionOrder, so+                // the winner is the row owning the rules every Entry cites.+                #expect(rows.first === taught)+            } else {+                #expect(rows.count == 1)+            }+        }++        // Works and Entries, against the seeded values, plus the relationship+        // the pass wrote and the citations it has to make resolvable.+        let worksByID = Dictionary(grouping: works, by: \.id).compactMapValues(\.first)+        let entriesByID = Dictionary(grouping: entries, by: \.id).compactMapValues(\.first)+        for (hostIndex, hostname) in Fixture.hostnames.enumerated() {+            let winner = try #require(+                try LibraryRepository.fetchSites(hostname: hostname, context: context).first)+            for workIndex in 0..<Fixture.worksPerHost {+                let work = try #require(+                    worksByID[Fixture.workID(hostIndex: hostIndex, workIndex: workIndex)])+                #expect(work.displayTitle == Fixture.workTitle(hostIndex: hostIndex, workIndex: workIndex))+                #expect(work.lastParsedTitle == work.displayTitle)+                #expect(work.siteHostname == hostname)+                #expect(work.urlIdentity == "\(workIndex)")+                #expect(work.urlIdentityState == .rule)+                #expect(work.urlIdentityRuleID == Fixture.urlRuleID(hostIndex: hostIndex))+                #expect(work.urlIdentityRuleVersion == Fixture.urlRuleVersion)+                #expect(work.genericNotes == "notes \(hostIndex)-\(workIndex)")+                #expect(work.genreTags == ["genre\(hostIndex)"])+                #expect(work.titleProvenance == .parsed)+                #expect(work.createdAt == Fixture.ts.addingTimeInterval(Double(workIndex)))+                #expect(work.site === winner, "\(work.displayTitle) pins to the winner")+                #expect(work.entryValues.count == Fixture.entriesPerWork)+                #expect(winner.urlRuleValues.contains {+                    $0.id == work.urlIdentityRuleID && $0.version == work.urlIdentityRuleVersion+                }, "\(work.displayTitle): cited URL-identity rule resolves within the assigned Site")++                for entryIndex in 0..<Fixture.entriesPerWork {+                    let entry = try #require(entriesByID[Fixture.entryID(+                        hostIndex: hostIndex, workIndex: workIndex, entryIndex: entryIndex)])+                    let raw = Fixture.rawURL(+                        hostname: hostname, workIndex: workIndex, entryIndex: entryIndex)+                    #expect(entry.captureTitle == Fixture.captureTitle(+                        hostIndex: hostIndex, workIndex: workIndex, entryIndex: entryIndex))+                    #expect(entry.captureTitleSourceRaw == CaptureTitleSource.host.rawValue)+                    #expect(entry.rawURLString == raw)+                    #expect(entry.entryIdentityKey == raw)+                    #expect(entry.conservativeIdentityKey == raw)+                    #expect(entry.hostname == hostname)+                    #expect(entry.note == "note \(entryIndex)")+                    #expect(entry.firstCapturedAt == Fixture.ts.addingTimeInterval(Double(entryIndex)))+                    #expect(entry.work?.id == work.id)+                    #expect(entry.chapterTitle == "Chapter \(entryIndex + 1)")+                    #expect(entry.chapterTitleProvenance == .pattern)+                    #expect(entry.chapterPatternID == Fixture.patternID(hostIndex: hostIndex))+                    #expect(entry.chapterPatternVersion == Fixture.patternVersion)+                    #expect(entry.workAssignmentProvenance == .pattern)+                    #expect(entry.workPatternID == Fixture.patternID(hostIndex: hostIndex))+                    #expect(entry.workPatternVersion == Fixture.patternVersion)+                    #expect(entry.chapterSequence == "\(entryIndex + 1)")+                    #expect(entry.chapterSequenceRuleID == Fixture.urlRuleID(hostIndex: hostIndex))+                    #expect(entry.chapterSequenceRuleVersion == Fixture.urlRuleVersion)+                    #expect(entry.site === winner, "\(raw) pins to the winner")+                    expectCitationsResolve(entry: entry, in: winner)+                }+            }+        }+    }++    // MARK: - Interruption convergence (Req 2.4, Q28)++    @Test("The pass converges the exact interrupted state: a store already converted to 5.0.0 with relationships nil")+    func convergesTheConvertedStore() throws {+        let (dir, cfg) = try config()+        try V4RecordedStoreFixture.install(at: cfg.v4StoreURL)++        // `ModelContainer.init` commits the schema conversion on the way in,+        // before the pass begins, and a failed pass does not undo it (Q28). So+        // the interrupted state is a store already recorded at 5.0.0 whose+        // relationships are nil — not an unconverted store.+        do {+            let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+            _ = ModelContext(container)+            withExtendedLifetime(container) {}+        }+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.v4StoreURL) == ["5.0.0"],+                "the conversion is committed before the pass runs")++        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        let context = ModelContext(container)+        defer { withExtendedLifetime((dir, container)) {} }+        let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)+        let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)+        #expect(entry.site == nil, "the interrupted state this test exists for")+        #expect(work.site == nil)++        // The re-run converges on the already-converted store.+        try V5RelationshipPass.run(context: context)+        #expect(entry.site?.hostname == V4RecordedStoreFixture.hostname)+        #expect(work.site?.hostname == V4RecordedStoreFixture.hostname)+        expectCitationsResolve(entry: entry, in: try #require(entry.site))++        // And a run over the converged store is a no-op that converges again.+        try V5RelationshipPass.run(context: context)+        #expect(entry.site?.hostname == V4RecordedStoreFixture.hostname)+        #expect(work.site?.hostname == V4RecordedStoreFixture.hostname)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift Added +534 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swiftnew file mode 100644index 0000000..8aa6832--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift@@ -0,0 +1,534 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Tasks 17/18, Req 1.4 and 2.5: every path that writes a record naming a Site+/// sets **both** halves — the hostname string and the relationship — in the same+/// save, so the two cannot diverge and the app's own writes never leave a+/// relationship for the migration to repair on a later launch.+///+/// The write sites and the relationship pass must also agree on *which* row a+/// duplicated hostname resolves to. Both route through `fetchSites`, which is+/// `SiteResolutionOrder`; a second selection rule would pin the same Entry to+/// different rows depending on whether it was captured or migrated.+@Suite("Write sites set both halves of a Site reference", .serialized)+struct WriteSiteRelationshipTests {++    // MARK: - Fixture++    private final class TempDir {+        let url: URL+        init(_ name: String) throws {+            url = FileManager.default.temporaryDirectory.appending(+                path: "\(name)-\(UUID())", directoryHint: .isDirectory)+            try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+        }+        deinit { try? FileManager.default.removeItem(at: url) }+    }++    private static let ts = Date(timeIntervalSince1970: 1_800_000_000)++    private func configuration(_ dir: TempDir) -> LibraryConfiguration {+        LibraryConfiguration(rootDirectory: dir.url, environment: .development)+    }++    /// An open, empty, certified library — the ordinary state the app's write+    /// paths run against.+    private func openLibrary(_ dir: TempDir) async throws -> (LibraryConfiguration, LibraryRepository) {+        let cfg = configuration(dir)+        let (_, repository) = try await LibraryRepository.openV4ForApp(+            cfg, clock: FixedRepositoryClock(Self.ts))+        return (cfg, repository)+    }++    /// Reads the store back through a fresh container, so the assertions see+    /// what was committed rather than what an in-memory object still holds.+    private func withStore<Result>(+        _ cfg: LibraryConfiguration, _ body: (ModelContext) throws -> Result+    ) throws -> Result {+        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        defer { withExtendedLifetime(container) {} }+        return try body(ModelContext(container))+    }++    // MARK: - capture++    @Test("Capture sets entry.hostname and entry.site, pointing at the Site it just created")+    func captureSetsBothHalvesForANewSite() async throws {+        let dir = try TempDir("WriteSiteCapture")+        let (cfg, repository) = try await openLibrary(dir)++        _ = try await repository.capture(draft("https://fresh.example/read/1"))++        try withStore(cfg) { context in+            let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)+            let site = try #require(try context.fetch(FetchDescriptor<Site>()).first)+            #expect(entry.hostname == "fresh.example")+            #expect(entry.site === site, "the new Entry points at the Site capture inserted for it")+        }+        withExtendedLifetime(dir) {}+    }++    @Test("Capture into an existing hostname points at the existing Site row")+    func captureSetsBothHalvesForAnExistingSite() async throws {+        let dir = try TempDir("WriteSiteCapture")+        let (cfg, repository) = try await openLibrary(dir)++        _ = try await repository.capture(draft("https://reused.example/read/1"))+        _ = try await repository.capture(draft("https://reused.example/read/2"))++        try withStore(cfg) { context in+            let sites = try context.fetch(FetchDescriptor<Site>())+            #expect(sites.count == 1, "the second capture reuses the row")+            let entries = try context.fetch(FetchDescriptor<Entry>())+            #expect(entries.count == 2)+            for entry in entries { #expect(entry.site === sites[0]) }+        }+        withExtendedLifetime(dir) {}+    }++    // MARK: - create-Work++    @Test("Creating a Work sets work.siteHostname and work.site, pointing at the Site it just created")+    func createWorkSetsBothHalvesForANewSite() async throws {+        let dir = try TempDir("WriteSiteWork")+        let (cfg, repository) = try await openLibrary(dir)++        _ = try await repository.createWork(+            NewWorkDraft(displayTitle: "A Work", hostname: "worksite.example"))++        try withStore(cfg) { context in+            let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)+            let site = try #require(try context.fetch(FetchDescriptor<Site>()).first)+            #expect(work.siteHostname == "worksite.example")+            #expect(work.site === site, "the new Work points at the Site it caused to exist")+        }+        withExtendedLifetime(dir) {}+    }++    @Test("Creating a Work on an existing hostname points at the existing Site row")+    func createWorkSetsBothHalvesForAnExistingSite() async throws {+        let dir = try TempDir("WriteSiteWork")+        let (cfg, repository) = try await openLibrary(dir)++        _ = try await repository.capture(draft("https://shared.example/read/1"))+        _ = try await repository.createWork(+            NewWorkDraft(displayTitle: "A Work", hostname: "shared.example"))++        try withStore(cfg) { context in+            let sites = try context.fetch(FetchDescriptor<Site>())+            #expect(sites.count == 1)+            let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)+            #expect(work.site === sites[0])+        }+        withExtendedLifetime(dir) {}+    }++    // MARK: - The seven write sites task 18's details do not name (Q41)++    /// The teaching commit creates one Work per parsed title+    /// (`+Contracts.swift:329`), and those Works must point at the row the+    /// commit taught rather than at nothing.+    @Test("A teaching commit's created Works point at the Site it taught")+    func teachingCommitSetsBothHalves() async throws {+        let dir = try TempDir("WriteSiteTeaching")+        let (cfg, repository) = try await openLibrary(dir)+        try await teach(repository, host: "taught.example")++        try withStore(cfg) { context in+            let site = try #require(try context.fetch(FetchDescriptor<Site>()).first)+            let works = try context.fetch(FetchDescriptor<Work>())+            #expect(!works.isEmpty, "the teaching commit created no Work to assert on")+            for work in works {+                #expect(work.siteHostname == "taught.example")+                #expect(work.site === site)+            }+        }+        withExtendedLifetime(dir) {}+    }++    /// The share extension's real capture path — `projectCapture` then+    /// `commitCapture` — which carries two write sites in one commit: the Entry+    /// (`+ReparseCapture.swift:303`) and, when the derived Work name is new, the+    /// Work `applyCaptureAssignment`'s `.create` arm builds (`:398`).+    @Test("The capture commit sets both halves on the Entry and on the Work it creates")+    func captureCommitSetsBothHalves() async throws {+        let dir = try TempDir("WriteSiteCaptureCommit")+        let (cfg, repository) = try await openLibrary(dir)+        let host = "taught.example"+        try await teach(repository, host: host)++        // A Work name the teaching sweep has not already created, so the+        // assignment takes the `.create` arm rather than reusing a row.+        let contract = try await repository.projectCapture(+            hostname: host, captureTitle: "Chapter 9 - Nonfiction | Site",+            captureTitleSource: .safariDocument,+            rawURLString: "https://\(host)/9", canonicalURLString: nil, note: "", rating: nil)+        guard case .committed(let snapshot) = try await repository.commitCapture(contract) else {+            Issue.record("expected the capture to commit")+            return+        }+        let createdWorkID = try #require(snapshot.workID)++        try withStore(cfg) { context in+            let site = try #require(try context.fetch(FetchDescriptor<Site>()).first)+            let captured = try #require(try context.fetch(FetchDescriptor<Entry>())+                .first { $0.id == snapshot.id })+            #expect(captured.site === site, "the committed capture left its Entry unlinked")+            let created = try #require(try context.fetch(FetchDescriptor<Work>())+                .first { $0.id == createdWorkID })+            #expect(created.displayTitle == "Nonfiction",+                    "the capture reused a Work instead of taking the .create arm")+            #expect(created.site === site)+        }+        withExtendedLifetime(dir) {}+    }++    /// Re-parse creates Works too (`+ReparseCapture.swift:156`). Reached by+    /// capturing conservatively — the plain `capture` convenience applies no+    /// rules — onto an already-taught hostname, then re-parsing that Entry.+    @Test("A re-parse commit's created Work points at the Entry's Site row")+    func reparseCommitSetsBothHalves() async throws {+        let dir = try TempDir("WriteSiteReparse")+        let (cfg, repository) = try await openLibrary(dir)+        let host = "taught.example"+        try await teach(repository, host: host)++        let conservative = try await repository.capture(+            draft("https://\(host)/7", title: "Chapter 7 - Anthology | Site"))+        let contract = try await repository.projectReparse(entryID: conservative.id)+        guard case .committed = try await repository.commitReparse(contract) else {+            Issue.record("expected the re-parse to commit")+            return+        }++        try withStore(cfg) { context in+            let site = try #require(try context.fetch(FetchDescriptor<Site>()).first)+            let created = try #require(try context.fetch(FetchDescriptor<Work>())+                .first { $0.displayTitle == "Anthology" })+            #expect(created.site === site, "the re-parse created a Work with no Site relationship")+        }+        withExtendedLifetime(dir) {}+    }++    /// `moveEntry`'s `.newWork` arm. The new Work lands on the row the Entry+    /// itself points at, which is not the same question as "which row wins the+    /// hostname now" — the winner is content-dependent and flips as unrelated+    /// teaching lands (Q44).+    @Test("moveEntry's new Work lands on the Entry's own row, not on the current winner")+    func moveEntryNewWorkFollowsTheEntrysOwnRow() async throws {+        let dir = try TempDir("WriteSiteMove")+        let cfg = configuration(dir)+        let host = "duplicated.example"+        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        defer { withExtendedLifetime(container) {} }++        // Two rows: a bare one the Entry will be pinned to, and a taught one+        // that wins `SiteResolutionOrder` on step 1.+        let repository = LibraryRepository.makeRepository(+            cfg, container, .m4, FixedRepositoryClock(Self.ts), ModelContextSaveStrategy())+        let captured = try await repository.capture(draft("https://\(host)/1"))+        do {+            let context = ModelContext(container)+            let winner = Site(hostname: host)+            winner.mode = .taught+            let pattern = try TitlePattern(+                version: 1, isActive: true, createdAt: Self.ts, definition: .wholeTitle, site: winner)+            winner.patterns = [pattern]+            context.insert(winner)+            context.insert(pattern)+            try context.save()+        }++        let context = ModelContext(container)+        let entryRow = try #require(try context.fetch(FetchDescriptor<Entry>())+            .first { $0.id == captured.id })+        let pinned = try #require(entryRow.site)+        let currentWinner = try #require(+            try LibraryRepository.fetchSites(hostname: host, context: context).first)+        #expect(pinned !== currentWinner, "the winner did not flip; the test proves nothing")++        try await repository.moveEntry(captured.id, to: .newWork(displayTitle: "A Manual Work"))++        let after = ModelContext(container)+        let moved = try #require(try after.fetch(FetchDescriptor<Entry>())+            .first { $0.id == captured.id })+        let work = try #require(moved.work)+        #expect(work.site === moved.site,+                "the new Work went to the current winner rather than to its Entry's row")+        withExtendedLifetime(dir) {}+    }++    // MARK: - materializeV4Payload (Req 2.5)++    /// A 4/4 archive references Sites by hostname and rules by id (Q7), so the+    /// relationships an import produces are derived from exactly those — no+    /// format change, no marker republication, no second pass.+    @Test("materializeV4Payload wires both relationships from its sitesByHostname map")+    func materializeWiresBothRelationships() throws {+        let schema = Schema(versionedSchema: AsterismSchemaV5.self)+        let container = try ModelContainer(+            for: schema,+            configurations: [ModelConfiguration(+                schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)])+        let context = ModelContext(container)++        try LibraryRepository.materializeV4Payload(+            BackupV4Fixtures.minimalTaughtPayload(), into: context)++        let site = try #require(try context.fetch(FetchDescriptor<Site>()).first)+        let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)+        let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)+        #expect(entry.site === site)+        #expect(work.site === site)+        withExtendedLifetime(container) {}+    }++    // MARK: - B1: import into a library already marked "5"++    /// The regression Decision 2 is written against. `confirmImportFillEmpty`+    /// and `confirmImportReplace` both reach `materializeV4Payload`, both+    /// require a readiness marker to already exist, and neither republishes one+    /// — so the relationship pass never runs again over what they wrote. If the+    /// importer did not set the relationships, nothing ever would.+    @Test("Fill-empty import into a \"5\"-marked library produces populated relationships")+    func fillEmptyImportPopulatesRelationships() async throws {+        let dir = try TempDir("WriteSiteImportFill")+        let cfg = configuration(dir)+        _ = try await LibraryRepository.openV4ForApp(cfg)+        #expect(try markerContent(cfg) == "5", "mark-at-birth certifies an empty store at \"5\"")++        let plan = try importPlan()+        let result = try await LibraryRepository.confirmImportFillEmpty(cfg, plan: plan)+        guard case .committed = result else {+            Issue.record("expected committed, got \(result)")+            return+        }+        #expect(try markerContent(cfg) == "5", "the import republishes nothing")+        try expectEveryRelationshipPopulated(cfg)+        withExtendedLifetime(dir) {}+    }++    @Test("Replace import into a \"5\"-marked library produces populated relationships")+    func replaceImportPopulatesRelationships() async throws {+        let dir = try TempDir("WriteSiteImportReplace")+        let cfg = configuration(dir)+        try seedCertifiedLibrary(cfg, hostname: "existing.example")++        let fingerprint = try await LibraryRepository.computeInventoryFingerprint(+            configuration: cfg)+        let plan = try importPlan()+        let result = try await LibraryRepository.confirmImportReplace(+            cfg, plan: plan, expectedInventory: fingerprint)+        guard case .committed = result else {+            Issue.record("expected committed, got \(result)")+            return+        }+        #expect(try markerContent(cfg) == "5")+        try expectEveryRelationshipPopulated(cfg)+        withExtendedLifetime(dir) {}+    }++    // MARK: - The write sites and the pass agree on the winner++    /// Duplicate Site rows cannot arise before mirroring, so this is about+    /// tests, fixtures and re-runs — but if a capture and a later re-run of the+    /// pass disagreed about which row a hostname resolves to, the Entry would+    /// silently move between rows, and with it every citation it replays.+    @Test("A write site and V5RelationshipPass pick the same row for a duplicated hostname")+    func writeSitesAgreeWithThePassOnTheWinner() async throws {+        let dir = try TempDir("WriteSiteWinner")+        let cfg = configuration(dir)+        let host = "duplicated.example"+        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        defer { withExtendedLifetime(container) {} }++        // Two rows for one hostname. They differ, so "either row" is a+        // detectable answer rather than an invisible one.+        do {+            let context = ModelContext(container)+            let taught = Site(hostname: host)+            taught.mode = .taught+            let pattern = try TitlePattern(+                version: 1, isActive: true, createdAt: Self.ts, definition: .wholeTitle, site: taught)+            taught.patterns = [pattern]+            context.insert(taught)+            context.insert(pattern)+            let untaught = Site(hostname: host)+            untaught.mode = .untaught+            context.insert(untaught)+            try context.save()+        }++        let repository = LibraryRepository.makeRepository(+            cfg, container, .m4, FixedRepositoryClock(Self.ts), ModelContextSaveStrategy())+        _ = try await repository.capture(draft("https://\(host)/read/1"))+        _ = try await repository.createWork(NewWorkDraft(displayTitle: "A Work", hostname: host))++        let context = ModelContext(container)+        let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)+        let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)+        let writtenEntrySite = try #require(entry.site)+        let writtenWorkSite = try #require(work.site)++        try V5RelationshipPass.run(context: context)++        #expect(entry.site === writtenEntrySite,+                "the pass must not move an Entry the write path already pinned")+        #expect(work.site === writtenWorkSite)+        withExtendedLifetime(dir) {}+    }++    // MARK: - Helpers++    private func draft(_ rawURL: String, title: String = "Chapter 1") -> CaptureDraft {+        CaptureDraft(+            captureTitle: title, captureTitleSource: .manual, rawURLString: rawURL)+    }++    /// Two conservative captures and one initial teaching, through the real+    /// contract APIs. The teaching commit is itself a write site, so this is+    /// both a helper and the setup the capture and re-parse tests need.+    private func teach(_ repository: LibraryRepository, host: String) async throws {+        _ = try await repository.capture(+            draft("https://\(host)/1", title: "Chapter 1 - Fiction | Site"))+        _ = try await repository.capture(+            draft("https://\(host)/2", title: "Chapter 2 - Fiction | Site"))+        let definition = PatternDefinition.segment(+            work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+            ignored: [try SegmentPositionSpec(origin: .end, offset: 0)])+        let contract = try await repository.projectInitialTeaching(+            hostname: host, patternDefinition: definition)+        guard case .committed = try await repository.commitTeaching(contract) else {+            throw LibraryRepositoryError.invalidInput(+                operation: "seeding a taught hostname", reason: "the teaching did not commit")+        }+    }++    private func markerContent(_ cfg: LibraryConfiguration) throws -> String {+        try String(contentsOf: cfg.v4MarkerURL, encoding: .utf8)+            .trimmingCharacters(in: .whitespacesAndNewlines)+    }++    private func importPlan() throws -> BackupImportV4Plan {+        let payload = BackupV4Fixtures.minimalTaughtPayload()+        return BackupImportV4Plan(+            metadata: BackupImportMetadata(+                formatVersion: 4, schemaVersion: 4, appBuild: "test",+                exportedAt: Self.ts, capabilityGate: "m4",+                entryCount: payload.entries.count, workCount: payload.works.count),+            payload: payload,+            counts: try LibraryRepository.validateImportPlanPayloadV4(payload))+    }++    /// A nonempty store certified at `"5"` — the state an import replaces into.+    private func seedCertifiedLibrary(_ cfg: LibraryConfiguration, hostname: String) throws {+        try FileManager.default.createDirectory(+            at: cfg.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)+        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        let context = ModelContext(container)+        let site = Site(hostname: hostname)+        context.insert(site)+        let raw = "https://\(hostname)/read/1"+        let entry = Entry(+            captureTitle: "Chapter 1", captureTitleSource: .host, rawURLString: raw,+            hostname: hostname, entryIdentityKey: raw, timestamp: Self.ts)+        entry.conservativeIdentityKey = raw+        entry.site = site+        context.insert(entry)+        try context.save()+        try Data("5\n".utf8).write(to: cfg.v4MarkerURL, options: .atomic)+        withExtendedLifetime(container) {}+    }++    private func expectEveryRelationshipPopulated(+        _ cfg: LibraryConfiguration, sourceLocation: SourceLocation = #_sourceLocation+    ) throws {+        try withStore(cfg) { context in+            let entries = try context.fetch(FetchDescriptor<Entry>())+            let works = try context.fetch(FetchDescriptor<Work>())+            #expect(!entries.isEmpty, sourceLocation: sourceLocation)+            #expect(!works.isEmpty, sourceLocation: sourceLocation)+            for entry in entries {+                #expect(entry.site?.hostname == entry.hostname,+                        "\(entry.rawURLString) imported with no Site relationship",+                        sourceLocation: sourceLocation)+            }+            for work in works {+                #expect(work.site?.hostname == work.siteHostname,+                        "\(work.displayTitle) imported with no Site relationship",+                        sourceLocation: sourceLocation)+            }+        }+    }+}++/// Task 19: the fixtures build their graphs underneath the validating commit+/// path, so nothing else would give their records a Site relationship. A fixture+/// whose relationships are nil is a graph the app cannot produce, and every+/// suite built on it would be measuring or asserting against the wrong shape+/// once reads follow `entry.site`.+@Suite("Fixtures build the graph certification produces", .serialized)+struct FixtureRelationshipTests {++    private final class TempDir {+        let url: URL+        init() throws {+            url = FileManager.default.temporaryDirectory.appending(+                path: "FixtureRelationships-\(UUID())", directoryHint: .isDirectory)+            try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+        }+        deinit { try? FileManager.default.removeItem(at: url) }+    }++    /// `.siteMissing` is the one kind whose correct state is a nil relationship:+    /// it exists to model an Entry whose Site is absent, which is the central+    /// case of this milestone and the only reason Req 2.1 permits a certified+    /// library to hold one.+    @Test("Every tolerated-state kind links its records, except the orphans of .siteMissing",+          arguments: ToleratedStateFixtureKind.allCases)+    func toleratedStateFixtureLinksItsRecords(kind: ToleratedStateFixtureKind) async throws {+        let dir = try TempDir()+        let cfg = LibraryConfiguration(rootDirectory: dir.url, environment: .development)+        let (_, repository) = try await LibraryRepository.openV4ForApp(cfg, capabilities: .m4)+        try await repository.seedToleratedStateFixture(kind)++        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        defer { withExtendedLifetime((container, dir)) {} }+        let context = ModelContext(container)+        let hostnames = Set(try context.fetch(FetchDescriptor<Site>()).map(\.hostname))++        for entry in try context.fetch(FetchDescriptor<Entry>()) {+            if hostnames.contains(entry.hostname) {+                #expect(entry.site?.hostname == entry.hostname,+                        "\(kind.rawValue): \(entry.rawURLString) has a Site row but no relationship")+            } else {+                #expect(entry.site == nil,+                        "\(kind.rawValue): \(entry.rawURLString) is an orphan and must stay unlinked")+            }+        }+        for work in try context.fetch(FetchDescriptor<Work>()) {+            #expect(work.site?.hostname == work.siteHostname)+        }+    }++    /// `.siteMissing` specifically: the kind is worth its own assertion because+    /// a sweep that "fixed" it would delete the state it exists to produce.+    @Test(".siteMissing leaves its Entries with a nil relationship, deliberately")+    func siteMissingKeepsItsOrphansUnlinked() async throws {+        let dir = try TempDir()+        let cfg = LibraryConfiguration(rootDirectory: dir.url, environment: .development)+        let (_, repository) = try await LibraryRepository.openV4ForApp(cfg, capabilities: .m4)+        try await repository.seedToleratedStateFixture(.siteMissing)++        let container = try LibraryRepository.openV4Container(at: cfg.v4StoreURL)+        defer { withExtendedLifetime((container, dir)) {} }+        let context = ModelContext(container)+        let orphans = try context.fetch(FetchDescriptor<Entry>())+            .filter { $0.hostname == "orphan.test" }+        #expect(orphans.count == 2)+        for orphan in orphans { #expect(orphan.site == nil) }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/{CitedPatternUnionTests.swift => CitedPatternResolutionTests.swift} Modified +70 / -66
(binary file — no textual diff)
docs/agent-notes/composed-teaching-ui.md Modified +5 / -2
diff --git a/docs/agent-notes/composed-teaching-ui.md b/docs/agent-notes/composed-teaching-ui.mdindex 465f6f6..edcd4ed 100644--- a/docs/agent-notes/composed-teaching-ui.md+++ b/docs/agent-notes/composed-teaching-ui.md@@ -16,8 +16,11 @@ special bootstrap. `AppLibraryModel.bootstrap()` now opens `openV4ForApp` for al launches, and since T-1969 that single call creates the store, marks it ready and returns an open repository — no confirm-and-reopen dance. `seedComposedFixture` then seeds an untaught actionable Entry on `composed.test` plus a composed-taught-Site `id.test` with URL identity. `openV4Container`, `makeRepository`, and-`publishV4Readiness` remain `public` (still used by tests).+Site `id.test` with URL identity. `openV4Container` and `makeRepository` remain+`public` (still used by tests). `publishV4Readiness` was deleted with the+relational-references milestone (Q32) — certification now publishes the `"5"`+marker through `publishV5Readiness`, and a test that needs a `"4"` marker writes+the bytes directly.  `ComposedSurfaceUITests` passes on the simulator (`testEntryDetailTeachOpensComposedSurface`, `testRecentPillOpensComposedSurface`, …).
docs/agent-notes/testing.md Modified +28 / -0
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex bdb2d3a..771e8be 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -48,6 +48,34 @@ resolve no Site mode, so they carry no Teach pill (Q38) and Entry detail refuses outright (Q39). The diagnosis screen is the only way in, which is why `LibraryDiagnosticsUITests` drives that route end to end. +## AsterismCore suites must run serially (`--no-parallel`)++`make test-core` passes `--no-parallel`, and it is load-bearing rather than a+speed or flakiness preference. Running the package suites in parallel crashes the+whole test process with:++```+NSUnknownKeyException: the entity Site is not key value coding-compliant for the key "entries"+```++Cause: `Site.entries` is the inverse of `Entry.site` and exists only in schema V5.+SwiftData keeps a **global** entity registry keyed on the entity *name*, and the+package's suites open containers at several schema versions — V3 and V4 are frozen+snapshots that carry the same entity name `Site` without that key. With suites+running concurrently in one process, a frozen-schema container can win the+registration for `Site`, and the next `entry.site = …` write in a V5 suite dies on+the missing inverse. Serial runs never overlap the containers, so they are+unaffected.++Consequences:++- Never drop `--no-parallel` from the Makefile recipe.+- A hand-rolled `swift test --package-path Packages/AsterismCore --filter …` needs+  `--no-parallel` too. The crash is easy to misread as a bug in whichever suite+  happened to be running: it is a process-level `NSException`, not a test failure,+  so the report shows an aborted run rather than a named assertion.+- Recorded as Q34 in `specs/relational-references/decision_log.md`.+ ## Misc  - `make test-only TEST=AsterismTests/SomeSuite` runs one suite; `TEST` also
docs/investigations/cloudkit-probe.md Added +191 / -0
diff --git a/docs/investigations/cloudkit-probe.md b/docs/investigations/cloudkit-probe.mdnew file mode 100644index 0000000..0e0ba29--- /dev/null+++ b/docs/investigations/cloudkit-probe.md@@ -0,0 +1,191 @@+# Investigation: CloudKit Probe++**Status:** complete — all three questions answered 2026-07-27. Ready to delete once the spike branch goes.+**Date opened:** 2026-07-26+**Retained as the record of the measurement.** Originally written to be deleted+once the answers were folded into `specs/relational-references/decision_log.md`+(Q9) and `specs/cloudkit-mirroring/decision_log.md` (Q3, Q23–Q25) — which has+happened. It is kept anyway because those entries cite it: they state conclusions,+this states the device, the OS, the method, the numbers, and what was *not*+covered. Delete it only if the specs it supports are themselves abandoned.++The **code** was always throwaway and is on branch `spike/cloudkit-probe`, which+is not merged and should not be. It is kept rather than discarded so the probe can+be re-run — the `URLRulePattern.definitionData` gap below is the obvious reason to.++---++## Why++Two facts that two planned milestones depend on are unproven, and one of them is+a kill switch.++**Q1 — Does a dangling relationship heal?** `specs/relational-references/`+converts `Entry.hostname`, `Work.siteHostname` and the rule citations into+modelled relationships, on the premise that a relationship whose target has not+arrived is nil and resolves itself when the record lands. If+`NSPersistentCloudKitContainer` does not do that, a relationship is no better+than a string and **the milestone should be abandoned, not shipped**.++**Q2 — Do the V4 attributes round-trip through CloudKit?**+`specs/cloudkit-mirroring/` assumes no schema change. Apple documents composite+attributes as usable with `NSPersistentCloudKitContainer` and transformables as+serialised to `NSData`; field reports say SwiftData arrays of Codable structs+land as transformables using the default `NSKeyedUnarchiveFromData` transformer+and produce mis-typed CloudKit fields. The reported failure **reproduces only on+physical hardware with a real iCloud account, never in the Simulator.** The+documented workaround is `Data?` plus a computed accessor — a schema change the+mirroring spec's non-goals currently exclude.++Attributes to cover, all of them rather than one representative:+`Work.genreTags` (`[String]`), `TitlePattern.segmentWorkAnchor`+(Codable struct), `TitlePattern.segmentIgnoredAnchors` (array of Codable+structs — the shape with the field reports against it), `Site.junkSuffixRule`+(Codable struct), `URLRulePattern.definitionData` (`Data`).++## Why this runs on Asterism Development, not a throwaway app++A throwaway app would need its own bundle ID, iCloud container, entitlements and+provisioning profile — none of it reusable. Development needs+`iCloud.me.nore.ig.Asterism.dev` and the iCloud capability on its App IDs, which+the mirroring milestone requires regardless, so that work is not thrown away.+Running against the real V4 model also answers Q2 definitively rather than by+analogy with a lookalike schema.++Development is safe to use for this: separate bundle ID, separate App Group,+separate store, separate container. It cannot reach the personal library.++## Setup — built, on branch `spike/cloudkit-probe`++Portal work (keep — the milestone needs it): **done 2026-07-27.** Both containers+exist, the iCloud (CloudKit) capability and Background Modes → Remote+notifications are on the app App IDs, and neither share extension carries an+iCloud entitlement — verified against the signed binaries, not just the source.++Spike code (throw away):++- `openV4Container(at:mirroring:)` and `openV4ForApp(..., mirroring:)` — the flag+  **defaults to off**. It has to: every host suite in `AsterismCoreTests` opens+  through the same function and the host has no entitlement. Defaulting off also+  gives Decision 2 for free, since the extension calls the same function and+  cannot accidentally acquire a second syncing container.+- `Entry.site` with a `Site.entries` inverse, `.nullify` — the relationship under+  test. Nothing reads it; `hostname` is still the operative reference.+- Mirroring attaches **after** the readiness marker is published (Q22 of the+  mirroring spec). The create-and-mark path measures emptiness before marking, so+  attaching mirroring first would let a record land inside that window and make+  the store nonempty and unmarked — the one state that still fails closed.+- `CloudKitProbe` (`AsterismCore`, `#if DEBUG`) and `CloudKitProbeView`, reachable+  from Settings on Development builds.+- The app opts in; a UI-test launch does not, since its store is a disposable+  temp directory with no business reaching iCloud.++Verified: both configurations build and codesign for a real device, and+`make test-core` is green.++## Procedure++Devices: the daily-use iPhone and the iPhone 14 Pro Max, both on the same iCloud+account, both running the Development build, **both signed the same way** and+both registered for development in the account — the CloudKit environment follows+the provisioning profile, so a differently-signed install talks to production,+finds no promoted schema, and appears to sync nothing.++First, once: Settings → **Initialize CloudKit schema**. It needs no data and+answers on its own whether CloudKit accepts the V4 model — the question phase 1+left open. If it fails, stop; nothing below will mean anything.++Two independent observations of Q1. Run both; they fail differently.++**A — Second-device hydration.** On device 1: Settings → **Seed 3,000 entries**+(40 sites × 75). Wait for upload; confirm records in the CloudKit dashboard.+Install on device 2, open Settings → **Watch remote changes**, and let it+hydrate.++**B — Delete and reinstall.** On device 1, delete the app and reinstall. The+local store is gone and the cloud records remain, so the whole library arrives at+once — the highest-volume import available, and the condition most likely to+expose a dangling intermediate state. Watch the same way.++Both log to Console under subsystem `me.nore.ig.Asterism`, category+`CloudKitProbe`, prefixed `[probe]`. **Sample nil-site count** takes a reading by+hand at any point.++Volume matters: a small import may be applied in one transaction and never expose+an intermediate state, which would make a clean run meaningless rather than+reassuring. Seed more if hydration completes in a single notification.++Note that T-1969 landed, so first run no longer needs the airplane-mode dance —+a fresh store is marked ready at creation.++## Reading the result++| Nil-relationship count over the import | Means |+|---|---|+| Rises above zero, then falls to zero | Dangling references heal. Q1 answered yes — proceed. |+| Never rises above zero | The framework never exposes the state. Also a pass, and a stronger one. |+| Ends above zero and stays there | Q1 answered no. **Abandon `specs/relational-references/`**, and the mirroring spec goes back to needing a pending-reference taxonomy. |++For Q2, inspect the record fields in the CloudKit dashboard for how each+attribute was mapped, and confirm the values survived on the receiving device.+Any attribute that arrives mis-typed, empty, or as an unreadable blob reopens the+mirroring spec's "no schema change" non-goal.++## Device-run rule++Every step here installs on real hardware. Per the project's `CLAUDE.md`, ask for+approval at the time of each run — a plan describing a run is not consent to+perform it. Back up both devices first. Development installs over the+Development app, not the personal one, but that is a reason for care, not a+reason to skip asking.++## Findings++_Record answers here as they arrive, then fold them into the two decision logs+and delete this file with its branch._++- **`initializeCloudKitSchema()` accepts the V4 model: YES** (2026-07-27, iPhone+  14 Pro Max, iOS 26.2, Development container). Settles phase 1's Q3, open since+  M4a because it needed an entitlement and an iCloud account. Note what it does+  and does not prove: CloudKit accepts the model and creates the record types. It+  says nothing about whether values round-trip — that is Q2.++- **Q1 (relationship healing): YES, decisively.** Seeded 3,000 entries across 40+  sites on the device, deleted the app, reinstalled, and watched hydration.+  **45 samples; nil-`site` count peaked at 2,995 of 3,000 and settled at 0.**++  Two conclusions, and the second is worth as much as the first:++  1. Core Data resolves a relationship whose target arrives later, with no+     app-level bookkeeping. `specs/relational-references/` is viable and its kill+     switch (Q9) did not fire.+  2. **The ordering hazard is severe, not marginal.** 2,995 of 3,000 — almost the+     entire library existed at once as Entries whose Site had not yet arrived. The+     M4 three-way split, and M4a's whole justification, rest on the claim that an+     Entry arriving before its Site is the expected state of every sync. That+     claim is now measured rather than inferred.++  A methodological note so the number is not over-read: the run produced 45+  distinct samples, so the import was applied across many transactions and the+  intermediate state was genuinely observed. Had it landed in one or two+  transactions, a `maxNil` of 0 would have proved nothing.++- **Q2 (attribute round-trip): YES.** After hydration, every seeded value was+  re-read and compared against what was written across all 40 sites: **all+  attributes intact.** Covers `Site.junkSuffixRule` (a Codable struct holding an+  array of Codable structs), `TitlePattern.segmentWorkAnchor` and+  `segmentIgnoredAnchors` via the decoded definition (the array-of-Codable-structs+  shape the field reports were about), and `Work.genreTags` (`[String]`).++  So the reported SwiftData failure mode — arrays of Codable structs landing as+  transformables and producing mis-typed CloudKit fields — **did not reproduce**+  on iOS 26.2 against a real container. Apple's documentation was right and the+  field reports do not apply here. The mirroring spec's "no schema change"+  non-goal holds.++  **Coverage gap, recorded rather than glossed:** the seed creates a Site,+  TitlePattern, Work and Entries per site, but **no `URLRulePattern`**, so+  `URLRulePattern.definitionData` is not exercised. It is plain `Data` rather than+  a composite attribute, and therefore the least likely of the five shapes to+  fail — but it is untested, and "all attributes intact" should not be read as+  covering it.
specs/OVERVIEW.md Modified +40 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex b282889..f7189de 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -7,6 +7,8 @@ | [URL Identity & Re-Share](#url-identity--re-share) | 2026-07-21 | Done | Adds exact URL-derived identity, safe re-share editing, conflict recovery, confirmed Work URLs, Work Merge, and explicit V2/V3 backup handoff. | | [Unified Teaching Composition](#unified-teaching-composition) | 2026-07-22 | Done | Replaces the site-level title/URL interpretation fork with per-field teaching source composition. | | [Library Integrity Tolerance](#library-integrity-tolerance) | 2026-07-25 | Done — **one requirement unmet** | Makes three recoverable graph states degrade instead of failing the library, ahead of enabling CloudKit. Req 5.5's 250 ms diagnosis budget measures 0.268–0.278 s on host and ships as a known issue (Decision 11). |+| [Relational References](#relational-references) | 2026-07-26 | Done — **one requirement unmet** | Converts the hostname and cited-rule string references into modelled relationships, so an unarrived target is nil and heals itself. Scheduled ahead of mirroring because a migration is a one-device problem only until sync is on. Req 2.6's 10 s migration budget measures 17.31–17.75 s on host over the single-Site 5,000-Entry fixture and ships as an accepted known issue (Decision 6, Q60). |+| [CloudKit Mirroring](#cloudkit-mirroring) | 2026-07-26 | Planned | Enables mirroring on separate containers per configuration, app-only; reconciles duplicate Site rows; makes export always produce a file and import upsert-only. No archive format change. |  --- @@ -73,3 +75,41 @@ Makes three recoverable graph states degrade instead of failing the library, ahe - [prerequisites.md](library-integrity-tolerance/prerequisites.md) - [requirements.md](library-integrity-tolerance/requirements.md) - [tasks.md](library-integrity-tolerance/tasks.md)++## Relational References++Converts the string cross-record references — `Entry.hostname`, `Work.siteHostname`, and the `(UUID, version)` rule citations — into modelled relationships, keeping the strings as capture-time evidence and as the archive's reference format.++**Why it exists and why it is first:** a string reference whose target is absent is indistinguishable from one whose target never existed, which is why `SiteResolutionOrder` (231 lines) picks a winner among Site rows and why `CitedRuleResolution` (72 lines) had to hunt a cited id across all of them. A relationship whose target has not arrived is simply nil and resolves itself when the record lands. `CitedRuleResolution` was deleted on this milestone (task 14) once every citation resolved through the citing record's own Site; `SiteResolutionOrder` stays, for the hostname-level questions Decision 5 keeps on the winner. Scheduled before mirroring because the migration is a one-device problem exactly until sync is switched on.++**Req 2.6 is unmet as measured.** The V4 → V5 relationship pass over the 5,000-Entry composed fixture runs 17.31–17.75 s in release on host against a 10 s budget, because the fixture is a single Site and all 5,000 `entry.site` assignments append to one inverse array (cost grows as roughly *n*^1.65). The breach is accepted by the requirement's owner rather than fixed: the real library is under 200 notes, which the same curve puts near 0.1 s (Decision 6, Q60). The assertion ships wrapped in `withKnownIssue` with a regression ceiling outside it, so a fix self-reports and a genuine slowdown still fails. Batching the save would cut the number and destroy the atomicity Req 2.4 rests on.++**The gating probe passed (Q9, 2026-07-27).** Core Data does heal a dangling relationship when its target arrives: over one hydration of 3,000 entries, the unresolved count peaked at 2,995 and settled at 0 across 45 sampled transactions. The premise holds — and the measurement strengthens the case, since it shows the dangling state is the norm during sync rather than an edge. See [docs/investigations/cloudkit-probe.md](../docs/investigations/cloudkit-probe.md).++**Two requirements were withdrawn during design rather than built around.** Req 1.4's relationship-vs-string disagreement diagnosis: the strings are init-only and a CKRecord carries both fields together, so divergence is bug-only — and the check would have been blind to the one corruption this milestone can introduce (right hostname, wrong row) while costing a relationship fault per Entry on a path already near budget (Q18). Req 4.4's "deleting a Site must not take the patterns an Entry cites": `Site.patterns` is `.cascade`, so neither arm was satisfiable, and there is no delete-Site flow in the app (Q19).++**One ordering constraint is load-bearing** (Q13): both citation-replay paths must stop throwing *before* the union lookup is deleted. `replayRecentCandidate` throws uncaught and the publication guards on a hostname lookup, so changing the search space first would let a nil relationship — the normal state during sync — fail all of Recent.++- [decision_log.md](relational-references/decision_log.md)+- [design.md](relational-references/design.md)+- [implementation.md](relational-references/implementation.md)+- [prerequisites.md](relational-references/prerequisites.md)+- [requirements.md](relational-references/requirements.md)+- [tasks.md](relational-references/tasks.md)++## CloudKit Mirroring++Phase 2 of the three-way M4 split. Mirroring on separate containers per configuration with the app as the only synchronising process; duplicate Site rows reconciled silently; sync visibility in Settings and the Recent banner; export that always produces a file; import that adds and updates instead of replacing.++**Assumes [Relational References](#relational-references) ships first** (Q20) — without it this spec needs a pending-reference taxonomy, notification-driven re-evaluation, and a widened archive format, most of which later milestones delete.++**Two reversals worth carrying forward, both recorded rather than quietly applied:**++- **Phase 1's tolerated set was incomplete.** `V4LibraryValidator.swift:351`, `:604` and `:637` quarantine a hostname when a taught Site's active pattern, an Entry's cited pattern, or a manually-assigned Entry's Work has not arrived — and quarantine blocks export and disables rule application. Phase 1's Decision 4 claimed its three states were what sync produces; Decision 1 here corrects that.+- **Format 5 was cancelled** (Decision 4). It existed to represent duplicate Site rows, which M4c already planned to reconcile silently. Reconciling them here instead means the archive stays 4/4 — no fifth codec, types, fixtures, or reference validator.++**Requirements still resting on unsettled facts:** whether `Work.genreTags` and the Codable-struct attributes survive CloudKit — Apple's docs and field reports disagree, and the reported failure appears only on real hardware — settled by [docs/investigations/cloudkit-probe.md](../docs/investigations/cloudkit-probe.md); The unmarked-store brick that also gated it is **closed** — T-1969 and T-1919 landed in `194ed46`, marking a fresh store ready at creation and dropping the first-run choice. One residual carries into the design: readiness is published after emptiness is measured, so mirroring must not attach to the store until it is marked (Q22).++- [decision_log.md](cloudkit-mirroring/decision_log.md)+- [prerequisites.md](cloudkit-mirroring/prerequisites.md)+- [requirements.md](cloudkit-mirroring/requirements.md)
specs/cloudkit-mirroring/decision_log.md Added +233 / -0
diff --git a/specs/cloudkit-mirroring/decision_log.md b/specs/cloudkit-mirroring/decision_log.mdnew file mode 100644index 0000000..d0c8026--- /dev/null+++ b/specs/cloudkit-mirroring/decision_log.md@@ -0,0 +1,233 @@+# Decision Log: CloudKit Mirroring++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-07-26 | Spec name `cloudkit-mirroring`; phase 2 of the three-way M4 split | Matches how the phase 1 decision log already refers to it, and names the milestone's defining change rather than the archive work that serves it |+| Q2 | 2026-07-26 | One spec covering mirroring, sync visibility, the archive widening, and the import rework — not a further split | Phase 1's Decision 3 moved the archive work here so it is designed alongside the mirroring hazards that determine what it must represent; splitting again would put its requirements back in front of that |+| Q3 | 2026-07-27 | **Both gating items are cleared.** The attribute probe passed and the unmarked-store brick is fixed | Decision 3. Attribute fidelity verified on device across 40 sites — `Site.junkSuffixRule`, `TitlePattern.segmentWorkAnchor` and `segmentIgnoredAnchors`, `Work.genreTags` all round-tripped intact, so the "no schema change" non-goal holds. `initializeCloudKitSchema()` accepted the V4 model, settling phase 1's Q3. Brick fixed by T-1969/T-1919 in `194ed46` |+| Q4 | 2026-07-26 | ~~A Site gets a payload-local key in the archive~~ — **withdrawn, see Decision 4.** The archive needs no Site key because duplicate Site rows no longer reach it | The key existed only to represent two rows for one hostname. Reconciling them instead removes the representation problem rather than solving it |+| Q5 | 2026-07-26 | 2/2, 3/3 and 4/4 archives all stay importable, and export keeps writing 4/4 | An archive taken before mirroring is enabled is exactly the one worth restoring if enabling it goes wrong. The importer already reads all three; narrowing that here would be an unforced loss. Originally this said "export writes only the widened format", which Decision 4 removed |+| Q6 | 2026-07-26 | ~~Import deletes records the archive does not describe~~ — **superseded, see Decision 2** | The original rationale (a deliberately deleted record must not return) stops applying once mirroring exists, because deletion propagation becomes mirroring's job |+| Q7 | 2026-07-26 | Export records degraded and pending states faithfully and import accepts them | A backup preserves what is there. Repairing on the way in would make import a reconciler, which is phase 3's job |+| Q8 | 2026-07-26 | Both configurations mirror, to `iCloud.me.nore.ig.Asterism` and `iCloud.me.nore.ig.Asterism.dev`, both in CloudKit's development environment | Carries forward phase 1's Q8. A Development container is the only way to exercise mirroring without testing against the personal library |+| Q9 | 2026-07-26 | Sync correctness is verified with two installs on one iCloud account, both signed the same way | The receive half is the half that can silently not work. Both installs must share a signing route: the environment follows the provisioning profile, so a distribution-signed second install would talk to production and appear to sync nothing |+| Q10 | 2026-07-26 | ~~New capability gate `.m5`~~ — **reversed.** The runtime stays on `.m4`; the new codec pins `"m4"`; the format is carried by the version fields alone | `supportsPhraseTeaching`, `supportsURLIdentity` and `supportsComposedForms` are `gate == .m3 \|\| gate == .m4` equality chains, so moving the gate turns all three off with nothing failing to compile, and three import paths hard-guard `.m4`. Format version and capability gate are independent axes; widening a file format should not force a feature-flag migration. Separately, those chains become exhaustive switches so the compiler catches the next one |+| Q11 | 2026-07-26 | Failures raise the Recent banner only when the reader must act; transient failures update the Settings line alone; failures that are neither are named rather than reported as success | Classifying the error is testable; a silence threshold would need a number picked out of the air. The third arm exists because `userDeletedZone`, `changeTokenExpired` and `zoneNotFound` are neither actionable nor transient and are terminal without a reset |+| Q12 | 2026-07-26 | Import commits in bounded batches; the batch size is set in the design against a measurement, not in the requirements | Phase 1's Decision 3 justified this with "a ~30,000-change save is TN3164's named rate-limit trigger". That number could not be found in the current technote and is treated as unsupported. The property that survives is real — an interrupted import must leave a legal library, and a single enormous save is a throttling risk — but the constant is a design choice, and a local save cap does not bound the CloudKit request rate anyway |+| Q13 | 2026-07-26 | ~~The archive header declares format 5 against schema 4~~ — **withdrawn, see Decision 4.** There is no format 5; 4/4 stands | With duplicate Site rows reconciled and unresolved references self-healing, nothing this milestone produces is unrepresentable in 4/4 except duplicate application UUIDs (Q18) |+| Q14 | 2026-07-26 | ~~Export keeps refusing while a Site is quarantined~~ — **reversed.** Export produces a file for every state but one (Q18) | The premise was that quarantine is not a state mirroring can produce. Decision 1 falsifies it. Composed with the old Req 2.1 the pair said: an ordinary sync quarantines a hostname, and the backup tool then declines — at the one moment an archive is most wanted |+| Q15 | 2026-07-26 | Sync visibility reports last export and last import separately, and never reads healthy while the library is degraded | A successful export event proves this device pushed, not that anything arrived. A single "last synchronized" line shows the reader the weaker fact under the stronger name, and a green line above an unreconciled library is worse than no line |+| Q16 | 2026-07-26 | Scale requirements assert convergence and budgets, never ordering or the absence of throttling | There is no injection point for CloudKit delivery order, so no deterministic test for "Entry arrives before Site" exists. A throttle can also surface minutes after a test ends. Asserting either would build a green suite that proves nothing |+| Q17 | 2026-07-26 | Performance measurements are taken with sync quiesced, and say so | Adding a background sync daemon to a harness whose p95 already spans 0.74–1.28 s on unchanged code makes the number noise. The current protocol (median always, p95 under `CONTROLLED=1`, Q58/Q61 of phase 1) is what applies — the "20 runs, assert the 19th" protocol the first draft cited was superseded |+| Q18 | 2026-07-26 | Export refuses for exactly one state: two records of one type sharing an application UUID | The archive keys records by UUID, so it cannot hold both, and silently dropping one is data loss in a backup. Far narrower than the refusal it replaces, and the state is now unlikely: it arises mainly from importing one archive on two devices, which upsert-only import (Decision 2) and the §13.2 one-device procedure both avoid. The repair is M4c's Entry/Work collapse |+| Q19 | 2026-07-26 | A hostname with no Site row gets an untaught Site materialised, rather than being represented as an orphan | It is exactly what capture already does for an unknown host, it makes the archive coherent with no format change, and it composes with Q20: if the real Site arrives later, the two rows reconcile silently |+| Q20 | 2026-07-26 | This spec assumes `specs/relational-references/` ships first | Modelled relationships make Entry→Site, Entry→Work and rule citations heal themselves, which deletes the pending-reference taxonomy, the notification-driven re-evaluation, and `CitedRuleResolution`. Doing mirroring first would mean building all three and discarding them |+| Q21 | 2026-07-27 | The brick half of Decision 3 is **done** — T-1969/T-1919 landed in `194ed46` | A fresh store is marked ready at creation, emptiness is measured rather than assumed, the first-run choice is gone, and the Q25 throw is preserved for a genuinely partial migration. `V4OpeningResult` is now `.ready` only |+| Q22 | 2026-07-27 | The app must not attach mirroring to the store until it is marked ready | Readiness is published *after* emptiness is measured (`LibraryRepository+V4Bootstrap.swift:167-181`). A record landing inside that window makes the store nonempty and unmarked, which is the one state that still fails closed. The window is milliseconds against a network round trip, so it is unlikely rather than impossible — and it is free to close by opening the create-and-mark path with mirroring off and attaching it afterwards. Req 6.1 |+| Q23 | 2026-07-27 | The reported SwiftData "arrays of Codable structs break under CloudKit" failure **does not reproduce** on iOS 26.2 | Field reports (Apple Forums 799236) said such attributes land as transformables using the default `NSKeyedUnarchiveFromData` transformer and produce mis-typed fields, only on real hardware. Measured directly: every value came back identical. Apple's documentation was right for this model. Recorded because the opposite result would have forced a schema change the spec excludes — and because a future OS could regress it |+| Q24 | 2026-07-27 | Decision 2 (only the app mirrors) now rests on an observation, not only on TN3164 | Two `NSPersistentCloudKitContainer`s over one store in one process produced exactly `134422`, "there is another instance of this persistent store actively syncing with CloudKit in this process", on device. The technote said it would; the device confirmed it. Hit accidentally when the probe's schema-init pointed at the live store |+| Q25 | 2026-07-27 | The ordering hazard the whole M4 split rests on is **severe**, not marginal | One ordinary hydration of 3,000 entries left **2,995** of them holding an unresolved Site reference at the peak, across 45 sampled transactions. M4a argued from first principles that an Entry arriving before its Site is the expected state of every sync; that is now measured. It also means any design that treats the dangling state as an edge case is wrong by two orders of magnitude |++---++## Decision 1: An Absent Reference Target Is Not Evidence of Damage++**Date**: 2026-07-26+**Status**: accepted, amended same day (Q20) — supersedes the closed set of phase 1's Decision 4++### Context++Phase 1 tolerated exactly three graph states and justified the closed set on the claim that "the three named states are the ones CloudKit's indeterminate delivery order and lack of cross-device uniqueness actually produce."++That claim is incomplete. `V4LibraryValidator` also fails — and the failure is recorded per hostname, which *quarantines* it (`V4LibraryValidator.swift:105`) — for at least three more states that indeterminate delivery produces directly: a `.taught` Site whose active `TitlePattern` has not arrived (`:351`), an Entry citing a title pattern that has not arrived (`:604`), and a manually-assigned Entry whose `Work` has not arrived (`:637`). Quarantine disables rule application on capture for that hostname and, under the rule this spec inherited, blocked backup export.++This is not confined to a second device's first sync. Entry provenance cites `TitlePattern` by id and version, so **every re-teach on one device leaves the other holding entries that cite a pattern whose replacement has not arrived**. Teaching is the app's primary loop; the condition therefore recurs for as long as the app is used.++Phase 1's Decision 4 anticipated this in its own negative consequences: *"Phase 2 or 3 may discover a fourth sync-producible state, which would need this decision revisited rather than being covered by a general clause."*++### Decision++An unresolved cross-record reference does not quarantine, does not disable rule application, and does not block export. Quarantine is reserved for record-local failures that no arriving record could repair.++**Amended 2026-07-26 (Q20).** The original form of this decision introduced a *pending* state with its own bookkeeping and notification-driven re-evaluation. Once `specs/relational-references/` makes these references modelled relationships, an unarrived target is simply nil and the association forms by itself when the record lands — so there is nothing to track and nothing to promote. What survives is the principle: **absence of a target is absence of evidence, not evidence of damage.** The mechanism is deleted; only the validator's refusal to throw on nil, and a re-derivation of what is *displayed* when records arrive, remain.++### Rationale++The distinction that makes this tractable is not "which states did we enumerate" but **what evidence exists that something is wrong**. An absent target is the absence of evidence: the local graph cannot distinguish "not here yet" from "genuinely gone", and no signal from `NSPersistentCloudKitContainer` reports that synchronisation has settled — a successful import event means that request finished, not that the graph is whole. Elapsed time is a heuristic, not proof. A record-local failure is different in kind: an unrecognised enum raw value or an impossible tuple is positive evidence, and no record that arrives later can make it legal.++Re-evaluation on imported changes is what makes pending states self-clearing. It also fixes a latent problem in the current code: validation runs at open and latches, so a quarantine can persist after the graph that caused it has become complete.++### Alternatives Considered++- **Enumerate the new states and tolerate those too**: mirrors phase 1's approach - Rejected because the set is not closed. It grew once under examination and would grow again; the general property (unresolved reference vs positive damage) is what actually separates the classes.+- **Promote a pending reference to damage after a timeout**: gives pending states an end - Rejected because no timeout is defensible. Apple publishes no hydration SLA, and throttling can extend an import to hours; the only outcome is falsely condemning a healthy library.+- **Stop validating at open entirely**: simplest - Rejected because record-local failures are real, are not sync artefacts, and are exactly what the diagnosis surface exists to show.++### Consequences++**Positive:**+- Teaching on one device stops silently disabling capture parsing on the other.+- Export stops being unavailable during ordinary sync activity.+- A quarantine that a later record repairs now clears on its own.++**Negative:**+- Genuine damage of the same shape — a pattern deleted by a bug — is now reported as pending indefinitely, and the reader is told less about it than before.+- The diagnosis surface gains a third class, and phase 1's Q21 damage-suggesting wording has to be re-examined now that sync can produce these states innocently.+- Validation moves from a one-shot open-time check to something re-evaluated on a notification, which is a change to a path every open already depends on.++### Impact++`V4LibraryValidator`, `LibraryDiagnostics` and its UI surface, the quarantine map and every consumer of it, `BackupV4Exporter`'s refusal gate, and the capture path's rule-application guard.++---++## Decision 2: Import Adds and Updates; It Never Deletes++**Date**: 2026-07-26+**Status**: accepted — supersedes Q6++### Context++Import was specified as a restore: after it, the library matches the archive exactly, including deleting records the archive does not describe. That is the current behaviour, reached by `deleteAllEntities` followed by re-materialisation in one save.++With mirroring live, every one of those deletions is exported and destroys the same records on every other device, including everything captured since the archive was taken. Two further facts close off the obvious mitigations. Mirroring's export is driven by persistent history, so detaching the CloudKit container, deleting, and reattaching replays the history and exports the deletions anyway — pausing changes timing and nothing else. And the existing staleness guard hashes the full entity id set at preview and re-compares at commit (`LibraryRepository+BackupImport.swift:256`, `:337-350`), so on any device receiving sync traffic the confirm step can never succeed.++There is also a hazard specific to a synced world: a second device that is still filling can produce a syntactically valid archive of a partial library. Under exact-restore semantics, importing that archive later turns an incomplete snapshot into authoritative deletions everywhere.++### Decision++Import adds every record the archive describes that the library lacks and updates every record it describes that the library already holds, matched by application UUID. It never deletes a record for being absent from the archive.++### Rationale++Deletion propagation becomes mirroring's job the moment mirroring exists. Q6's rationale — that a deliberately deleted record must not come back — was reasoning about a single-device world where import was the only way records moved; in a mirrored world a deletion made on any device already reaches the others without import's help.++Upsert-only removes the delete-propagation hazard outright, removes the need for any pause mechanism, removes the partial-snapshot hazard, and restores something the app otherwise loses: with mirroring on and a destructive restore, an accidental deletion has no safe recovery, because the tool that would fix it is itself destructive.++It is also less code. The batch ordering, the deletion pass, and the staleness fingerprint all fall away.++### Alternatives Considered++- **Exact restore, purging the CloudKit zone first**: `purgeObjectsAndRecordsInZone` deletes cloud records and local objects so other devices honour the removal - Rejected for this milestone: field reports on its reliability are mixed, a device offline during the purge is still a hazard, and it is a separate ceremony rather than the ordinary restore path.+- **Exact restore, empty targets only**: safe, and matches the §13.2 dev→prod bridge - Rejected because it cannot restore over a damaged library, which is the case the backup exists for.+- **Pause mirroring, import, resume**: the intuitive fix - Rejected because it does not work. History-driven export replays the deletions on resume.++### Consequences++**Positive:**+- A restore can never destroy data on another device.+- Accidental deletion regains a recovery path.+- The importer loses its deletion pass, its ordering constraint, and its staleness fingerprint.++**Negative:**+- "Restore" no longer means "make the library match this file". A record deleted on purpose and present in an old archive returns on import, and the only way to remove it again is to delete it again.+- The §13.2 dev→prod transition relies on the target being empty, which it is — but that is now an assumption rather than something import enforces.+- A genuinely corrupted library cannot be replaced wholesale; it can only be added to.++### Impact++`LibraryRepository+BackupImport` (all three commit paths), `computeInventoryFingerprint` and its callers, the confirm-import UI, and the §13.2 runbook.++---++## Decision 3: Two Items Gate This Spec and Precede It++**Date**: 2026-07-26+**Status**: accepted++### Context++Two facts the requirements depend on are not established.++**Whether the V4 attributes can mirror at all.** `Work.genreTags` is an array of `String`; `TitlePattern.segmentWorkAnchor` and `segmentIgnoredAnchors` are a Codable struct and an array of Codable structs; `Site.junkSuffixRule` is a Codable struct. Apple documents composite attributes as usable with `NSPersistentCloudKitContainer` and transformables as serialised to `NSData`. Field reports contradict this for arrays of Codable structs under SwiftData, which land as transformables using the default `NSKeyedUnarchiveFromData` transformer and produce mis-typed CloudKit fields — reproducing **only on physical devices with real iCloud accounts, never in the Simulator**. Phase 1's Q2 proved only that a mirroring `ModelContainer` constructs; its harness died before push registration. The documented workaround is `Data?` plus a computed accessor, which is a schema change this spec's non-goals exclude.++**A second device can be bricked permanently.** `openV4ForApp` creates an empty store and returns `.setupRequired` without publishing the readiness marker (`LibraryRepository+V4Bootstrap.swift:109-118`). CloudKit then fills that unmarked store, and a kill before setup is confirmed puts the next launch into the throw at `:137-139` — "a nonempty store carries neither a readiness marker nor a migration sidecar" — permanently, in both processes. The share extension separately fails closed while the marker is absent. Phase 1's Q6 declined to make this recoverable, reasoning that "the state cannot arise from mirroring (readiness markers are local files, not mirrored records)". The marker is local; the records are not.++### Decision++Both precede the rest of the spec. The attribute probe runs first, on a physical device against the Development container, and its result decides whether "no schema change" is reachable. The unmarked-store fix is implemented and shipped standalone, ahead of any mirroring work.++### Rationale++The probe is a feasibility gate wearing an acceptance criterion's clothes. Placing it inside the spec as task 1 would mean committing to a scope — "the store stays on V4" — before knowing whether that scope exists. It costs one device run to settle.++The brick fix has no dependency on the archive work, is the only finding that permanently destroys access to a library rather than degrading it, and is the cheapest of the set. It also has value independent of mirroring: the same state is reachable from an interrupted first run today.++### Alternatives Considered++- **Keep both inside the spec, probe as task 1** - Rejected for the probe because the non-goals contradict one of its outcomes, and a requirements document must not simultaneously depend on an unproven fact and forbid the remedy.+- **Fix the brick as part of the mirroring work** - Rejected because it would ship behind the archive work, leaving the window open for the entire milestone, and because nothing about the fix needs mirroring to exist.+- **Stage everything through Development for a fortnight first** - Not rejected on merit; deferred as a sequencing choice within the spec rather than a gate on writing it.++### Consequences++**Positive:**+- The requirements are finalised against measured facts rather than around an unknown.+- The brick is closed before any second device exists to hit it.++**Negative:**+- A device run is needed before the spec can be finished, and device runs require approval at the time (project `CLAUDE.md`).+- If the probe fails, this spec grows a schema migration and the estimate is wrong.++### Impact++`prerequisites.md`; potentially `AsterismSchemaV5` and a migration; `LibraryRepository+V4Bootstrap` for the brick fix.++---++## Decision 4: Reconcile Duplicate Site Rows Here; the Archive Format Does Not Change++**Date**: 2026-07-26+**Status**: accepted — withdraws Q4 and Q13, and moves one item forward from M4c++### Context++The plan inherited from phase 1 was that this milestone widens the archive to a fifth format, because `BackupV4Site` is keyed by hostname and every reference to a Site is a hostname string, so two rows owning different rules flatten into two indistinguishable records. Phase 1's Decision 3 called that "a project, not a clause" and deferred it here.++Reading the 4/4 reference validator (`BackupV4Codec.swift:217-231`, `:378-385`) shows it rejects five things: duplicate Entry, Work, TitlePattern and URLRule ids; duplicate Site hostnames; an Entry whose hostname has no Site; an Entry whose Work is absent; and an Entry citing a rule that is not present.++Set against what M4c already plans, four of those five need no representation:++- **Duplicate Site hostnames.** M4c's stated plan is to reconcile duplicate Site rows *silently*, because they are re-derivable teaching knowledge and the alternative degraded state is an unopenable library. So the format-5 machinery to represent them would be dead the day M4c shipped.+- **An Entry whose hostname has no Site.** Materialising an untaught Site is what capture already does for an unknown host (Q19).+- **An absent Work, and an unresolved rule citation.** Both become nil relationships that heal themselves once `specs/relational-references/` lands (Q20).++Only duplicate application UUIDs remain unrepresentable, and that state is rare and has a repair in M4c.++### Decision++Duplicate Site rows reconcile silently in this milestone, pulled forward from M4c. A hostname with no Site row gets an untaught Site. The archive format stays 4/4: no fifth codec, no payload key for Site, no format version bump.++### Rationale++The format change was solving a representation problem that only exists because the incoherence is allowed to persist. Reconciling is both smaller and better: the reader's library stops carrying the artefact at all, rather than carrying it and having the backup learn to describe it.++Pulling this one item forward does not undermine M4c's reason for being last — "write the reconciler against duplicates actually observed rather than a guess". That argument is about **Entries and Works**, where collapsing wrong destroys reader-authored notes and the divergent case needs a review sheet. Site rows are the one class M4c already decided needs no reader involvement and no observation to design, precisely because nothing authored is at stake.++It also removes an ordering hazard nobody had costed: with duplicate Sites persisting, every Entry's rule citation has to be resolved against the union of rows for a hostname, which is why `CitedRuleResolution` exists.++### Alternatives Considered++- **Build format 5 as planned** - Rejected: it is the largest single piece of the milestone, and M4c deletes its main purpose. Building something to throw it away next milestone is the definition of waste.+- **Format 5 but keep Site reconciliation in M4c** - Rejected for the same reason, plus it leaves the reader with duplicate Site rows and no repair path for a whole milestone (phase 1 Req 3.4 already records that re-teaching cannot clear one).+- **Pull all of M4c forward** - Rejected. Entry and Work collapse needs the divergent-review sheet and is exactly the work that benefits from observing real duplicates first.+- **Let export drop one of two duplicate-UUID records** - Rejected: silent data loss inside a backup is worse than a named refusal.++### Consequences++**Positive:**+- The largest piece of the milestone disappears: no fifth codec, types, fixtures, reference validator, or migration of the confirm-import UI.+- Existing archives stay readable with no compatibility work, because nothing about the format moved.+- The reader gets a repair for duplicate Site rows a milestone earlier than planned.+- `CitedRuleResolution` becomes unnecessary once rows are unique per hostname.++**Negative:**+- M4c loses its cheapest item, so what remains there is uniformly the hard part.+- Reconciliation is a *write* performed in response to arriving records, which mirrors back out; two devices must select the same survivor or they will fight. Req 1.5 exists for that, and the deterministic Site order already provides it.+- Export still refuses in one case (Q18), so "export never refuses" is not literally true.++### Impact++`SiteResolutionOrder` gains a reconciling caller; `CitedRuleResolution` becomes removable; `BackupV4Codec`, `BackupV4Types`, `BackupV4Exporter` and both import paths are left alone apart from the refusal gate; M4c's scope shrinks to Entries and Works.
specs/cloudkit-mirroring/prerequisites.md Added +72 / -0
diff --git a/specs/cloudkit-mirroring/prerequisites.md b/specs/cloudkit-mirroring/prerequisites.mdnew file mode 100644index 0000000..3f78e93--- /dev/null+++ b/specs/cloudkit-mirroring/prerequisites.md@@ -0,0 +1,72 @@+# Prerequisites for CloudKit Mirroring++These tasks require human intervention outside of code. The first two sections+gate the spec itself (Decision 3), not just its implementation.++## Before the requirements are final++- [x] **Created both CloudKit containers** in the Apple Developer account:+      `iCloud.me.nore.ig.Asterism` and `iCloud.me.nore.ig.Asterism.dev` (Q8).+- [x] **Added the iCloud (CloudKit) capability and Background Modes → Remote+      notifications** (commit `6006b6a`) to both App IDs and both share-extension App IDs, and+      regenerate the provisioning profiles. Selecting CloudKit also configures+      push; no user notification permission is required, and no background+      processing task is needed for mirroring alone.+- [x] **Ran `docs/investigations/cloudkit-probe.md` — Q2 answered yes (2026-07-27).**+      It decides whether the "no schema change" non-goal is reachable+      (Decision 3), covering every generated mapping rather than one+      representative struct: `Work.genreTags`, `TitlePattern.segmentWorkAnchor`,+      `TitlePattern.segmentIgnoredAnchors`, `Site.junkSuffixRule`, and+      `URLRulePattern.definitionData`. The reported failure for arrays of+      Codable structs reproduces **only on real hardware with a real iCloud+      account — never in the Simulator**, so a simulator pass proves nothing.+      All four seeded shapes round-tripped intact, so the "no schema change"+      non-goal holds. `initializeCloudKitSchema()` also succeeded, settling phase+      1's Q3. One gap: `URLRulePattern.definitionData` was not exercised, because+      the seed created no URL rule.++## Before implementation++- [ ] **`specs/relational-references/` ships first** (Q20). This spec is written+      against the graph that milestone leaves behind: an unresolved reference is+      a nil relationship that heals itself, so there is no pending-reference+      taxonomy here and no notification-driven re-derivation beyond refreshing+      what is displayed.+- [ ] **Take a pre-flight backup and prove it restores.** A byte-level container+      download (Xcode → Devices and Simulators → Download Container) *and* a+      format-4 archive, with the archive proven to import into a Development+      install. Note that a restored container copy taken before mirroring is not+      a clean undo once the mirror has moved on: its metadata claims records are+      already exported.+- [x] **Unmarked-store fix shipped** — T-1969 and T-1919 landed in `194ed46`+      (2026-07-27). A fresh store is marked ready at creation and the first-run+      choice is gone. One residual follows into the design rather than here: the+      marker is published *after* emptiness is measured, so mirroring must not be+      attached to the store until it is marked (Q22, Req 6.1).++## Before testing++- [ ] **The second device — the iPhone 14 Pro Max — signed into the same iCloud+      account**, with both installs signed the same way (Q9). The CloudKit+      environment follows the provisioning profile, so a distribution-signed+      second install talks to production, where no schema is promoted, and will+      appear to sync nothing. Register it for development and add it to the+      profiles alongside the daily-use phone.+- [ ] Keep both devices **unlocked** for the duration of any device run, and back+      them up first. A locked phone yields+      `com.apple.dt.deviceprep Code=-3 "Unlock <device> to Continue"` partway+      through and corrupts the run.+- [ ] Performance runs are taken with **sync quiesced** (Q17), on the same device+      class as the recorded baselines.++## Notes++**The CloudKit development environment is not a backup.** It is resettable from+the dashboard and carries no durability promise. Turning mirroring on will feel+like the data is now safe; it is not, and the archive discipline must not relax.++**Rollback, in order of preference.** Dashboard → Reset Development Environment+is the "undo mirroring" button, available precisely because production is a+non-goal; it also destroys the schema, so `initializeCloudKitSchema()` must be+re-run afterwards. Before a second install has merged, a container download+restores the device. After two installs have merged, a merge has no inverse.
specs/cloudkit-mirroring/requirements.md Added +151 / -0
diff --git a/specs/cloudkit-mirroring/requirements.md b/specs/cloudkit-mirroring/requirements.mdnew file mode 100644index 0000000..d3597c8--- /dev/null+++ b/specs/cloudkit-mirroring/requirements.md@@ -0,0 +1,151 @@+# Requirements: CloudKit Mirroring++## Introduction++The library is local-only: every store opens with `cloudKitDatabase: .none`, no configuration carries an iCloud entitlement, and an import replaces the whole library in one save. This milestone turns mirroring on for both configurations against separate containers, with the app as the only synchronising process. Two changes make that safe: the Site graph is made coherent rather than merely tolerated, so the archive never has to represent an incoherent one; and import adds and updates instead of deleting, so a restore cannot propagate as data loss.++**This spec assumes `specs/relational-references/` ships first.** That milestone turns the Entry→Site, Work→Site, and rule-citation references into modelled relationships, so a reference whose target has not arrived is nil and heals itself when it does. Without it, this spec needs a pending-reference taxonomy, notification-driven re-evaluation, and a widened archive format — roughly double the work, most of it thrown away later.++Reference: `docs/asterism-design.md` §2.2, §3.2, §10, §13.1, §13.2, §14 (M4b); `specs/library-integrity-tolerance/decision_log.md` Decisions 2, 3, 4, and Q3, Q6, Q8, Q11.++## Non-Goals++- **Any archive format change.** 4/4 stays. Making the archive represent duplicate Site rows was the reason a format 5 was planned; §1 removes the need by reconciling them instead.+- Reconciling duplicate Entries and Works — phase 3 (M4c). Only Site rows reconcile here, because they alone are re-derivable teaching knowledge that needs no reader involvement.+- Semantic duplicates: two devices independently capturing the same serial mint two Works with different UUIDs. A known, unaddressed divergence — no UUID-keyed pass will find it.+- Destructive restore, and any purge of the CloudKit zone that would make one safe.+- Schema changes. The relational migration is `specs/relational-references/`; nothing here alters the schema it leaves behind.+- Work deletion and its detach prompt, and the `intentionallyUnattached` contract under sync (M5).+- Mirroring from the share extension, and any sync state on the capture sheet.+- CloudKit's production environment and schema promotion (§13.2).+- Sharing, the public database, and `CKShare`.+- Scheduled backups; export stays manually triggered.+- Markdown export, search, and the Sites settings screen (M5).++---++### 1. The Site Graph Is Made Coherent++**User Story:** As the reader, I want the app to settle which Site row is authoritative for a hostname on its own, so that sync artefacts in re-derivable teaching knowledge never reach me or my backups.++**Acceptance Criteria:**++1. <a name="1.1"></a>WHERE more than one Site row exists for a hostname, the app SHALL reduce them to one, retaining the union of their title rules and URL rules.+2. <a name="1.2"></a>Reconciliation SHALL require no reader action and present no confirmation.+3. <a name="1.3"></a>IF the union holds more than one active title rule or more than one current URL rule, THEN the app SHALL keep the one the existing deterministic Site order selects and retain the rest as inactive history.+4. <a name="1.4"></a>Records that referenced either row SHALL reference the survivor afterwards, and a rule an Entry cites SHALL keep resolving across the reconciliation.+5. <a name="1.5"></a>Two devices reconciling the same set of rows SHALL select the same survivor.+6. <a name="1.6"></a>WHERE an Entry or Work names a hostname with no Site row, the app SHALL materialise an untaught Site for that hostname, as capture already does for an unknown host.+7. <a name="1.7"></a>Reconciliation SHALL run when records arrive from sync, not only at launch.++---++### 2. An Unresolved Reference Does Not Fail Anything++**User Story:** As the reader, I want a reference whose target has not arrived yet to cost me nothing, so that teaching a site on one device does not degrade the other.++**Acceptance Criteria:**++1. <a name="2.1"></a>A reference whose target is absent SHALL leave its record renderable and marked as needing attention, and SHALL NOT quarantine its hostname, disable rule application on capture, or prevent export.+2. <a name="2.2"></a>WHEN records arrive from sync, THEN the app SHALL re-derive what it displays, so a reference resolved by an arriving record stops being reported without relaunching.+3. <a name="2.3"></a>A record-local failure — an unrecognised enum raw value, or a blank required value — SHALL continue to quarantine its hostname, because no arriving record can repair it.+4. <a name="2.4"></a>The app SHALL NOT treat an absent reference as damage on elapsed time alone.++---++### 3. The Archive Always Produces a File++**User Story:** As the reader, I want a backup I can take at any moment, so that the state most likely to need rescuing is not the state the tool refuses to run in.++**Acceptance Criteria:**++1. <a name="3.1"></a>Export SHALL produce a file for any library the app can open, including one carrying unresolved references or a quarantined hostname, with the single exception in [3.3](#3.3).+2. <a name="3.2"></a>Export SHALL write the existing 4/4 format, unchanged.+3. <a name="3.3"></a>IF two records of one type share an application UUID, THEN export SHALL refuse and name that as the reason, because the archive keys records by UUID and cannot represent both.+4. <a name="3.4"></a>Export SHALL refuse to produce a file whose decode does not reproduce what was exported, and SHALL name that as the reason.+5. <a name="3.5"></a>Exporting a library carrying unresolved references and importing the result into an empty library SHALL produce a library with the same records, relationships, and diagnoses.++---++### 4. Restoring an Archive Adds and Updates++**User Story:** As the reader, I want restoring a backup to put back what I lost without taking away what I still have, so that a restore cannot become a deletion that reaches every device.++**Acceptance Criteria:**++1. <a name="4.1"></a>Import SHALL add every record the archive describes that the library lacks, and update every record it describes that the library already holds, matched by application UUID.+2. <a name="4.2"></a>Import SHALL NOT delete a record because the archive does not describe it.+3. <a name="4.3"></a>Importing the 5,000-Entry fixture SHALL commit in more than one save.+4. <a name="4.4"></a>Every commit boundary SHALL leave a library the app can open, and IF an import stops partway THEN the app SHALL report that it did not complete and which archive it was applying.+5. <a name="4.5"></a>Import SHALL NOT be refused because records arrived while the reader was confirming it.+6. <a name="4.6"></a>An archive in the 2/2, 3/3, or 4/4 format SHALL import, producing the same records and relationships it produces today.+7. <a name="4.7"></a>IF an archive fails its format or checksum checks, THEN import SHALL refuse it, name the reason, and leave the library unchanged.++---++### 5. The App Mirrors; the Extension Does Not++**User Story:** As the reader, I want my notes on every device, so that capture and reading are not tied to the phone I happened to share from.++**Acceptance Criteria:**++1. <a name="5.1"></a>Exactly one process SHALL open the library with CloudKit mirroring enabled, and it SHALL be the app; the share extension SHALL open the same store with mirroring off.+2. <a name="5.2"></a>An Entry captured on one install SHALL appear on a second install signed into the same iCloud account, and an edit to a note, rating, chapter title, or work assignment SHALL propagate likewise.+3. <a name="5.3"></a>Deleting an Entry on one install SHALL delete it on the other.+4. <a name="5.4"></a>A capture written by the extension SHALL reach the container the next time the app runs, with no action by the reader.+5. <a name="5.5"></a>Every stored attribute of the schema SHALL survive a round trip to a second install unchanged, including `Work.genreTags`, `TitlePattern.segmentWorkAnchor`, `TitlePattern.segmentIgnoredAnchors`, `Site.junkSuffixRule`, and `URLRulePattern.definitionData`.+6. <a name="5.6"></a>Two installs left idle after a change SHALL converge on the same records, and no assertion about sync SHALL depend on the order in which records arrive.++---++### 6. A Device Whose Library Is Still Filling++**User Story:** As the reader, I want a new device to survive its first sync, so that installing the app on a second phone cannot leave me with a library that will not open.++Most of this section was closed ahead of the spec by T-1969 and T-1919 (commit `194ed46`): a fresh store is marked ready the moment it is created, an empty unmarked store left by an older build is marked on open, and the first-run choice is gone. What remains is the part that only mirroring can violate.++**Acceptance Criteria:**++1. <a name="6.1"></a>Mirroring SHALL NOT be able to write into the store before it is marked ready. Readiness is published after emptiness is measured, so a record arriving inside that window would make the store nonempty and unmarked — the one state that still fails closed.+2. <a name="6.2"></a>A store that CloudKit has filled SHALL open on every subsequent launch, and SHALL NOT be reported as an unverifiable partial migration.+3. <a name="6.3"></a>The share extension SHALL be able to capture on a device whose library is still arriving.+4. <a name="6.4"></a>No action SHALL be blocked on the library being fully populated, because no signal reports that initial sync is complete.+5. <a name="6.5"></a>An empty library that has never completed a sync SHALL be distinguishable from one that is genuinely empty, so a new device does not present an unsynced library as settled.++---++### 7. The Two Configurations Stay Separate++**User Story:** As the reader, I want development builds to be unable to touch my real library, so that testing sync cannot cost me the data sync exists to protect.++**Acceptance Criteria:**++1. <a name="7.1"></a>A record written by one configuration SHALL never appear in the other's library, on any device.+2. <a name="7.2"></a>The CloudKit container identifier and the App Group identifier SHALL both derive from one declared environment value, so a build cannot pair one configuration's store with the other's container. Tracked separately as T-1982 — the App Group is currently declared in three independent forms and the container in two, with nothing enforcing agreement.++---++### 8. The Reader Can See Whether Sync Is Working++**User Story:** As the reader, I want to know when my notes have stopped reaching iCloud, so that I find out from the app rather than from a note missing on my other device.++**Acceptance Criteria:**++1. <a name="8.1"></a>Settings SHALL show when the library last exported to iCloud and when it last imported, separately, or state that it never has.+2. <a name="8.2"></a>WHEN a sync failure requires the reader to act — iCloud signed out, iCloud storage full, or the account restricted — THEN Recent SHALL report it through the existing banner, and Settings SHALL name the condition and what to do about it.+3. <a name="8.3"></a>WHERE a sync failure is transient — offline, throttled, or the service unavailable — the app SHALL record it in Settings and SHALL NOT raise the banner.+4. <a name="8.4"></a>WHERE a failure is neither, including one requiring the app to reset its sync state, the app SHALL record it in Settings and name it rather than reporting success.+5. <a name="8.5"></a>Settings SHALL NOT report sync as healthy while the library carries a quarantined hostname or records sharing an application UUID; the count of each SHALL be visible alongside the sync lines.+6. <a name="8.6"></a>The banner and the Settings lines SHALL update when the conditions they report change, without relaunching the app.++---++### 9. Scale++**User Story:** As the reader, I want sync to cost nothing in speed, so that the app is not slower for being synchronized.++**Acceptance Criteria:**++1. <a name="9.1"></a>With mirroring enabled and the 5,000-Entry fixture in place, Recent's publish-to-interactive path SHALL stay within its 2 s budget and the extension's open-and-validate path within its 1 s budget, measured by the project's current protocol — median asserted on every run, p95 only under `CONTROLLED=1` — with sync quiesced, and the measurement SHALL state that it was.+2. <a name="9.2"></a>Site reconciliation SHALL add no measurable cost to the capture path for a hostname carrying one Site row.+3. <a name="9.3"></a>Importing the 5,000-Entry fixture with mirroring enabled SHALL converge, recovering on its own if CloudKit throttles it.
specs/relational-references/decision_log.md Added +631 / -0
diff --git a/specs/relational-references/decision_log.md b/specs/relational-references/decision_log.mdnew file mode 100644index 0000000..888aa34--- /dev/null+++ b/specs/relational-references/decision_log.md@@ -0,0 +1,631 @@+# Decision Log: Relational References++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-07-26 | Spec name `relational-references` | Names what changes — the references become relational — rather than the schema version it happens to consume |+| Q2 | 2026-07-26 | Scheduled before `specs/cloudkit-mirroring/` | A migration is a one-device problem exactly until mirroring is on. Afterwards it runs per device, emits writes that mirror out, and has to be correct against a store other devices are concurrently changing |+| Q3 | 2026-07-26 | The hostname strings, cited ids and version integers all stay | They are capture-time evidence (§2.6), they are how the archive references records, and keeping them makes the migration reversible in practice: the relationship is derived data |+| Q4 | 2026-07-26 | `Entry.work` is left alone | It is already a relationship with an inverse (`Models.swift:140-141`). What makes an absent Work fatal today is a validator rule, not the data shape |+| Q5 | 2026-07-26 | `TitlePattern.site` and `URLRulePattern.site` are left alone | Also already relationships. Site↔rules is the one edge of the graph that was modelled from the start |+| Q6 | 2026-07-26 | ~~The relationship wins where it and the string disagree, and the disagreement is diagnosed~~ — **the diagnosis half is withdrawn, see Q18.** The relationship remains the operative reference | Two representations of one fact can drift, and silently preferring either would hide a bug. The relationship is the one the app acts on, so it is the one that must be right, and the string is the evidence that says whether it is |+| Q7 | 2026-07-26 | No archive format change | The archive already references Sites by hostname and rules by id. Relationships are derived from exactly those, so an existing archive imports unchanged (Req 2.5) |+| Q8 | 2026-07-26 | Site-by-hostname selection survives only for capture of a new URL and for teaching | Those are the two moments no record identifies a Site yet, so a hostname lookup is the only thing available. Everywhere else it becomes an ambiguity the app no longer has to resolve |+| Q9 | 2026-07-27 | **Answered: yes.** Core Data mirroring heals a relationship whose target arrives later | Measured on device (iPhone 14 Pro Max, iOS 26.2): 3,000 entries seeded, app deleted, reinstalled, hydration watched across 45 samples. The nil-`site` count peaked at **2,995 of 3,000** and settled at **0** — almost the whole library existed as dangling references, and every one resolved with no app-level bookkeeping. The kill switch did not fire. `docs/investigations/cloudkit-probe.md` |+| Q10 | 2026-07-26 | ~~The migration reuses V4's sidecar-and-marker machinery~~ — **marker only, see Q12.** Still not a SwiftData custom stage | Populating a relationship from a string is a data pass, and V4 already established that such a pass cannot live in a custom stage: it would not fire between structurally similar schemas and it would run inside the share extension, which must never migrate |+| Q11 | 2026-07-27 | Only `Entry.site` and `Work.site` are modelled; cited rules resolve through the Site relationship rather than each getting their own | Entry carries **seven** citation pairs and Work an eighth, and CloudKit requires every relationship to have an inverse — so modelling them all means 8 relationships plus 8 to-many inverses (three arrays on `TitlePattern`, five on `URLRulePattern`), permanent schema surface for a resolution the Site relationship already reaches. `Site → patterns` and `Site → urlRules` are already relationships, so a cited id resolves as a lookup among the rules the Entry's own Site owns. That is what `CitedRuleResolution` was working around: not that ids are unresolvable, but that a hostname string could not say *which row* to look in. Amends Req 1.2 and 3.2 |+| Q12 | 2026-07-27 | The migration needs **no sidecar** — only the readiness-marker half of V4's machinery | Amends Q10. V4 needed a sidecar because the V3→V4 conversion dropped `titleInterpretationRaw` and `workTitleTrimRule`, so the information had to be carried across a lossy change. This conversion drops nothing (Q3), so the migration reads `hostname` from the converted store and derives the relationship from it. It is idempotent by construction — setting a relationship that is already set is a no-op — so an interrupted run resumes by running again, with no pre-allocated identifiers to reconcile |+| Q13 | 2026-07-27 | Both citation-replay throws are removed **before** the union lookup is deleted, not after | `replayRecentCandidate` throws `corruptLibrary` uncaught, and the publication guards on a *hostname* lookup rather than on `entry.site`. Changing the search space first would let a nil relationship — 2,995 of 3,000 at the probe's peak — pass the guard, find nothing, and fail all of Recent. The file's own comment warned that this call site "does not look like it needs attention when the set grows. It does." Strengthens Req 3.4 to name both paths |+| Q14 | 2026-07-27 | The app accepts markers `"4"` and `"5"`; the extension accepts only `"5"` | `openV4Container` is shared, so `ModelContainer.init` performs the lightweight conversion in whichever process opens first. **Corrected 2026-07-27:** the first draft said the locks do not serialise the two processes, which is false — `CrossProcessLibraryLock` uses `flock` `LOCK_SH`/`LOCK_EX` (`CrossProcessLibraryLock.swift:59`), so the app's exclusive lease genuinely blocks the extension for as long as it is held. The hazard is what the lease does *not* cover: it is released when `openV4ForApp` returns, after which two extension invocations can hold shared leases *concurrently* and both attempt the conversion — and the extension can be invoked when the app is not running at all, so there is no exclusive lease to wait behind. The marker check, taken before any container is constructed, is what keeps the conversion in the app. One function accepting both versions would let the extension convert the store; one demanding `"5"` would make the app throw on every library the migration exists for. The split is what actually enforces Req 2.3 |+| Q15 | 2026-07-27 | Req 2.4 rests on **atomicity** — one save, marker last — not on idempotence | Both were claimed in the first draft and they undercut each other: if the save is atomic an interruption leaves zero progress, so "resumes by running again" is really "starts over". Idempotence remains true but secondary. Batching is rejected: nothing to batch around before mirroring, and it would trade all-or-nothing for nothing |+| Q16 | 2026-07-27 | Migration resolves each hostname through `SiteResolutionOrder`, not a last-write-wins map | V4's pass built `sitesByHost[hostname] = site` over an unsorted fetch (`V4Migration.swift:71-74`); the repo already records why an unsorted selection is unsafe. Duplicate rows cannot exist at migration time, so this is about tests, fixtures and re-runs — but "arbitrary" would also falsify the determinism the milestone claims |+| Q17 | 2026-07-27 | The `Site.entries` / `Site.works` inverses are `internal`, no `entryValues` accessor is added, and a source-scan test fails on any traversal | **Corrected 2026-07-27.** The original rationale claimed access control was the enforcement. It is not: all five `Work.entries` traversals that motivated Q17 — including the two in the validator's per-Entry loop on the extension's 1 s path — are *inside* AsterismCore, where `internal` is no barrier whatever. `internal` only keeps the app and extension targets out, which was never where the mistake was going to be made. What actually protects the fan-out is the two things that work inside the package: the missing `entryValues` / `workValues` convenience accessor, so the traversal must be written out longhand, and `SiteInverseReachTests`, a deliberately grep-shaped scan of the package sources that fails when someone writes it out longhand. The `internal` declaration stays — it costs nothing and closes the app target — but it is not the guard |+| Q18 | 2026-07-27 | Req 1.4's disagreement diagnosis is **withdrawn**; Req 1.4 now only requires both halves written together | The strings are init-only and a CKRecord carries both fields in one record, so divergence is bug-only and Req 1.5 (now 1.4) already prevents it. The check would also be blind to the one corruption this milestone could introduce — right hostname, wrong row — while costing a relationship fault per Entry on a path already near budget |+| Q19 | 2026-07-27 | Req 4.4 is **withdrawn**; Site deletion semantics are a non-goal | `Site.patterns` is `.cascade`, so neither arm of the requirement was satisfiable. There is no delete-Site flow in the app, so the cascade is reachable only from tests and fixtures — and `M4PerformanceFixture` depends on it for `.siteMissing`. A requirement the code cannot meet, for a path that does not exist, is worse than none |+| Q20 | 2026-07-27 | Whether freezing V4 is necessary is **task 1**, not an assumption. **Answered 2026-07-27: it is necessary — the container refuses to open.** V4 is frozen as nested snapshots (task 2) and the live classes move to V5 | The V3 precedent does not transfer: V3 was frozen partly so the migration could read columns the conversion dropped, and nothing here reads a V4-shaped object. Probed host-side: a store written under the unmodified V4 schema (`NSStoreModelVersionIdentifiers = 4.0.0` in `Z_METADATA`), then reopened through `openV4Container`'s exact configuration after adding one property to a live class, fails at `addPersistentStore` with `NSCocoaErrorDomain` **134504 — "Cannot use staged migration with an unknown model version."**, surfacing as `SwiftDataError(_error: .loadIssueModelContainer, _explanation: nil)`. Not a silent success and not a tolerated hash mismatch: the store does not open at all. The result is the same for the real change (`Entry.site: Site?` with the `.nullify` `Site.entries` inverse) and for a bare `String?` column, so it is *any* edit to V4's body, not relationship-specific. Control: reverting the property and reopening the same store succeeds and reads its row back. So this milestone is the five-class snapshot plus the six pinned call sites, not two properties and a stage |+| Q21 | 2026-07-27 | Q11 carries a standing dependency on the mirroring spec's Site reconciliation | Resolving citations through `entry.site` is correct only while a hostname has one row. Duplicates cannot arise in this milestone, and `specs/cloudkit-mirroring/` §1 reconciles them as part of enabling sync. If that is ever dropped from the mirroring spec, this resolution breaks and Q11 must be revisited |+| Q22 | 2026-07-27 | **The V4 freeze cannot land on its own — tasks 2 and 3 are one change.** A frozen V4 is only openable alongside a V5 that is *structurally different from it*, so the freeze ships with the two relationships or not at all. Task 2 therefore inherits task 3's block on task 5 | Measured while implementing the freeze. Declaring `AsterismV5MigrationPlan` as `[V3, V4, V5]` with a `.lightweight(V4 → V5)` stage while V5 is a verbatim copy of V4 aborts the process the moment a real migration runs — a V3-recorded store — with `NSInvalidArgumentException`, *"Duplicate version checksums detected."*, thrown out of `NSLightweightMigrationStage` during `migrateStoreWithContext:`. Dropping that stage and leaving `[V3 → V4]` alone instead fails the open with `NSCocoaErrorDomain` 134504, *"Cannot use staged migration with an unknown **coordinator** model version."* — the container's own schema is V5 and no stage reaches it. Adding `Entry.site` / `Work.site` with their inverses makes V5 genuinely distinct and both errors go away: the full `V4MigrationBootstrapTests` suite (23 tests) passes, which also **answers the design's open question — a V3-recorded store does traverse V3 → V4 → V5 in a single open.** Landing the freeze early on its own would mean shipping V5 before the marker split (task 5), which is the state Q14 exists to prevent |+| Q23 | 2026-07-27 | **Confirmed on the landed change: a V3-recorded store traverses V3 → V4 → V5 in a single open.** The open question task 3's details carried ("whether a V3-recorded store traverses both stages in one open") is closed, and the answer is now recorded in design.md §"Schema V5" | `openV4Container` now carries `Schema(versionedSchema: AsterismSchemaV5.self)` and `AsterismV5MigrationPlan` (`[V3, V4, V5]`, two lightweight stages). The full `V4MigrationBootstrapTests` suite passes on it, including the V3-marker branch and the sidecar-resume branch, which are the two paths that open a store recorded at 3.0.0. No intermediate open at V4 is needed and none is performed |+| Q24 | 2026-07-27 | `AsterismV4MigrationPlan` is **deleted**, not extended with the V4 → V5 stage; `AsterismV5MigrationPlan` declaring `[V3, V4, V5]` replaces it | The design left both open. The plan had five references — `openV4Container` and four test files — all of which had to be repointed anyway. A plan that stops one version short of the live schema can no longer open the store (134504, "unknown coordinator model version"), so keeping it would leave a declaration that reads as a supported path and is not one |+| Q25 | 2026-07-27 | The frozen `AsterismSchemaV4` snapshots carry stored columns and `public init() {}` only — no computed accessors, unlike `AsterismSchemaV3`, which keeps `patternValues` | The V3 snapshot has an accessor because `V4Migration.buildSidecar` genuinely reads V3-shaped objects, for the two columns the V3 → V4 conversion drops. The V4 → V5 conversion drops nothing (Q3), so nothing reads a V4-shaped object at runtime; the snapshot exists solely to give the plan a `from` version for stores recorded at 4.0.0 |+| Q26 | 2026-07-27 | Mark-at-birth certifies an **empty** store at `"5"` directly, not `"4"` | An empty store has nothing to migrate: it is created at the current schema and is already in the state the relationship pass produces, so certifying it at `"4"` would be certifying a library as unmigrated that will never be migrated — the share extension would decline it forever, telling the reader to "open the containing app once", which is exactly what they had just done. Only the *V4-marker*, sidecar-resume and V3-marker paths stay at `"4"`; they carry a populated graph and task 11 is what republishes them. This also removes the stand-in `publishV5Readiness` calls the integration tests had grown, so the app→extension handoff is proved end-to-end again rather than simulated |+| Q27 | 2026-07-27 | The four `V4LibraryValidator` cited-rule sites are made nil-tolerant in their **own task, before** resolution narrows to `entry.site` | `:389`, `:528`, `:611` and `:697` throw when the citation does not resolve; the throw becomes a diagnosis and the diagnosis quarantines the hostname. Once resolution follows `entry.site`, every nil relationship — a state Req 2.1 explicitly permits — would quarantine its hostname, which Req 3.4 forbids by name. It is the same failure Q13 fixed for the replay paths, one layer down, and the parity tests as scoped would miss it: they compare what a call site *resolves*, and the quarantine is computed above the call site |+| Q28 | 2026-07-27 | Q15's atomicity covers the **relationship pass**, not the schema conversion | Clarifies Q15. `ModelContainer.init` converts the store to 5.0.0 on the way in, and that conversion is committed before the pass starts and is not undone by the pass failing. So the interrupted state is not "an unconverted store with no marker" — it is a store *already at 5.0.0* whose marker still reads `"4"`, with relationships partly or wholly nil. That is precisely the state the re-run must converge on, and it is what the interruption test has to construct. The open itself is not atomic and Req 2.4 must not be read as claiming it is |+| Q29 | 2026-07-27 | The write-site task must land **before or with** the task that wires the pass in, or import must reset the marker | `confirmImportReplace` rewrites the whole library through `materializeV4Payload` and publishes no marker (`LibraryRepository+BackupImport.swift:133-230`) — it requires a marker to already be there and leaves it as it found it. An import into a library marked `"5"` therefore produces a library full of records the importer wrote, with every relationship nil, and nothing ever repairs them: the pass runs once, at certification, and that already happened. Either the write sites set both halves before the marker can say `"5"`, or the import resets the marker to `"4"` so the next launch re-runs the pass |+| Q30 | 2026-07-27 | Installing a build containing the V5 schema on a physical device is **one-way**, and a container download must precede the first run | `ModelContainer.init` converts the store to 5.0.0 the first time a V5 build opens it, and no earlier build can open it afterwards — the previous build fails at `addPersistentStore` with CoreData 134504, the same error Q20 recorded from the other direction. There is no downgrade path and no in-app way back. So: download the device container (Xcode → Devices and Simulators) *before* the first run of a V5 build, not after something goes wrong. Per CLAUDE.md every physical-device run needs the reader's approval at the moment of running; this decision does not grant it and no task's existence does either |+| Q31 | 2026-07-28 | The V4-marker branch runs the relationship pass only when the marker reads `"4"`, and republishes `"5"` afterwards | The marker is the durable record of whether the pass has run. Running it on every `"5"` open would sweep every Entry and Work on every app launch, and would contradict the premise Q29 already rests on — that the pass runs once, at certification. A `"4"` marker can only mean a pre-freeze certification or a pass an interruption cut short (Q28); both are exactly the states the re-run exists for |+| Q32 | 2026-07-28 | `publishV4Readiness` is deleted with task 11 | Its only production caller was `certifyMigration`, which now runs the pass and publishes `"5"`. A function that stamps `"4"` would read as a supported certification path and is not one — a `"4"` marker can now only come from a pre-freeze build's library. Tests that need one write the marker bytes directly |+| Q33 | 2026-07-28 | The pass assigns the `SiteResolutionOrder` winner even where a relationship is already set, guarded by an identity check so the converged case dirties nothing | Skip-if-set would make the outcome depend on prior state rather than store content, and a record pinned to a row that is no longer the winner would never converge — falsifying the re-run determinism Q16 claims. The identity guard keeps the ordinary converged case a true no-op: no dirtied object, no inverse-array churn |+| Q34 | 2026-07-28 | Mixed-schema test suites must not run in parallel; `make test-core`'s `--no-parallel` is load-bearing | The pass writes `Entry.site`, whose inverse `Site.entries` exists only in V5. With suites running in parallel in one process, a concurrently open frozen-schema container (V3/V4) can win SwiftData's global entity registry for the shared entity name `Site`, and the inverse write dies with `NSUnknownKeyException: the entity Site is not key value coding-compliant for the key "entries"`. Serial runs — what the Makefile has always done — are unaffected; observed only under a hand-rolled parallel `swift test --filter` across `V4MigrationBootstrapTests` and the new V5 suites. **Recorded where it is read, 2026-07-28:** a one-line comment above the Makefile recipe and a section in `docs/agent-notes/testing.md`; a decision-log entry alone was invisible to anyone running `swift test` by hand |+| Q35 | 2026-07-28 | The pass-before-validate ordering is owned by **task 13**, not task 11 | Task 11 settled the ordering and could not pin it. The ordering is behaviourally inert until something in the validator reads `Entry.site`: with all four cited-rule sites still resolving through `CitedRuleResolution`'s union, no store exists in which swapping `V5RelationshipPass.run` and `validateV4Store` produces a different diagnosis, so a regression test would pass either way. Task 13 is the first task after which the test is constructible — a store where a nil relationship would quarantine and a populated one would not — so the obligation moves to its details rather than being written as an untestable assertion now |+| Q36 | 2026-07-28 | The readiness marker is published **before** the V3-marker and sidecar cleanup, in both certification paths | The two branches disagreed: `certifyMigration` published then cleaned up, the V4-marker branch cleaned up then published. The design review proposed unifying on cleanup-first, marker-last. Inverted after checking the crash windows. In the V4-marker branch a marker already exists and `publishV5Readiness` overwrites it, so cleanup-first is safe there — but in `certifyMigration` no V4 marker exists yet, and deleting the V3 marker and sidecar first opens a window where a crash leaves a nonempty converted store with no marker, no sidecar and no V3 marker: `openV4ForApp` then throws "unverifiable partial migration" and the library is unopenable. The V3 marker and sidecar are the *recovery evidence* for the state being left, so they go after the marker that replaces them. Q15's "marker last" means after the migration's one save, not after housekeeping; the comment now says so |+| Q37 | 2026-07-28 | `V4Migration.runCompletionPass` resolves hostnames through `SiteResolutionOrder` too | It built `sitesByHost[site.hostname] = site` last-write-wins over an unsorted fetch — the exact pattern Q16 condemns — two statements upstream of the V5 pass that exists to avoid it. Duplicate rows cannot exist when the completion pass runs (they arise only from mirroring, which ships after), so behaviour is unchanged and the existing V4 completion-pass tests stay green. This is consistency, not a bug fix: leaving the anti-pattern beside its own remedy invites the next reader to copy it |+| Q38 | 2026-07-28 | `V5RelationshipPass.run` takes an injectable `RepositorySaveStrategy`, defaulted to `context.save()` | Its one save was a bare `context.save()`, so the `libraryUnavailable("running the relationship migration pass")` branches in both callers had no coverage at all. Both callers already carry a `saveStrategy` — the bootstrap threads one through `openV4ForApp` and `certifyMigration` — so threading it one level further costs nothing, changes no public API (the pass is internal), and makes "a failing save aborts the open and leaves the marker at `\"4\"`" a test rather than an assertion |+| Q39 | 2026-07-28 | The nil-Site citation tolerance is confined to the **tolerant** store-level pass; `validateStrict` and `validateEntryTuple` are unchanged | Decision 3 keeps the three backup import gates strict, and an archive that cannot resolve its own citations must still be refused. `validateEntryTuple` is stricter still: it validates the tuple a commit is *about to write*, and Req 1.4 makes a write site set both halves in the same save, so an unlinked citation there is a bug in the writer rather than a state to survive |+| Q40 | 2026-07-28 | `validateExtractionReplay`'s two "cannot resolve its retained rule" throws tolerate on the same terms as the four cited-rule sites | It is not one of the four, but it replays a reference `requiredReference` has just tolerated. Left alone it would re-throw one call later and quarantine the hostname anyway, undoing the demotion for every Entry carrying a URL extraction — which is most of them |+| Q41 | 2026-07-28 | Task 18 sets the relationship at **every** Entry/Work construction site in the package, not only the three its details name. **Amended 2026-07-28: six of the seven, not seven — `V2MigrationStore` is carved out, see Q45** | Req 1.4 is unconditional — writing a record that names a Site sets both halves in the same save — and the details named `capture`, create-Work and `materializeV4Payload` because those are the ones Q29 and Decision 2 were arguing about. Seven more exist: `moveEntry`'s `.newWork`, `+ReparseCapture`'s three (the re-parse Work creation, the lookup-first capture Entry, and `applyCaptureAssignment`'s `.create`), `+ComposedTeaching.applyComposedOutcome`, `+Contracts`' teaching-commit Work creation, and `V2MigrationStore`'s materialization. Leaving any of them would make the answer to "when can a `"5"` library carry a nil relationship?" longer than Decision 2's one line, and task 19 depends on it directly: the M4 performance fixture's 1,000 Works are created by `applyComposedOutcome`, so the fixture cannot set `work.site` without it. Every one resolves its row through `fetchSites`, except `moveEntry`, which follows the Entry's own row instead (Q44) |+| Q42 | 2026-07-28 | The fixtures that seed underneath the validating commit path link their records by **running `V5RelationshipPass`** after their save, rather than assigning inline — **except a fixture that builds duplicate Site rows with split citation ownership, which must assign inline (Decision 4)** | `ToleratedStateFixture` and eight seeding test helpers publish a `"5"` marker over a store they built themselves, so the graph has to look as though certification ran. Running the pass is what makes that true by construction: it resolves each hostname through `SiteResolutionOrder`, so `.duplicateSiteRows` pins its Entry to the same winner a capture would, and it needs no exception for `.siteMissing` — those Entries are on a hostname with no Site row, so the pass leaves them nil, which is the state the kind exists to model. It must run *after* the seed's save: a temporary `PersistentIdentifier` has no defined order (`IdentityResolution.swift:225-229`), so resolving before the save could pick either duplicate row. The carve-out is narrow and checked: of the three suites seeding a duplicated hostname, only `CitedPatternResolutionTests` gives each row its own rules and its own citing records — `IdentityLookupToleranceTests` and `RefreshUnionInvariantTests` seed bare duplicate rows whose Entries cite nothing, so the winner is the only answer there and the pass is right for them |+| Q43 | 2026-07-28 | `M2`/`M3`/`M4PerformanceFixture` assign the relationship inline, not through the pass | Each holds its `Site` rows in hand while it builds — one row per hostname in M2, a single row in M3 and M4 — so there is no winner to resolve and an inline assignment is the shorter, more obvious statement. M4's 1,000 Works are created by `applyComposedOutcome` during its phase 2 composed commit, which sets the relationship itself since task 18; M4's `.siteMissing` tolerated state deletes the taught row and the `.nullify` inverses take all 5,000 relationships to nil, which is exactly what that state models. The two app-target helpers publishing `"5"` (`CrossViewRefreshTests`, `IntegrationSafetyNetTests`) seed an *empty* store, so they carry no records at all — mark-at-birth's own shape (Q26) — and are annotated rather than changed |+| Q44 | 2026-07-28 | `moveEntry`'s `.newWork` puts the new Work on **`entry.site`** — the Entry's own row — falling back to the `fetchSites` winner only when that relationship is nil | It read `fetchSites(...).first` while holding an `entry` whose `.site` was already pinned. The winner is content-dependent and flips as unrelated teaching lands (the `library-integrity-tolerance` finding), so on a duplicated hostname the moved Entry and the Work created *for that Entry* could end up on different rows — and it charged a fetch to a path that previously had none. No `entry.site === work.site` validator invariant is added this round: tasks 15 and 16 are the first reads to follow `work.site`, so whether the disagreement needs diagnosing (and what it would be diagnosed as, given Decision 3 closed the tolerated set) belongs to them. Recorded here as an open question for those tasks rather than as a rule with no reader |+| Q45 | 2026-07-28 | `V2MigrationStore` is the **one** Entry/Work construction site that deliberately sets no relationship | Amends Q41, which swept it in with the rest. Everything it writes goes into a container built from `Schema(versionedSchema: AsterismSchemaV2.self)`, and `create` refuses to run unless the destination does not exist — so the only store it ever touches is one it has just written itself and immediately reads back through `readSnapshot`, which is hostname-keyed. The V2 store is a separate file from the V4/V5 store and nothing carries a V2-written relationship forward, so the assignment was dead data in a store recorded at 2.0.0. Reverted, with the reasoning written at the type rather than only here — the next sweep for "every write site" will find this one and needs the answer in front of it |+| Q46 | 2026-07-28 | **Open, deferred to a future spec:** `AsterismSchemaV2` stamps version 2.0.0 over the *live* (now V5) model classes, so a store it writes carries a 2.0.0 identifier and a V5-shaped model | Q20 measured the same aliasing from the other direction: a store recorded under one schema version cannot be reopened once that version's class bodies change (CoreData 134504). `AsterismSchemaV2` has always aliased the live classes, and `openCurrent` opens the V2 runtime store the same way, so the question "can a genuinely 2.0.0-recorded store still open after V5?" is live — but it is about the V1→V2 developer migration path, not about this milestone's references, and freezing V2 is the same five-class exercise tasks 1–3 did for V4. Flagged here so it is not rediscovered as a surprise |+| Q47 | 2026-07-28 | The export/import round trip is **not** asymmetric for a record whose relationship is nil while its hostname still has a Site row | Raised as a possible defect: the tolerant open path emits no `.siteMissing` for such a record (its hostname *does* have a row), so it exports — and `validateStrict` at the import gate does not tolerate. Checked and it does not bite: `validateImportPlanPayloadV4` runs `materializeV4Payload` *before* `validateStrict`, and materialization re-derives every relationship from the archive's hostnames (Q7), so the imported graph is linked whatever the exporting store held. Nor is the state reachable from the app: Site deletion is a design non-goal (Q19) with no in-app flow, `M4PerformanceFixture`'s `.siteMissing` guards on exactly one Site row so its deletion leaves the hostname with none, and after task 18 a `"5"` library carries a nil relationship only where no row carries the hostname (Decision 2). What can produce it is CloudKit `.nullify` against a surviving duplicate row, which is the mirroring spec's problem and its Site reconciliation's answer (Q21) |+| Q48 | 2026-07-28 | Reads do not guard `entry.site === work.site`; each read follows its own record's relationship | Answers the question Q44 left open for task 15. A divergence is unreachable from the app's own writes — Q44 pins `moveEntry`'s new Work to the Entry's own row, and every other write site resolves both halves through `fetchSites` in one transaction — so it can arise only from moveEntry-era data predating Q44 or from sync, and duplicated rows under sync are the mirroring spec's Site reconciliation (Q21). A read-side guard would charge a cross-record fault to Entry detail and the Work URL basis for a state with no local producer, and the divergent shape already has a visible signal: a record pinned to a row that does not own its citations is diagnosed (Decision 4) |+| Q49 | 2026-07-28 | `projectReparse` (+ReparseCapture) follows **`entry.site`** | Task 16, call 1 of 5. Reached from an existing Entry, so the re-parse basis must come from the row whose rules the Entry's fields were produced against — not whichever row currently wins the hostname. A nil relationship reads as an untaught hostname and takes the same `invalidInput` refusal it always took; refusing an action is not among the things Req 3.4 forbids a nil relationship to cause. **Scope, recorded 2026-07-28:** the Site is record-scoped, the candidate Works are not — `allWorks` is gathered by `siteHostname` (`:55-56`), deliberately. Work identity is a hostname-level fact (`Work.siteHostname` is what every Work lookup keys on and no Work is owned by a Site row), so narrowing reuse to the Site's own Works would create a second Work for a title the hostname already has whenever two rows exist |+| Q50 | 2026-07-28 | `commitReparse` (+ReparseCapture) follows **`entry.site`** | Task 16, call 2 of 5. The same entry-shaped question at commit time, so the commit rebuilds its basis from the same row the projection read — and the Works it creates are assigned to the Entry's own row, extending Q44's rule (a Work created *for* an Entry lands on that Entry's row) to the re-parse path. **Scope, recorded 2026-07-28:** as in Q49, reuse is hostname-scoped (`allWorks` by `siteHostname`, `:113-114`) while the Site is record-scoped. So an existing Work is reused across rows and only a *newly created* Work is pinned to `entry.site`; that is the same asymmetry `moveEntry` settled in Q44, not an oversight |+| Q51 | 2026-07-28 | `commitCapture`'s insert-if-absent check (+ReparseCapture) **keeps the hostname lookup** | Task 16, call 3 of 5. The Entry being captured does not exist yet, so no record identifies a Site; the call asks whether the hostname has *any* row before inserting one — capture of a new URL, the first of Q8's two preserved moments (Req 3.3) |+| Q52 | 2026-07-28 | `commitCapture`'s rule-application lookup (+ReparseCapture) **keeps the hostname lookup** | Task 16, call 4 of 5. Applying rules to a new capture is the genuine winner question: two rows can own conflicting current rules and one winner is the honest answer. The new Entry is then assigned to that winner in the same transaction (task 18), so the tuple validation resolves the cited ids within the row that owns them |+| Q53 | 2026-07-28 | `buildCaptureBasis` (+ReparseCapture) **keeps the hostname lookup** | Task 16, call 5 of 5. A capture is projected from a raw URL before any Entry exists — hostname is all there is to resolve, the same moment as Q51 seen from the projection side (Req 3.3) |+| Q54 | 2026-07-28 | The Merge basis keeps its hostname lookup, and the comment saying "no Work model is in hand" is **wrong and replaced** | `buildMergeBasis` fetches both Works two lines above the comment, so the stated ground was false. The real one: a merge spans two Works that may point at different rows, and neither relationship is privileged over the other, so there is no record-shaped answer to take — the question is which teaching governs the merge, which is hostname-shaped. On a duplicated hostname the surface is refused upstream anyway: `commitMerge` sees the `.duplicateSiteRows` quarantine and returns `.invalidated`, so the winner only ever governs a basis being *shown*. `buildWorkURLBasis` has one Work in hand and reads `work.site` (Decision 5) |+| Q55 | 2026-07-28 | `LibraryRepository.titlePattern(id:)` is **deleted**, with its `LibraryProviding` requirement and `TitlePatternSnapshot` | It resolved a cited id globally — no Site scoping, no version test — under a doc comment forbidding the reader to narrow it, citing Decision 9 of `specs/library-integrity-tolerance`, whose union half this milestone replaced. Investigated before touching it: **no production caller anywhere**. The app target never calls it, `MockLibraryProvider` implemented it only to satisfy the protocol, and its two callers were tests — one pinning the very global lookup Req 3.2 removes, one using it as a stand-in for `RecordResolutionOrder.sortedPatterns` (rewritten to call that directly). Narrowing it would have meant designing a resolution for a caller that does not exist; leaving the Decision 9 argument standing would have left the deleted rule reading as current |+| Q56 | 2026-07-28 | `validateExtractionReplay` resolves its retained rule among the **citing record's own Site's** rules, not in the library-global winners index | The last cited-rule read still going through a graph-wide index, and the one residual path Decision 3 left open: an Entry with a nil relationship whose cited rule id existed anywhere in the store still replayed against that rule, and quarantined its hostname when the replay disagreed — the outcome Req 3.4 forbids for exactly that state, one call after `requiredReference` had tolerated the same reference (Q40). On a duplicate-id graph the global winner could also disagree with what `resolves()` accepted two lines earlier. Narrowed to the same source `resolves()`/`citedPattern()` use, which made the `rules:` parameter dead all the way up: out of `validateExtractionReplay`, out of the per-Entry `validate`, out of the public `validateEntryTuple` (its one production caller passed `site.urlRuleValues`, the assigned row's own array, so behaviour is unchanged), and the graph-wide `winners` index and `retainedRule` helper are deleted |+| Q57 | 2026-07-28 | Req 5.3's number is measured as `V4LibraryValidator.validate(context:)` alone, with a **fresh container and context per sample** and the container open outside the timer | `openV4ForExtension` is container open + validate + counts, so it bounds the path but cannot say how much of it is the validator — and Req 5.3 asks about the validator specifically. Reusing one context across samples would leave every to-one relationship faulted and every rule array materialised, which is the cost being measured. Measured this way it comes to 0.762–0.773 s against the extension path's 0.766–0.776 s: validation is ~99% of that open, so the two numbers agree and the existing budget carries over intact |+| Q58 | 2026-07-28 | The migration measurement joins `make test-performance-m4` rather than getting a target of its own, and the target's runtime goes from ~6 to ~30 minutes | One opt-in performance command is the thing a maintainer already knows to run; a second would be the one nobody remembers. The cost is real and irreducible — each sample is ~17 s of migration plus a ~13 s reset that must be committed and reopened to be a pre-pass graph at all — so it is written into the Makefile comment rather than hidden |+| Q59 | 2026-07-28 | The entries-only / works-only split of the pass is **recorded, never asserted** | Req 2.6 bounds the whole pass; half of it is not a requirement and a budget invented for it would be a number nobody agreed to. It exists so the breach below has a measured cause rather than a hypothesis: 5,000 Entries into one inverse array cost 14.2× what 1,000 Works cost, over a 5× record-count ratio |+| Q60 | 2026-07-28 | Decision 6's breach is **accepted by the requirement's owner**; no optimisation, no device measurement, no batching | The real library is under 200 notes on a single phone. At the measured ~*n*^1.65 growth, a 200-Entry pass costs on the order of 0.1 s — the breach exists only at the fixture's 5,000-Entry worst case, a scale this library has no path to. The known-issue-plus-ceiling construction stays as the record and the regression guard |+| Q61 | 2026-07-28 | Recent and Entry detail treat a **nil-site pattern-provenance Entry identically**: not applicable, rendered healthy, Teach available. `replayRecentCandidate` is deleted and the applicability predicate lives in one shared helper both surfaces call | Decision 5's third negative consequence made explicit rather than left implicit. An Entry with a nil relationship on a hostname that *does* have a Site row cites nothing that can fail to resolve, so there is no unresolved citation to report — its provenance is simply absent, and teaching the hostname is the repair the pill already offers. The pre-push review found the two surfaces disagreeing on exactly that state: Recent flagged `citationUnresolved` while Entry detail rendered healthy, which is the same "same Entry, two answers" defect Decision 5 was written to close, one field over. Two call sites answering an applicability question separately is what allowed the divergence, so the predicate becomes one helper and the surfaces cannot drift again |++---++## Decision 1: Model the Three String References as Relationships++**Date**: 2026-07-26+**Status**: accepted++### Context++This decision was taken conditionally on the Q9 probe, which passed on+2026-07-27: Core Data does heal a relationship whose target arrives later, so the+premise the whole milestone rests on holds.++`Entry.hostname` and `Work.siteHostname` are strings, and an Entry cites the rules that produced its fields as `(UUID, version)` pairs. The design doc names this as the reason M4 had to be split at all: *"`Entry.hostname` and `Work.siteHostname` are plain strings, not modelled relationships, so CloudKit cannot preserve the ordering the store-level validator assumes."*++The cost is visible in the code that exists to compensate. `SiteResolutionOrder` (231 lines) picks a winner among Site rows sharing a hostname, by a five-step total order whose own documentation concedes that steps 1–2 do not help the case CloudKit most often produces. `CitedRuleResolution` (72 lines) exists because a rule id an Entry cites may be owned by a Site row that did not win, so it searches the union instead — and its file comment explains that merging the two resolutions reintroduces a bug. The store-level validator throws when a cited pattern does not resolve, which quarantines the hostname, which disables rule application on capture and blocks backup export.++None of that machinery would exist if the reference were a relationship. A relationship whose target is absent is nil; when the target arrives, Core Data forms the association. There is nothing to search, no winner to select, and no way to confuse "not here yet" with "never existed" — because the reference points at a record, not at a name that several records might answer to.++### Decision++Add optional, inverse-bearing relationships from Entry and Work to Site, and from Entry to each title pattern and URL rule it cites. Keep every existing string and version integer. Resolution follows the relationship; hostname lookup survives only where no record identifies a Site yet.++### Rationale++The distinction that matters is not string-versus-pointer, it is **whether the persistence layer knows the reference exists**. It does for a relationship, so it can resolve it, defer it, and heal it. It does not for a string, so the application has to do all three by hand — and the application cannot, because no local signal distinguishes an absent record from an unarrived one.++Keeping the strings is what makes this affordable. They remain the archive's reference format, so no codec changes; they remain capture-time evidence, so §2.6's immutability principle is untouched; and they make the relationship derived data, so the migration is re-runnable rather than destructive.++Scheduling it before mirroring is the part with the shortest justification and the largest consequence. Schema surgery on a local single-device store is ordinary work. The same surgery on a mirrored store runs on every device, produces writes that propagate, and must be correct while other devices change the data underneath it.++### Alternatives Considered++- **Leave the strings and build the pending-reference machinery**: what the mirroring spec originally specified - Rejected because it is more code that does worse: a taxonomy, notification-driven re-evaluation, and a rule for when pending becomes damage — a rule that cannot be written, since no timeout is defensible.+- **Replace the strings with relationships**: cleaner model - Rejected because the strings are capture-time evidence, are what the archive references, and are what makes the migration re-runnable.+- **Model only Entry→Site, leave the rule citations as ids**: smaller - Rejected because rule citations are the reference that fails most often. Every re-teach on one device leaves the other citing a pattern whose replacement has not arrived; that is the recurring case, not the first-sync one.+- **Do it after mirroring, once real sync behaviour is known** - Rejected on the sequencing argument above. There is no version of this that gets cheaper by waiting.++### Consequences++**Positive:**+- `CitedRuleResolution` becomes removable, and `SiteResolutionOrder` shrinks to the two moments in Q8.+- The mirroring spec loses its pending-reference taxonomy, its re-evaluation machinery, and — together with reconciling duplicate Sites — its entire archive format change.+- An absent target stops being able to quarantine a hostname, which is what disables capture parsing and backup export today.+- Duplicate Site rows stop being able to misdirect a record that already points at one.++**Negative:**+- A schema version and a data migration over the whole library, on a store holding real notes. That is the risk this milestone is, and it is not small.+- Two representations of one fact, which can drift. Req 1.4 and Q6 make drift visible rather than pretending it cannot happen.+- A to-many inverse on Site (`Site.entries`) that could hold thousands of rows. Faulting it must be avoided on hot paths, and Req 5.1 exists to catch it if it is not.+- ~~The whole milestone rests on the Q9 probe.~~ Settled 2026-07-27: it heals. What the probe also showed is that the hazard is *severe* — 2,995 of 3,000 entries were dangling at the peak of one ordinary hydration — so the string-keyed alternative is worse than this decision assumed, not better.++### Impact++`Models.swift` and a new versioned schema; the migration bootstrap in `LibraryRepository+V4Bootstrap`; `LibraryRepository.fetchSites` and its ~20 call sites; `SiteResolutionOrder`; `CitedRuleResolution`; `V4LibraryValidator`; the capture, teaching, re-parse, work-merge, and URL-identity write paths; `materializeV4Payload` in the import path.++---++## Decision 2: Write Sites Land Before Any Read Follows the Relationship++**Date**: 2026-07-28+**Status**: accepted++### Context++Q29 set a constraint: task 18 — setting `Entry.site` and `Work.site` at every write site — must land **before or with** task 11, which wires the relationship pass into the certification paths. It did not. Task 11 shipped first, so the constraint was violated on the branch rather than in a hypothetical future.++The state this leaves at HEAD is concrete. `confirmImportReplace` and `confirmImportFillEmpty` rewrite the library through `materializeV4Payload` (`LibraryRepository+BackupImportV4.swift:44`), which builds a `sitesByHostname` map for the *rules* and never touches `Entry.site` or `Work.site`. Both entry points require the readiness marker to already exist and neither republishes it, so an import into a library marked `"5"` produces a full library of records with every relationship nil — and nothing repairs it, because `openV4ForApp`'s V4-marker branch runs the pass only when the marker reads `"4"` (Q31). The pass ran once, at certification, and that already happened.++Today this is inert: every reader still resolves through hostname strings and `CitedRuleResolution`'s union, so a nil relationship changes nothing anyone observes. It stops being inert at task 14, when cited-rule resolution starts following `entry.site`. At that point an imported library silently loses every citation it carries — permanent, user-visible damage to a library the reader restored from their own backup, which is exactly the moment they are least able to absorb it.++### Decision++Write sites land before any read follows the relationship. Expressed as dependencies rather than prose: task 18 now blocks task 12 (resolution-parity tests) and task 14 (resolve through the record's Site). The graph reads 17 → 18 → {12, 14}; task 13 stays independent and may proceed at any time.++No repair machinery is added: no marker reset on import, no import-side invocation of the pass.++### Rationale++Nothing has shipped. The `"5"`-with-nil-relationships state is reachable only on this branch, between task 11 and task 18, and only through a code path whose output nothing yet reads. Once task 18 lands, the state becomes *unreachable* — every write site sets both halves, so a `"5"` library can carry a nil relationship for exactly one reason: a hostname matching no `Site` row. That is the tolerated state Req 2.1 names and this milestone exists to make survivable.++Repair machinery would therefore be machinery for a state that ordering makes impossible. It would also have to be maintained, tested, and reasoned about at every future certification path — permanent cost for a transient branch-local hazard.++The reordering also answers a question the milestone had left open: *when may a `"5"` library carry a nil relationship?* After task 18, the answer is closed and short — only when no `Site` row carries the record's hostname.++### Alternatives Considered++- **Reset the marker to `"4"` on import, and revive `publishV4Readiness`**: the import stamps `"4"`, and the next app launch re-runs the pass over the imported graph — Rejected. It adds a repair path for a state that reordering makes unreachable, and it reopens Q32, which deleted `publishV4Readiness` precisely because a function stamping `"4"` reads as a supported certification path and is not one. It also makes every import cost a full-library sweep on the next launch.+- **Invoke the pass from the import path**: `confirmImportReplace` runs `V5RelationshipPass.run` after `materializeV4Payload` — Rejected for the same reason, plus a worse one: the import already holds an exclusive lease and an open context, so the pass would run inside a transaction it was not designed for, and the milestone would then have two places that populate relationships instead of one.+- **Fix `materializeV4Payload` immediately, outside task 18**: land the import half now and leave the rest of task 18 for later — Rejected because it splits one coherent change across two commits for no benefit; task 18 is small and its blocking test task (17) is already written to cover the import site.++### Consequences++**Positive:**+- No new machinery: no marker reset, no second populate path, no repair pass.+- The question "when can a `"5"` library carry nil relationships?" is closed. After task 18 the only answer is a hostname matching no `Site` row — the tolerated state.+- The dependency graph now enforces what Q29 could only assert. A future agent reordering the work runs into `rune`, not into a paragraph.++**Negative:**+- The Resolution phase is serialized behind the Write sites phase. Tasks 12 and 14 cannot start until 17 and 18 are done, which lengthens the critical path.+- The branch is left, until task 18 lands, in a state where the import path is known-broken-in-waiting. That is recorded here and in task 18's details rather than fixed on the spot.++### Impact++`specs/relational-references/tasks.md` (dependencies on tasks 12 and 14; details on 18); `LibraryRepository+BackupImportV4.materializeV4Payload` and both `LibraryRepository+BackupImport` entry points, which task 18 changes; `V5CertificationPathTests`, whose already-migrated test is re-scoped to pin "an ordinary open does not re-run the pass" rather than "no repair path exists".++---++## Decision 3: A Nil Relationship Demotes the Resolution Clause, Not the Whole Check++**Date**: 2026-07-28+**Status**: accepted++### Context++The four cited-rule sites in `V4LibraryValidator` — `:389` (Work rule identity),+`:528` (v3 identity name contributor), `:611` (pattern chapter provenance) and+`:697` (`requiredReference`) — throw when a citation does not resolve. The throw+becomes a per-Site diagnosis and the diagnosis quarantines the hostname, which+disables rule application on capture and blocks backup export.++Task 14 narrows resolution from `CitedRuleResolution`'s union to the record's own+`entry.site` / `work.site`. A nil relationship is a state Req 2.1 explicitly+permits, and after that change every record carrying one would fail to resolve+every citation it holds — quarantining its hostname for exactly the state this+milestone exists to make survivable. Req 3.4 forbids it by name: a nil+relationship shall not quarantine a hostname, fail a screen, or prevent export.++Q27 made this its own task, before task 14 rather than with it, because the+parity tests planned for task 12 compare what a *call site* resolves and a+quarantine is computed a layer above them.++### Decision++At each of the four sites, only the **resolution clause** is demoted. A citation+that fails to resolve is a tuple failure when the citing record points at a+Site, and a tolerated no-op when it does not. Every other clause of every guard —+a nonblank `.rule` identity, a complete `(id, version)` reference, each arm of+the closed tuple table, the v3 name replay against a contributor that *did*+resolve — is untouched and keeps failing closed.++No new diagnosis case is introduced. A tolerated citation produces silence from+the validator; the tolerated *diagnosis* for a record with no Site is the+existing `.siteMissing`, which the store-level loop already emits for exactly the+hostnames that carry no Site row.++### Rationale++The alternative shape — "a nil relationship short-circuits citation validation+outright, before the citation is even looked at" — is the same function as this+one *after* task 14, because at that point a nil relationship makes every+citation unresolvable anyway. The two differ only while the union lookup is still+in place, and there the failure-conditioned form is strictly smaller: it changes+behaviour only for records whose citation was going to be diagnosed regardless,+so it is the safe no-op today that Q27 and Decision 2 both assume task 13 to be.+The short-circuit form would instead stop validating the tuples of every citing+record in the library until tasks 18 and 19 populated the relationships,+silently weakening every suite in between.++Emitting a *new* diagnosis for a nil relationship was rejected on Req 3.4's own+wording. `.siteMissing` is the only tolerated case that fits, and it feeds+`unresolvedRecordCount`, which `backupV4Snapshot` refuses on — so emitting it for+a record whose hostname *does* have a Site row would "prevent export" for a state+the requirement says must not. It would also disagree with `LibraryToleranceScan`,+which derives `.siteMissing` from hostnames alone and would drop the diagnosis on+the next foreground refresh. After task 18 the question is moot in the other+direction: Decision 2 closes the answer to "when may a `"5"` library carry a nil+relationship?" at "only when no Site row carries the record's hostname", which is+precisely when the loop already emits `.siteMissing`.++### Alternatives Considered++- **Short-circuit on nil before evaluating the citation**: skip the cited-rule+  checks entirely whenever the relationship is nil — Rejected as above: identical+  after task 14, materially more disruptive before it, and it discards tuple+  validation for records the union can still resolve perfectly well.+- **Emit a new tolerated diagnosis case for an unlinked citation**: extend+  `LibraryDiagnosis` — Rejected. Decision 4 closed that set deliberately, the new+  case would have to be taught to `LibraryToleranceScan` to survive a refresh,+  and it describes a state that becomes unreachable at task 18.+- **Reuse `.siteMissing` from the four sites**: no new case, and honest from the+  record's point of view — Rejected because it blocks export (Req 3.4) and+  flaps against the scan.+- **Apply the tolerance in `validateStrict` too**: one code path, no strictness+  parameter — Rejected: the import gates must keep refusing an archive that+  cannot resolve its own citations (Decision 3 of M4a, Q39).++### Consequences++**Positive:**+- Task 14 can narrow resolution to `entry.site` without any nil relationship+  reaching a quarantine.+- The closed tuple table is unchanged for every record that points at a Site,+  which is every record the app writes after task 18.+- The change is inert over today's graphs: `make test-core` is green with no+  fixture edits, so it carries no risk into the write-site and fixture tasks.++**Negative:**+- The tolerance is expressed as "on failure, if unlinked" rather than as a+  positive rule, so reading it requires knowing that after task 14 an unlinked+  record fails every citation by construction. The `CitationContext` doc comment+  is where that is written down.+- A record with a nil relationship and a genuinely corrupt citation is+  indistinguishable from one whose Site simply never arrived. That is inherent —+  with no Site there is nothing to replay the citation against — and it is what+  Req 3.4 asks for.++### Impact++`V4LibraryValidator` (the four sites, `validateExtractionReplay` per Q40, and the+new `CitationContext` / `resolves(citedRule:…)` / `citedPattern(…)` helpers);+`V4ValidatorNilSiteToleranceTests`; the Q35 ordering regression test in+`V5CertificationPathTests`, which asserts that the diagnostics an open publishes+describe the **post-pass** graph — pass-first diagnoses an unresolvable citation+against the now-populated relationship, validate-first tolerates it against a nil+one, and reversing the two calls in the V4-marker branch fails the test.++---++## Decision 4: The Hostname-Winner Pass Is Not a Fixture Builder for Split-Ownership Duplicates++**Date**: 2026-07-28+**Status**: accepted++### Context++Q42 made the seeding fixtures link their records by running `V5RelationshipPass`+after their save, so a store carrying a `"5"` marker would hold the graph+certification actually leaves behind. That is right for eight of the nine, and+wrong for the ninth.++`CitedResolutionFixture` (`CitedPatternResolutionTests.swift`) builds the one shape the+pass cannot express: **two taught Site rows on `dup.example`, each owning its own+title pattern and URL rule, each with its own Work and Entry citing the rules of+the row that owns them.** The pass resolves the hostname to a single+`SiteResolutionOrder` winner and assigns *both* Entries to it — so the losing+row's Entry ends up pointing at a row that does not own a single id it cites.++Today that is invisible: every reader still resolves citations through+`CitedRuleResolution`'s union, which searches every row for the hostname. At+task 14 it stops being invisible. The suite's two central tests — the losing+row's citations replay clean, and they keep replaying after a committed change+flips the winner — would fail, and the design's claim that "a record's own+relationship is a fixed pointer, which is precisely the stability that comment+demands" would be false in the repository's own fixture.++### Decision++The hostname-winner pass is valid only where a hostname's records' citations are+owned by the winner or by no row at all — which is every graph the pass can meet+in production. Fixtures that construct duplicate rows with **split citation+ownership** therefore model the sync-shaped graph instead: `entry.site` and+`work.site` are assigned inline, per record, to the row that owns that record's+citations. They do not run the pass. Q42's row carries the carve-out.++The union suite is **converted** at task 14, not deleted, and must still pass.++### Rationale++The pass never faces a duplicated hostname with split citation ownership in+production, for two independent reasons. At migration time duplicate rows cannot+exist at all (Q16, M4a Decision 3) — they arise only from mirroring, which ships+after this milestone. After mirroring, a record's relationship arrives as a+*synced pointer to its originating row* (Q9, Decision 1): Core Data forms the+association from the CKRecord's reference, not by resolving the hostname string.+Hostname resolution is what the relationships exist to replace, so the one thing+that could produce a winner-pinned record on a split-ownership hostname is the+pass itself, running over a graph only a fixture can build.++Assigning inline is also the more honest fixture. The Q42 comment says the seeded+graph "must look as though certification ran"; for this fixture what it must look+like is a *mirrored* library, because that is the only way two rows with separate+rule ownership come to exist. The inline assignment is that graph, written out.++Keeping the union suite through task 14 is what makes the conversion checkable.+Its two properties — the losing row's citations resolve, and a committed winner+flip changes nothing — are exactly the properties the `library-integrity-tolerance`+milestone added the union for. If the relationship form cannot hold them, the+milestone is a regression, and the suite is where that shows up.++### Alternatives Considered++- **Keep the union lookup for citations, over `entry.site.hostname`**: leave+  `CitedRuleResolution` in service and just narrow the hostname to the one the+  relationship names — Rejected. Req 3.2 asks resolution to search only the rules+  the citing record's Site owns, and Decision 1 counts `CitedRuleResolution`'s+  removal among the things this milestone is *for*. It would also keep the+  duplicate-row ambiguity alive at every citation site, one indirection further+  down.+- **Accept winner-pinning and delete the union suite at task 14**: treat the two+  tests as artefacts of the string era — Rejected. They encode the fix+  `library-integrity-tolerance` made for duplicated hostnames: provenance replay+  must not come and go as unrelated teaching flips the winner. Deleting them+  removes the only place that regression would be caught, and the relational form+  is supposed to make the property *easier* to hold, not moot.+- **Make the pass citation-aware**: have it prefer the row owning a record's+  cited ids and fall back to the winner — Rejected. It is machinery for a graph+  the pass never meets in production, it would need its own tie-break rules when+  a record's citations span rows, and it would make the pass's output depend on+  citation content rather than on hostname, which is the property Q16 and Q33+  spent two decisions pinning down.++### Consequences++**Positive:**+- The fixture stops asserting a state the milestone will make wrong, so task 14+  converts the suite rather than deleting or "fixing" it under pressure.+- The pass keeps one rule — resolve the hostname through `SiteResolutionOrder` —+  with no content-dependent special case.+- The carve-out is narrow and was checked rather than assumed: of the three+  suites seeding a duplicated hostname, the other two (`IdentityLookupTolerance`,+  `RefreshUnionInvariant`) seed bare rows whose Entries cite nothing, so the+  winner is the only available answer and Q42 stands for them.++**Negative:**+- Two ways to link a fixture's graph, chosen by a property of the fixture. Q42's+  row and the fixture's own comment carry the rule; a fixture that grows split+  ownership later would have to notice.+- A record whose `site` points at a row that does not own its citations remains a+  **diagnosed** state after task 14 — the citation fails to resolve and, with the+  relationship present, Decision 3's tolerance does not apply. That is the+  correct signal rather than a defect: it says the pointer is wrong, which is+  exactly what it would mean.++### Impact++`CitedPatternResolutionTests.CitedResolutionFixture` (inline assignment, no pass); Q42's+row; task 12's and task 14's details, which now say the union suite converts;+the design's "fixed pointer" paragraph, which had to be qualified.++---++## Decision 5: Presentation Follows the Hostname Winner; Provenance Follows the Relationship++**Date**: 2026-07-28+**Status**: accepted++### Context++Task 15 converted `entryTeachingDetail` wholesale to `entry.site`: not only the+citation replay, but the Site mode, the active and historical pattern summaries,+the display title, `hasCurrentURLRule` and the available actions. Recent was left+as it was — it resolves each row's Site through `SiteResolutionOrder` over a+hostname-keyed map (`+RecentPresentation.swift:58-61`), because a per-Entry+relationship read there would fault 5,000 to-one relationships on a path with a+2 s budget.++The result is two screens disagreeing about the same Entry. On a hostname with+two Site rows, Recent renders the row whose teaching currently governs the+hostname while Entry detail renders whichever row that Entry happens to point at:+different mode, different title cleaning, different actions, no explanation+offered to the reader for why opening a row changes what it says. The conversion+also introduced a new failure: `entryTeachingDetail` throws `corruptLibrary` when+the *Entry's own* row holds an illegal tuple, even where the winner is legal and+Recent shows the hostname as healthy.++Underneath the inconsistency is a question the milestone had not separated. "Which+record did this Entry's evidence come from" and "what does this hostname teach"+are different questions, and Req 3.1 as written answered both with the+relationship.++### Decision++Presentation follows the hostname winner; provenance follows the relationship.++Site mode, the pattern summaries, the display title, `hasCurrentURLRule` and the+available actions in Entry detail resolve `fetchSites(hostname:)` again, as+Recent does. The citation replay — the assignment settlement, the unresolved+candidate title — keeps following `entry.site`, as does `buildWorkURLBasis`+through `work.site` and every cited-rule site in the validator.++### Rationale++The two questions have different natural scopes, and the app already enforces+that.++Site mode, title cleaning and action availability are **teaching** questions, and+teaching refuses a duplicated hostname at every entry point:+`buildComposedTeachingBasis` returns `.quarantined`, and both Recent and Entry+detail withhold their actions for exactly that reason. So "what does this+hostname's teaching say" has one answer per hostname, and the winner rule is how+the app names it. Two surfaces reading the same hostname must not name it+differently.++Citation resolution is a **record** question: an id an Entry recorded, owned by+exactly one row, and the record points at that row. The winner is+content-dependent — a teaching commit on either row flips it — so routing+provenance through it made an Entry's replay resolve, then fail, then resolve+again as unrelated teaching landed. That is the defect the+`library-integrity-tolerance` union existed for and this milestone replaced with a+fixed pointer.++Req 4.3 — "Entry detail's provenance disclosure SHALL show the same facts it+shows today" — needs no amendment once this lands: the disclosure half is exactly+what stays on `entry.site`, and it shows the same facts it always did. Req 3.1+and 3.3 do need amending, because as written they claimed the whole surface for+the relationship.++### Alternatives Considered++- **Convert Recent's per-Entry Site to `record.site` as well**: one rule, both+  screens relational — Rejected on two grounds. It charges 5,000 to-one+  relationship faults to a path with a 2 s budget (Req 5.1), and it makes+  presentation *diverge per row* on a duplicated hostname: two Entries on one+  hostname would render with different modes and different pills in the same+  list, for a difference the reader cannot see or act on.+- **Leave Entry detail relational and Recent winner-shaped**: the state the+  review caught — Rejected. It is not a design, it is an inconsistency: the same+  Entry says different things depending on which screen is asked, and neither+  screen can explain why.+- **Keep the whole surface relational and add a divergence diagnosis**: detect+  `entry.site !== winner` and surface it — Rejected. Q48 already refused a+  read-side `entry.site === work.site` guard for a state with no local producer,+  and this is the same shape one step out: it would charge a cross-record read to+  every detail open in order to describe a state only sync can create, which the+  mirroring spec's Site reconciliation owns (Q21).++### Consequences++**Positive:**+- Recent and Entry detail agree about the same Entry, by construction, because+  both resolve presentation the same way.+- A record's provenance disclosure survives a winner flip: the replay follows a+  fixed pointer, which is what the union was added for and what the relationship+  now provides more cheaply.+- The `corruptLibrary` throw for an illegal tuple on a non-winning row is gone+  again — that path is winner-shaped, as it was before task 15.++**Negative:**+- Two resolutions live in one function, and a future reader has to know which+  half a value belongs to. The two-search comment in `entryTeachingDetail` and+  the pointer at `+RecentPresentation.swift:54` are where that is written down.+- An Entry with a nil relationship on a hostname that *does* have a Site row+  renders healthy — presented from the winner, with a Teach pill — while its own+  provenance is absent. That is the honest rendering: it cites nothing that can+  fail to resolve, and teaching the hostname is the repair the pill offers.+- On a duplicated hostname a record can point at a row that is not the one+  presented. That remains diagnosed through citation resolution (Decision 4)+  rather than through a read-side guard.++### Impact++`LibraryRepository+EntryDetail.entryTeachingDetail` (presentation half reverted,+provenance half unchanged); `LibraryRepository+RecentPresentation` (comment only);+Req 3.1 and 3.3, amended; task 15's details; `EntryDetailAndMergeToleranceTests`,+which gains the discriminating test — two rows teaching different active+patterns, the Entry pinned to the loser, both halves asserted.++---++## Decision 6: Req 2.6's Breach Is Recorded, Not Fixed++**Date**: 2026-07-28+**Status**: accepted++### Context++Task 21 measured the V4 → V5 relationship pass over the 5,000-Entry composed+fixture, in release on the host, with the graph stripped and the store reopened+before each timed sample. The median across five runs is **17.31 – 17.75 s**+against Req [2.6](requirements.md#2.6)'s **10 s** budget — 1.75×, with a+run-to-run spread of ≤ 1.03×. The whole certification open (pass, then+`validateV4Store`, then the `"5"` marker) is 17.94 – 18.40 s over four runs, so+validation contributes ~0.6–0.9 s and the pass is essentially the entire+interval.++The cause is the one the design named. Splitting the pass by record type+measures 5,000 `entry.site` assignments into one `Site.entries` at 16.55–17.00 s+and 1,000 `work.site` assignments into one `Site.works` at 1.17–1.20 s: a+14.17–14.24× cost ratio over a 5× record-count ratio, i.e. cost growing as roughly *n*^1.65 in the+size of the inverse array being maintained. The M4 fixture is a **single** Site+carrying all 5,000 Entries, which the design chose deliberately as the worst+shape available for this pass.++The one change that would obviously bring the number down is batching the save.+Q15 already rejected batching, and Req [2.4](requirements.md#2.4)'s+all-or-nothing guarantee rests on the single save it rejected batching to keep.++### Decision++Record the breach with its numbers and leave the pass as it is. Req 2.6's+assertion is wrapped in `withKnownIssue` and paired with a regression ceiling+(22 s for the pass, 23 s for the certification open) asserted outside the+known-issue block. Do not batch, do not raise the budget, do not reshape the+fixture.++### Rationale++A measurement task is not a licence to change the thing measured. Batching is+not a tuning knob here — it is the trade Req 2.4 is built on, and reopening it+is a decision for the requirement's owner with the number in hand, which is+precisely what this entry puts there.++The known-issue-plus-ceiling construction is the one Decision 11 of+`specs/library-integrity-tolerance` arrived at for the same situation on Req 5.5.+`withKnownIssue` alone would swallow a failure at 17 s and at 170 s alike, which+is the "assert nothing and record the number" property that decision rejected;+the ceiling refuses a run that has drifted into being a *new* problem while+leaving the recorded one recorded. Letting the suite simply fail was the other+option, and it makes `make test-performance-m4` red for everyone, which trains+people to stop reading it.++Two things the number is *not* evidence for. It is a **host** measurement of a+write-and-save workload, and Req 2.6's protocol is the physical-device protocol;+the only calibration point in the repository (`recentPresentation`: 0.713 s host+against 0.305 s device) is a read workload, so "≈ 7.5 s on device, therefore+fine" is an inference and is written down as one. And the single-Site fixture is+the worst case by construction: at *n*^1.65, the same 5,000 Entries spread over+~40 hostnames would cost roughly an order of magnitude less. Neither of those+makes the breach go away against the shape the requirement is measured on.++### Alternatives Considered++- **Batch the save** (commit every *k* records): would cut the interval, and+  destroys the atomicity Req 2.4 rests on and Q15 preserved. Rejected here on+  scope as well as on merit — reopening Q15 is a spec decision, not a+  measurement-task edit.+- **Raise Req 2.6's budget to fit the measurement**: fits the requirement to the+  implementation and deletes the signal. If 17.4 s is acceptable, that is a+  decision someone makes about what a reader waits through, not a number+  quietly rewritten.+- **Spread the fixture across hostnames** so the inverse arrays stay short:+  would pass, and would measure a shape the design explicitly refused. Task 21+  names this by name: "do not 'fix' the fixture to spread the Entries across+  hostnames in order to meet the number."+- **Let the assertion fail every run**: honest, and turns a standing known+  breach into noise that hides the next real regression. The ceiling keeps the+  discrimination the plain failure would lose.+- **Assign relationships without maintaining the inverse** (drop `Site.entries` /+  `Site.works`): not available — CloudKit requires every relationship to declare+  an inverse (Req 1.3), which is why those arrays exist at all (Q17).++### Consequences++**Positive:**++- Req 2.6 has a measured answer, a measured cause, and a bounded regression+  check, instead of an untested budget.+- The atomicity Req 2.4 depends on is untouched.+- A future change that makes the migration materially slower still fails the+  suite, at the ceiling.++**Negative:**++- A published requirement is knowingly unmet on the host, and `make+  test-performance-m4` reports a known issue on every run.+- Whether it is met on a device is unknown and cannot be settled from this+  repository as it stands: the `AsterismCore` package target is in no scheme's+  test action (Decision 10 of `library-integrity-tolerance`), so measuring the+  migration on device needs a signpost around the certification path and a UI+  test to drive it — neither of which exists, and neither of which any task here+  authorises.+- The ceilings are absolute durations measured on one M1 Max. On a materially+  slower machine they would fail for the machine's reasons rather than the+  code's.++### Impact++`M4MigrationScalePerformanceTests` (the whole suite); Req 2.6, unmet and+recorded as such in `implementation.md`; Q15, whose rejection of batching is now+a live question rather than a settled one; the migration path itself, unchanged.++---
specs/relational-references/design.md Added +186 / -0
diff --git a/specs/relational-references/design.md b/specs/relational-references/design.mdnew file mode 100644index 0000000..2ee75a5--- /dev/null+++ b/specs/relational-references/design.md@@ -0,0 +1,186 @@+# Design: Relational References++## Overview++Add `Entry.site` and `Work.site` as modelled relationships alongside the existing `hostname` / `siteHostname` strings, migrate the existing library to populate them, and resolve a record's Site by following its relationship rather than picking a winner among rows sharing a hostname. Cited rules resolve through the Site the record points at (Q11).++## Architecture++### Schema V5++`AsterismSchemaV4` currently declares the *live* model classes — `Models.swift:16` opens `extension AsterismSchemaV4 {`, so the live 432-line file **is** V4's body. Adding properties there redefines V4 in place.++**What that actually breaks is not established, and it sets this milestone's size.** The V3 precedent proves less than it appears to: V3 was frozen partly because the migration must *read the dropped columns* (`V4Migration.swift:40` fetches `AsterismSchemaV3.Site` for `titleInterpretationRaw`), and nothing here needs to read a V4-shaped object. So task 1 is to determine the observed failure — container refuses to open, opens with a mismatched hash, or silently succeeds — and only then commit to the freeze. The design below assumes the freeze is required; if it is not, V5 collapses to adding two properties and a stage.++**Corrected 2026-07-28: the freeze is required, and it landed.** Task 1 probed it host-side and the answer is the harshest of the three: the container refuses to open. A store written under the unmodified V4 schema, reopened through `openV4Container`'s exact configuration after adding a single property to a live class, fails at `addPersistentStore` with `NSCocoaErrorDomain` **134504 — "Cannot use staged migration with an unknown model version."** Not a tolerated hash mismatch and not a silent success. The result is the same for a bare `String?` column as for the real change, so it is *any* edit to V4's body, not something relationship-specific (Q20). So this milestone is the five-class snapshot plus the pinned call sites below, not two properties and a stage.++**And the freeze could not land on its own.** A frozen V4 is only openable beside a V5 that is *structurally different from it*: declaring the V5 plan while V5 was a verbatim copy of V4 aborted the first real migration with `NSInvalidArgumentException`, *"Duplicate version checksums detected."*, and dropping that stage failed the open with 134504 again, this time *"unknown coordinator model version"*. Adding `Entry.site` / `Work.site` with their inverses makes V5 genuinely distinct and both errors go away, so tasks 2 and 3 shipped as one change (Q22). That also closed the open question task 3's details carried: **a V3-recorded store traverses V3 → V4 → V5 in a single open**, with no intermediate open at V4 and none performed (Q22, Q23).++Frozen, V4 becomes nested snapshot classes with the same entity names and top-level typealiases, as `AsterismSchemaV3` is. Everything that pins the V4 schema then has to move with it:++| Site | Change |+|---|---|+| `Models.swift` | Live classes move to a V5 extension; a frozen V4 snapshot of all five is added |+| `LibraryRepository+BackupImportV4.swift:18` | Builds its in-memory validation container from `Schema(versionedSchema: AsterismSchemaV4.self)` while `materializeV4Payload` inserts **live** classes. After a freeze those are different entities — this mismatches at runtime and must move to V5 |+| `openV4Container` (`V4Bootstrap.swift:238`) | Carries the schema and plan; `public` for the app's UI-test bootstrap |+| `AsterismV4MigrationPlan` | **Deleted, not extended** (Q24). `AsterismV5MigrationPlan` declaring `[V3, V4, V5]` replaces it; a plan stopping one version short of the live schema can no longer open the store at all (134504), so keeping it would leave a declaration that reads as a supported path and is not one |+| `V4ValidatorToleranceTests`, `LibraryToleranceScanTests`, `BackupImportTransactionTests`, `IdentityResolutionTests` | Pin `AsterismSchemaV4.self` / `AsterismV4MigrationPlan.self` directly |++| Model | New | Delete rule | Why |+|---|---|---|---|+| `Entry.site: Site?` | relationship | — | The reference under test |+| `Work.site: Site?` | relationship | — | Same, for Works |+| `Site.entries: [Entry]?` | inverse of `Entry.site` | `.nullify` | Required by CloudKit; deleting a Site must never take notes with it |+| `Site.works: [Work]?` | inverse of `Work.site` | `.nullify` | Same |++Nothing is removed. The hostnames, cited ids and version integers all stay (Q3). `Site.patterns` and `Site.urlRules` keep `.cascade` — unchanged, and now a stated non-goal.++### The two-process marker contract++This governs whether the share extension can convert the store behind the app's back.++`openV4Container` is shared by both processes, so **`ModelContainer.init` performs the lightweight conversion in whichever process opens first**. The extension takes only a *shared* lock (`V4Bootstrap.swift:201`) where the app takes exclusive.++The locks are not the hole they were first described as. `CrossProcessLibraryLock` is `flock` with `LOCK_SH` / `LOCK_EX` (`CrossProcessLibraryLock.swift:59`), so a shared lease genuinely blocks while the app holds its exclusive one — an extension invocation *during* `openV4ForApp` waits. What the lease does not cover is everything after it: it is released when `openV4ForApp` returns, and from that moment two extension invocations can hold shared leases **concurrently** and both attempt the conversion. The extension can also be invoked when the app is not running at all, so there may be no exclusive lease to wait behind in the first place. Locking answers "not while the app is migrating"; it does not answer "not in the extension".++The defence is therefore the existing §3.2 contract: the extension compares the marker *before constructing the container*. That only works if the two processes read the marker differently:++| Process | Accepts | On a `"4"` marker |+|---|---|---|+| App | `"4"` or `"5"` | Runs the relationship pass, then republishes `"5"` |+| Extension | `"5"` only | Declines with the shipped "open the app once" message (Req 2.3) |++So `validateV4MarkerContent` splits into an app-side accepted-set check and an extension-side exact-version check. Keeping one function that accepts `{4, 5}` for both would let the extension open an unmigrated store and trigger the conversion under a shared lock; keeping one that demands `"5"` would make the app throw on every library this migration exists for.++### The relationship pass++Runs in `openV4ForApp` under the exclusive lock, app-only. The reason is *not* the one `AsterismSchemaV4.swift:29` gives — "a custom stage does not fire between structurally-similar schemas" stops applying once V4 is a distinct frozen schema. The reason that still holds is the second one: a custom stage would also run inside the share extension, which must never migrate.++**Row selection must be deterministic and must be the same rule the rest of the app uses.** The V4 pass built `sitesByHost[site.hostname] = site` over an unsorted `FetchDescriptor<Site>()` (`V4Migration.swift:71-74`) — last-write-wins over an arbitrary order. `LibraryRepository.swift:950-956` already records why that shape is unsafe. The V5 pass resolves each hostname through `SiteResolutionOrder` instead, so a record is pinned to the row the app itself would have chosen.++Duplicate Site rows **cannot exist when this runs** — they arise only from mirroring, which ships after (M4a Decision 3). The deterministic rule is therefore about tests, fixtures, and any future re-run, not about a state a real library can be in at migration time.++**Req 2.4 rests on atomicity — of the pass, not of the open (Q28).** One save at the end, marker published last: an interruption leaves the pass with no partial commit, and the next launch starts it over. The *schema conversion* is not covered by that: `ModelContainer.init` commits the store to 5.0.0 on the way in, before the pass begins, and a failed pass does not undo it. So the state an interruption actually leaves is a store already recorded at 5.0.0 whose marker still reads `"4"` — which is the state the re-run must converge on, and the one the interruption test has to construct. The pass is also idempotent — setting a relationship that already holds is a no-op — but that is a secondary property, not what the requirement leans on. Nothing is batched: this runs before mirroring, so there is no export history or rate limit to batch around, and batching would trade the all-or-nothing property for nothing.++**Three entry paths reach certification, and all three must run the pass**:++| Path | Today | Must become |+|---|---|---|+| V4 marker present (`:89`) | validate, open | **run pass**, validate, publish `"5"`, open |+| V3 marker present (`:135`) | sidecar → `certifyMigration` → publish | …→ V4 completion pass → **V5 pass** → publish `"5"` |+| Sidecar resume (`:103`) | `certifyMigration` | same as above |++`certifyMigration` publishes readiness at `:321`. Left alone it would stamp `"5"` on a library whose relationships were never populated — an M3-era library upgrading in one launch would certify with every relationship nil.++A **fourth** path publishes readiness and is deliberately not in the table: mark-at-birth for an empty store (`:179`). It publishes `"5"` directly and runs no pass, because an empty store has nothing to populate and is already in the state the pass produces (Q26). It must stay that way — marking it `"4"` would leave the share extension declining a library that is never going to be migrated.++In the V4-marker branch the pass has to run **before** `validateV4Store`. That branch validates first today, and diagnostics feed the session's quarantine map; running the pass afterwards would open the library on a map computed from the pre-pass graph, where every relationship is still nil.++The pass necessarily populates `Site.entries`: setting `entry.site` 5,000 times maintains the inverse array. **Corrected 2026-07-28:** an earlier draft said this fans out across "~40 Sites". It does not — the 5,000-Entry composed fixture is a *single* Site (`M4PerformanceFixture.m4FixtureHostname`, 1,000 Works × 5 chapters on one hostname), so all 5,000 assignments append to one inverse array. That is the worst shape for inverse maintenance, not an average one, and it is what Req 2.6's 10 s budget is measured against (task 21). This is the one place the traversal is unavoidable, and it remains the main unknown in that budget.++### Resolution++`LibraryRepository.fetchSites(hostname:context:)` is the single funnel — **19 call sites** as the design was written; see the correction under the table for what landed. It stays: Q8/Req 3.3 keeps hostname lookup wherever no record identifies a Site yet.++| File:symbol | Calls | Becomes | Rationale |+|---|---|---|---|+| `+ComposedTeaching` | 3 | lookup | Teaching starts from a hostname |+| `+Contracts.buildTeachingBasis` | 1 | lookup | Builds the basis *from* a hostname |+| `+Contracts` teaching-commit check | 1 | lookup | Validates the post-commit row set |+| `+Articles` | 1 | lookup | Teaching action on a hostname |+| `+URLIdentity` | 1 | lookup | URL-rule teaching |+| `LibraryRepository.siteStatus(forRawURL:)` | 1 | lookup | No Entry exists yet |+| `LibraryRepository.capture` | 1 | lookup | Creates the Entry — **write site** |+| `LibraryRepository` create-Work | 1 | lookup | Creates the Work, and the Site when absent — **write site** |+| `+Capture.swift:135` | 1 | lookup | Re-share identity candidates; on Req 5.2's **100 ms** budget |+| `+WorkMerge` (hostname-shaped) | 1 | lookup | Operates on a hostname |+| `+EntryDetail` | 1 | **both** | Amended by Decision 5: the presentation half (mode, summaries, display title, actions) keeps the winner lookup, so Recent and Entry detail agree; the citation replay follows `entry.site` |+| `+WorkMerge` (`work.siteHostname`) | 1 | `work.site` | A Work is in hand |+| `+ReparseCapture` | 5 | **per call** | Re-parse of an existing Entry follows the relationship; capture-shaped calls keep the lookup. The task list must name each |++`fetchSites` keeps 12 callers, loses 2, and 5 are split.++**Corrected 2026-07-28: the arithmetic above did not survive contact, and a caller count is not the thing to hold the design to.** Read the classification instead — which *questions* are hostname-shaped and which are record-shaped — because that is what did survive.++What actually landed:++- **Three calls left**, not two. `+ReparseCapture`'s `projectReparse` and `commitReparse` follow `entry.site`, because a re-parse must replay against the row whose rules produced the Entry's fields rather than whichever row currently wins (Q49, Q50); `+WorkMerge`'s `work.siteHostname` read follows `work.site` in `buildWorkURLBasis`, which has one Work in hand (Decision 5). `+ReparseCapture`'s other three calls are capture-shaped and keep the lookup (Q51–Q53), and `+WorkMerge`'s merge basis keeps its own (Q54) — a merge spans two Works that may point at different rows, so there is no record-shaped answer to take.+- **Two callers the table never listed.** `+ComposedTeaching.applyComposedOutcome` and `moveEntry`'s `.newWork` both resolved a hostname while holding a record. `moveEntry` now puts the new Work on `entry.site` — the Entry's own row — and falls back to the winner only where that relationship is nil (Q44), which is the general rule for a Work created *for* an Entry. `applyComposedOutcome` is a teaching commit that already knows its row.+- **`+EntryDetail` is a split, not a conversion** (Decision 5): the presentation half keeps the winner lookup so Recent and Entry detail agree, while the citation replay follows `entry.site`.++The residual callers are all hostname-shaped by the Q8/Req 3.3 rule, and several of them are the same find-or-insert question asked from different entry points — capture, create-Work, and `+ReparseCapture`'s insert-if-absent all ask "does this hostname have a row, and if not, make one". Consolidating those behind a single write-side helper moves the count without moving the classification, which is why the count is not the contract.++`SiteResolutionOrder` stays in service; Req 3.5's narrowing is about records that already point at a row, not about retiring the winner rule.++**Write sites (Req 1.4).** Both halves in the same save, or the app's own writes leave the relationship unset:++| Site | Sets |+|---|---|+| `LibraryRepository.capture` | `entry.hostname` → also `entry.site` |+| `LibraryRepository` create-Work | `work.siteHostname` → also `work.site`; it inserts a `Site` when none exists, and the new Work must point at *that* row |+| `materializeV4Payload` | Already builds `sitesByHostname`; wire both relationships from it (Req 2.5) |+| `M2`/`M3`/`M4PerformanceFixture` | Must set relationships, or the scale suites measure a graph the app never produces |+| `ToleratedStateFixture` | **Except `.siteMissing`**, whose correct state is a nil relationship — that kind exists to model an Entry whose Site is absent, which is now the central case |++One ordering constraint: `+ReparseCapture.swift:260-263` may insert a Site moments before validating in the same transaction, and `SiteResolutionOrder` deliberately sorts a temporary `PersistentIdentifier` **last** (`IdentityResolution.swift:225-229`), so `siteRows.first` is the pre-existing row. A new Entry must be assigned before validation, and to the row the lookup returned — not to whichever was inserted most recently.++### Citation replay: fix the throw, then delete the union++`CitedRuleResolution` (8 call sites: `V4LibraryValidator` ×4, `+RecentPresentation` ×1, `+ReparseCapture` ×2, `+EntryDetail` ×1) exists because a hostname string cannot say *which row* owns a cited rule. With `entry.site` fixed, a citation resolves within that row's own rules, version included:++```swift+entry.site?.patternValues.first { $0.id == citedID && $0.version == citedVersion }+```++The version test is not optional — every existing call site performs it and Req 4.2 requires it.++**Deleting the union is unsafe until a throw is fixed first.** `LibraryRepository+RecentPresentation.swift:299-318` — `replayRecentCandidate` **throws `corruptLibrary` and the throw is not caught**; it propagates out of the whole publication. The file says so itself, and names what has been holding it up:++> *"currently safe: … `CitedRuleResolution` searches every row for the hostname, so a duplicated Site cannot hide a cited pattern. … It is a sharp edge for whoever widens the tolerated set. This call site does not look like it needs attention when the set grows. It does."*++The publication guards on a **hostname** lookup (`:106`), not on `entry.site`. Change the search space to `entry.site?.patternValues` and a nil relationship — 2,995 of 3,000 at the probe's peak — passes the guard, finds an empty array, and fails all of Recent. That is Req 3.4 broken on the main screen by the milestone that exists to make nil survivable.++So the order is fixed: **both replay paths stop throwing before the union is removed.** An unresolvable citation returns the same "needs attention" rendering the milestone uses everywhere else, at `+RecentPresentation.swift:299` and the equivalent in `+EntryDetail.swift:46`. Only then does the search space change — for the *replay*, which is the citation half. What each screen presents about the hostname stays on the winner (Decision 5).++The file comment's other warning — never replace the union with the *winner* — remains sound and is not what this does. The winner is content-dependent and flips as unrelated teaching lands; a record's own relationship is a fixed pointer, which is precisely the stability that comment demands.++**Fixed after certification, not before it (Decision 4).** The relationship pass *is* the winner rule, applied once: until it has run, a record's pointer is whatever the pass most recently resolved, and a re-run re-resolves it (Q33). That is safe because duplicate rows cannot exist while the pass runs for real — at migration time they cannot exist at all (Q16), and after mirroring a relationship arrives as a synced pointer to its originating row rather than through hostname resolution (Q9, Decision 1). So the pass never meets a duplicated hostname whose rows own separate rules. A fixture that builds one must assign inline, per record, to the row owning that record's citations; running the pass over it would pin both records to the winner and falsify the sentence above.++**Q11 carries a standing dependency.** Resolving through `entry.site` is correct only while a hostname has one row. Duplicate rows cannot exist in this milestone, and `specs/cloudkit-mirroring/` §1 reconciles them silently as part of enabling sync. If that reconciliation is ever dropped from the mirroring spec, this resolution breaks and Q11 must be revisited.++### Keeping the `Site.entries` inverse out of reach++Both inverses exist only to satisfy CloudKit's requirement that every relationship has one (Req 1.3). Traversing `Site.entries` faults every Entry for a hostname, which the budgets in Req 5.1 would pay for.++The reason to bother is `Work.entries`: same shape — optional to-many, `.nullify` — and traversed at `V4LibraryValidator.swift:401` and `:422` (the latter inside a per-Entry loop on the extension's 1 s path), `BackupV4Exporter.swift:136`, and `LibraryRepository.swift:798`, `:1001`. `Site.entries` has roughly 125× the fan-out.++**Access control is not the guard, and the first draft was wrong to say it was.** Every one of those five `Work.entries` traversals is *inside* AsterismCore, which is where the equivalent mistake would be made for `Site.entries` — and `internal` is no barrier inside the module that declares it. Declaring both inverses `internal` while `Site` stays `public` is still worth doing, because it closes the app and extension targets for free, but it does not touch the case that matters.++Two things do:++- **No convenience accessor.** Do not add an `entryValues` / `workValues` twin beside the existing `patternValues` / `urlRuleValues` (`Models.swift:202-203`). The traversal then has to be written out longhand, optional and all.+- **A source-scan test.** `SiteInverseReachTests` walks the package's own sources and fails on any member access whose receiver chain names a site — `site.entries`, `entry.site?.entries`, `siteRows.first?.works` — while leaving `work.entries` alone. It is a grep with a compiler around it and says so; the point is that it fires inside the module, where `internal` does not.++## Error Handling++| Condition | Behaviour |+|---|---|+| Relationship pass fails mid-run | No marker published, no partial commit; next launch starts over (Req 2.4) |+| A record's hostname matches no Site row at migration | Relationship left nil (Req 2.1) — the state M4a made survivable, not an error |+| Citation does not resolve within the record's Site | Rendered as needing attention; **never thrown** (Req 3.4) |+| Extension finds a `"4"` marker | Declines to open, existing message (Req 2.3) |+| V5 marker with no store, or nonempty unmarked store | Unchanged from V4 — fails closed |++## Testing Strategy++**Migration.** Losslessness over the 5,000-Entry fixture: every field value, provenance tuple and timestamp preserved, per-type counts unchanged (Req 2.2). Note this test cannot catch a wrong-row assignment — counts and tuples are identical either way — so it is paired with an assertion that each record's cited rules resolve *within the Site it was assigned*. Interruption: a store left unmarked converges on the next run and is never certified partway (Req 2.4). Determinism: two runs over a fixture carrying duplicate rows for one hostname pin records to the same row.++Property-based testing fits losslessness and determinism — both are universal claims over a larger input space than examples cover. Swift Testing's `@Test(arguments:)` over generated graph shapes is what the project already has; adding a PBT dependency is not justified by this milestone alone.++**The replay fix comes with its own tests, before the union is removed**: Recent publishes with entries whose cited pattern does not resolve, and Entry detail renders the same, both without throwing. These are regression tests for the blocker above and should be written first.++**Resolution parity** — for each of the 8 `CitedRuleResolution` call sites, the relationship form resolves the same record the union form did, including the superseded-pattern case (Req 4.1) and the version mismatch case (Req 4.2).++**Scale** — Req 5.1's two budgets, Req 5.2's 100 ms capture budget (which `+Capture.swift:135` sits on), and Req 5.3's validation measurement, with sync quiesced. Req 5.3 wants the direction of change stated, so the test reports the number rather than only asserting a bound.++**Archive** — a 4/4 archive imports and its relationships derive from the hostnames and ids it carries (Req 2.5). The codecs do not change; this tests `materializeV4Payload` wiring, not the format.
specs/relational-references/implementation.md Added +254 / -0
diff --git a/specs/relational-references/implementation.md b/specs/relational-references/implementation.mdnew file mode 100644index 0000000..327c084--- /dev/null+++ b/specs/relational-references/implementation.md@@ -0,0 +1,254 @@+# Implementation: Relational References++Branch `feature/relational-references` against `origin/main`.++---++## Task 20 — The scale budgets after resolution became relational (Req 5.1, 5.2, 5.3)++**Date:** 2026-07-28++### Environment++| | |+|---|---|+| Host | Apple M1 Max, 32 GB; macOS 26.5.1 |+| Configuration | `release` (`-O`, `wholemodule`), `-Xswiftc -DASTERISM_PERFORMANCE_TESTING` |+| Command | `make test-performance-m4 [RUNS=n] PERFORMANCE_LOG=…` |+| Statistic | 20 samples per read-path measurement after a warm-up. Median asserted every run, p95 asserted only under `CONTROLLED=1` and reported always (Decision 10 of `library-integrity-tolerance`) |+| Runs | Five complete host runs, reported as bands. **Do not quote a single run.** |++The pre-change baselines quoted throughout come from+`specs/library-integrity-tolerance/implementation.md` (tasks 34 and 36), recorded+on **the same machine with the same command**. That is what makes a before/after+comparison possible at all: these numbers are "comparable to a later run of the+same command on the same machine, and to nothing else".++### Headline++Every host-measurable budget in Req 5.1, 5.2 and 5.3 holds, with the headroom it+had before the milestone. **Req 5.3's direction of change is: no measurable+change** — the small rise the raw numbers show is present in the same size on a+path that reads no relationship at all (below).++Req 2.6 is the exception, and it is not on this task: the migration breaches its+10 s budget at ~17.6 s. See [task 21](#task-21--the-migration-against-its-10-s-budget-req-26).++### Measured — the paths this milestone changed++Median bands across five runs, against the pre-change bands recorded on the same+host.++| Measurement | Budget | Before (union / strings) | After (relationships) | Change |+|---|---|---|---|---|+| **extension open + validate** | **1 s** | 0.7505 – 0.7569 s | **0.7589 – 0.7877 s** | +0.3% – +4.1% |+| **store-level validation alone** | 1 s (that path's budget) | not separately measured | **0.7618 – 0.7875 s** | — (new measurement, Q57) |+| open + validate, duplicate Site rows | 1 s | 0.7591 – 0.7643 s | 0.7652 – 0.7854 s | +0.1% – +3.4% |+| open + validate, `.siteMissing` | 1 s | 0.3625 – 0.3679 s | 0.3620 – 0.3708 s | ≈ 0 |+| open + validate, `.duplicateIdentity` | 1 s | 0.7448 – 0.7672 s | 0.7677 – 0.7876 s | +0.1% – +5.7% |+| **Recent publication (host)** | 2 s | 0.6859 – 0.7128 s | **0.7157 – 0.7396 s** | +0.4% – +7.8% |+| Recent publication, duplicate Site rows | 2 s | 0.6978 – 0.7146 s | 0.7141 – 0.7416 s | −0.1% – +6.3% |+| **capture rule application** | **100 ms** | 0.069 – 0.072 ms | **0.074 – 0.076 ms** | +3% – +10% (of 0.07 **milli**seconds) |+| capture projection, duplicate Site rows | 100 ms | 0.0572 – 0.0586 s | 0.0593 – 0.0621 s | +1.2% – +8.6% |+| capture projection, `.siteMissing` | 100 ms | 0.0647 – 0.0659 s | 0.0641 – 0.0671 s | ≈ 0 |+| capture projection, `.duplicateIdentity` | 100 ms | 0.0639 – 0.0664 s | 0.0686 – 0.0709 s | +3.3% – +11.0% |+| complete preview, expanded | 1 s | 0.0772 – 0.0778 s | 0.0763 – 0.0797 s | ≈ 0 |+| complete preview, title-only | 1 s | 0.0284 – 0.0293 s | 0.0285 – 0.0295 s | ≈ 0 |+| edit acknowledgement, expanded | 100 ms | 0.017 – 0.018 ms | 0.017 ms | ≈ 0 |+| edit acknowledgement, title-only | 100 ms | 0.006 ms | 0.006 ms | ≈ 0 |+| **diagnosis refresh, foreground** — *control* | 250 ms ❌ | 0.2721 – 0.2784 s | 0.2826 – 0.2921 s | **+1.5% – +7.4%** |+| diagnosis refresh, after a write — *control* | 250 ms ❌ | 0.2683 – 0.2770 s | 0.2811 – 0.2924 s | +1.5% – +9.0% |+| diagnosis refresh, duplicate Site rows — *control* | 250 ms ❌ | 0.2714 – 0.2783 s | 0.2831 – 0.2925 s | +1.7% – +7.8% |++Req 5.1's second budget (Recent **publish-to-interactive**) has a device number,+not a host one — see *Device-pending*. The host row above measures+`recentPresentation`, the interval the device signpost wraps, and is comparable+to the earlier host measurement rather than to the 0.305 s device baseline.++The Req 5.5 diagnosis-refresh rows are marked ❌ because that budget was already+breached before this milestone (Decision 11 of `library-integrity-tolerance`) and+still is. They are listed here as the **control**, not as a result: see below.++### Req 5.3 — did following relationships make validation faster or slower?++**Neither, within what this host can resolve. The change is not measurable.**++The raw numbers say the extension open path is 0.3–4.1% slower than the+pre-change band. Three things say that is the machine and not the code:++1. **The control moved at least as much.** `LibraryToleranceScan`'s diagnosis+   refresh enumerates ~6,000 rows reading two scalar columns; it reads no+   relationship, and nothing in this milestone touches it. It rose+   **+1.5% – +7.4%** over the same interval — *more* than the open path. Machine+   drift across the five runs is visible directly: nearly every measurement in the+   suite rose from the first run to the last, relationship-reading or not.+2. **Normalised against that control**, the open path got marginally *faster*:+   `extension-open / diagnosis-refresh-foreground` was ≈ 2.74 before and+   2.66 – 2.73 after. The two workloads are not the same, so this is a coarse+   normalisation and not a precise one — but it does not point at a regression.+3. **The `.siteMissing` path is unchanged** (0.3620–0.3708 s against+   0.3625–0.3679 s). That fixture has 5,000 Entries with a nil `entry.site`, so+   it is exactly the graph where the validator's new unconditional relationship+   read happens 5,000 times and resolves to nothing. If reading `entry.site` per+   record cost anything visible, it would show here first. It does not.++**Why the feared cost did not appear.** The design flagged two: a to-one fault+per record (`work.site` at `V4LibraryValidator` ~:445 and `entry.site` at ~:495,+both unconditional), and a linear scan of the citing Site's `urlRuleValues` /+`patternValues` per citation instead of a dictionary hit. Neither is on the+critical path in the M4 fixture's shape. The relationships are faulted as part of+the same fetch batch the validator already pays for, and the composed fixture's+one Site owns exactly one active title pattern and one URL rule — so the "linear+scan" the briefing warned about is a scan of a two-element array. The remedy held+in reserve (index the citing Site's rules once per record instead of per+reference) was **not applied**: there is nothing measurable for it to remove, and+it would trade a real simplification for a hypothetical gain.++**Where the time actually goes**: `store-level-validation` (0.7618–0.7875 s) is+~99% of `extension-open-and-validate` (0.7589–0.7877 s) measured in the same+runs. Container open and `v3Counts` together are under 1% of that path. Whatever+optimisation the open path ever needs belongs in `validate(graph:)`, and within+it in the per-Entry title-derivation replay that `.siteMissing` skips — that+skip alone halves the number.++**Req 5.3 verdict: not slower.** The budget already recorded for that path (1 s,+~24% headroom) is intact, at 0.762–0.788 s, with ~21–24% headroom.++### Host-measurable, and device-pending++**Measured here (host, release):** extension open-and-validate; store-level+validation; Recent publication as `recentPresentation`; capture rule application+and the whole capture projection; the composed-preview budgets; diagnosis+refresh; and, under task 21, the relationship pass and the certification open.++**Not measured, and not measurable from this task.** These need a physical+iPhone, and a device run needs the user's approval at the time of running+(`CLAUDE.md`). None was run:++| Pending measurement | Budget | Harness | What it needs |+|---|---|---|---|+| **Recent publish-to-interactive, on device** | Req 5.1, 2 s | **Exists**: `M4ScaleRecentPerformanceUITests` via `make test-performance-m4-recent` (`XCTOSSignpostMetric(RecentPublication)`, `Personal` configuration) | Only an approved device run. Pre-change baseline for comparison: **0.305 s ±1.59%** on iPhone 17 Pro, recorded 2026-07-26 |+| **Extension open-and-validate, on device** | Req 5.1, 1 s | **Does not exist**: `M4ScalePerformanceTests` is an `AsterismCore` package test, and that target is in no scheme's test action, so it cannot run on device at all (Decision 10 of `library-integrity-tolerance`) | A harness first — a signpost around the extension open and a UI test to drive it — then an approved device run |+| **Migration, on device** | Req 2.6, 10 s | **Does not exist**, same reason | A signpost around the certification path and a UI test to drive it, then an approved device run. This is the one that matters most, because the host number breaches — see task 21 |++The one calibration point between host and device is `recentPresentation`:+0.713 s host against 0.305 s device on the same fixture, i.e. the device is+~2.3× faster on that *read* workload. It is a single point, on one workload, and+it is not a substitute for any row in the table above.++---++## Task 21 — The migration against its 10 s budget (Req 2.6)++**Date:** 2026-07-28++### Headline: the budget is breached, by 1.75×++| Measurement | Budget | Median (runs) | p95 | Spread within a run | Runs |+|---|---|---|---|---|---|+| **V5 relationship pass** | **10 s** | **17.31 – 17.75 s** ❌ | 17.34 – 17.84 s | ≤ 1.03× | 5 |+| **whole certification open** | 10 s | **17.94 – 18.40 s** ❌ | 18.34 – 18.50 s | ≤ 1.03× | 4 |+| pass, Entry half only (recorded) | — | 16.55 – 17.00 s | 16.59 – 17.09 s | ≤ 1.02× | 3 |+| pass, Work half only (recorded) | — | 1.17 – 1.20 s | 1.20 – 1.21 s | ≤ 1.04× | 3 |++This is a measurement, not a hiccup: five runs agreed to within 2.5%, and the+within-run spread is ≤ 1.03× — the tightest in the whole suite.++**Validation is not the cost.** The certification open (pass →+`validateV4Store` → publish `"5"`) is only ~0.6–0.9 s more than the pass alone,+which matches the 0.77 s that store-level validation measures on its own. The+pass *is* the migration's cost.++### What the pass is spending it on++The Entry half and the Work half of the same pass, measured separately by+leaving one half already linked — Q33's identity check makes an already-correct+assignment a true no-op, which is what makes the split expressible:++| Half | Records | Inverse array | Median |+|---|---|---|---|+| `entry.site` | 5,000 | one `Site.entries` | 16.55 – 17.00 s |+| `work.site` | 1,000 | one `Site.works` | 1.17 – 1.20 s |++**14.17 – 14.24× the cost for 5× the records.** Fitting *n*^k gives k ≈ 1.65: the cost+grows superlinearly in the size of the inverse array being maintained, which is+the unknown the design named ("this is the one place the traversal is+unavoidable, and it remains the main unknown in that budget"). The two halves+also sum to the whole (16.5 + 1.2 = 17.7 against 17.5–17.7 measured), so nothing+else material is hiding in the pass.++The stripping half of the harness shows the same shape from the other side:+nulling 6,000 relationships and saving takes ~13 s, close to what setting them+takes. It is relationship mutation against a large inverse array that costs, in+either direction.++### The shape this is measured against++The M4 fixture is **one** Site carrying all 5,000 Entries+(`LibraryRepository.m4FixtureHostname`, 1,000 Works × 5 chapters), so every+assignment appends to the same array. The design chose that deliberately as the+worst shape available and task 21 forbids reshaping it to meet the number.++At *n*^1.65, the same 5,000 Entries spread over ~40 hostnames would cost roughly+an order of magnitude less — call it under 2 s. **That is arithmetic, not a+measurement**, and it does not soften the result: Req 2.6 is measured on the+single-Site fixture, and on the single-Site fixture it fails.++### What was done about it: nothing, deliberately++Recorded, not fixed — see **Decision 6**. In short:++- **Batching the save is the obvious fix and stays rejected.** Req 2.4's+  all-or-nothing rests on the single save that Q15 rejected batching to keep.+  A breach reopens that as a *decision*; it does not authorise the change from+  inside a measurement task.+- **The budget is not raised and the fixture is not reshaped.** Either would+  delete the signal.+- **The assertions are wrapped in `withKnownIssue` with a regression ceiling+  asserted outside it** (22 s for the pass, 23 s for the certification open) —+  the construction Decision 11 of `library-integrity-tolerance` arrived at for+  the same situation on Req 5.5. `withKnownIssue` alone swallows a failure at+  17 s and at 170 s alike; the ceiling refuses a run that has become a *new*+  problem. Raising a ceiling to make a run pass would give the tests back the+  property they exist to remove.++### What this measurement cannot say++**It is a host measurement of a write-and-save workload, and Req 2.6's protocol+is the physical-device protocol.** The only host/device calibration in the+repository is `recentPresentation` — a *read* workload — at 2.3×. Applying it+here would put the pass near 7.5 s, inside budget. **That is an inference from+the wrong kind of workload and is not evidence.** Settling it needs a device+harness that does not exist (see the device-pending table above) and a device run+the user approves at the time.++**The user-visible cost is a one-time cost.** The pass runs once per library, on+a `"4"` marker, and republishes `"5"` (Q31); it does not run on subsequent+launches. A 17 s first launch after upgrading is a different kind of problem+from a 17 s launch, and the requirement does not distinguish them.++### How the measurement avoids measuring nothing++Four ways this could have silently measured a no-op, all guarded in+`M4MigrationScalePerformanceTests` rather than reasoned about:++| Trap | Guard |+|---|---|+| The pass is skipped on a `"5"` marker (Q31), and every seeded fixture publishes `"5"` | `V5RelationshipPass.run` is called directly; the certification test writes a `"4"` marker before every timed open |+| The fixture already sets both halves (Q43) and the pass skips an identical winner (Q33), so an unstripped run dirties nothing | Both relationships are nulled, **saved**, and the container released; the timed run opens a fresh container |+| `.siteMissing` is not a pre-pass graph — no Site row means the pass correctly assigns nothing | The coherent fixture is used, stripped |+| A pre-count alone cannot distinguish a pass that did the work from one that assigned none | Both the pre-timing linked count (0) and the post-pass linked count (5,000 Entries, 1,000 Works) are asserted, the latter from a fresh container so it is the persisted state |++---++## Files++| File | Change |+|---|---|+| `Packages/AsterismCore/Tests/AsterismCoreTests/M4MigrationScalePerformanceTests.swift` | New. Req 2.6: the pass, the certification open, and the Entry/Work split |+| `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift` | Adds `store-level-validation` (Req 5.3, Q57) |+| `Makefile` | `test-performance-m4` picks up the new suite; records the ~30 minute runtime |++Both suites are gated on `ASTERISM_RUN_PHYSICAL_PERFORMANCE=1`, exactly as the+existing ones are, so `make test-core` runs neither: it reports them skipped and+stays at ~75 s.
specs/relational-references/prerequisites.md Added +44 / -0
diff --git a/specs/relational-references/prerequisites.md b/specs/relational-references/prerequisites.mdnew file mode 100644index 0000000..7eee552--- /dev/null+++ b/specs/relational-references/prerequisites.md@@ -0,0 +1,44 @@+# Prerequisites for Relational References++These tasks require human intervention outside of code.++## Before anything else — the premise++- [x] **Ran `docs/investigations/cloudkit-probe.md` — Q1 answered yes (2026-07-27).**+      The question is whether `NSPersistentCloudKitContainer` resolves a+      relationship whose target arrives later.++      Peak nil-`site` count 2,995 of 3,000 across 45 samples, settling at 0. The+      kill switch did not fire, and the measurement strengthened the case: the+      dangling state is the norm during hydration, not an edge.++      The probe cannot be run inside this milestone: the relationship it tests+      does not exist in the schema yet, so testing it here would mean first+      building the work the probe exists to de-risk. It runs on a throwaway+      branch against the Asterism Development configuration instead, and it+      answers the mirroring spec's attribute question in the same session.++## Before implementation++- [ ] **Take a pre-flight backup and prove it restores.** A byte-level container+      download (Xcode → Devices and Simulators → Download Container) *and* a+      4/4 archive proven to import into a Development install. This milestone+      migrates the store that holds the real notes; a backup that has not been+      restored is not known to be a backup.+- [x] **Unmarked-store fix shipped** — T-1969 and T-1919 landed in `194ed46`+      (2026-07-27), so a fresh install no longer risks an unopenable library.++## Before testing++- [ ] A physical device for the scale assertions in Req 5, on the same device+      class as the recorded baselines, with the library quiesced.+- [ ] Keep the device **unlocked** for the duration of any device run and back it+      up first. A locked phone yields+      `com.apple.dt.deviceprep Code=-3 "Unlock <device> to Continue"` partway+      through and corrupts the run.++## Notes++No Apple Developer portal work is needed for the migration itself — it is a+local schema change. The only external dependency is the Q9 probe, which+borrows the mirroring spec's CloudKit setup.
specs/relational-references/requirements.md Added +85 / -0
diff --git a/specs/relational-references/requirements.md b/specs/relational-references/requirements.mdnew file mode 100644index 0000000..d139800--- /dev/null+++ b/specs/relational-references/requirements.md@@ -0,0 +1,85 @@+# Requirements: Relational References++## Introduction++Three of the library's cross-record references are strings rather than modelled relationships: an Entry and a Work name their Site by `hostname`, and an Entry cites the title patterns and URL rules that produced its fields as `(UUID, version)` pairs. A string reference whose target is absent is indistinguishable from one whose target never existed, so the app has to track the difference itself — which is why Site lookup resolves a winner among rows, why a cited rule id must be hunted across every row for a hostname, and why an absent target is currently fatal. A modelled relationship has none of that: an unarrived target is nil, and the association forms by itself when the record lands.++This milestone converts those references to relationships while keeping the strings and version integers as capture-time evidence. It is scheduled ahead of CloudKit mirroring deliberately: the conversion needs a data migration, and a migration is a one-device problem exactly until mirroring is switched on.++Reference: `docs/asterism-design.md` §2.2, §2.4, §2.6, §13.1, §14; `specs/cloudkit-mirroring/decision_log.md` Q20 and Decision 4.++## Non-Goals++- Enabling CloudKit mirroring, containers, or entitlements — that is `specs/cloudkit-mirroring/`. (The containers and the iCloud/remote-notification entitlements have in fact landed ahead of this milestone, as that spec's own gating prerequisites — `specs/cloudkit-mirroring/prerequisites.md`, commit `6006b6a`. They are inert: the app still opens its store with mirroring off. No mirroring behaviour ships here.)+- Reconciling duplicates of any kind. Duplicate Site rows are reconciled in the mirroring spec; duplicate Entries and Works in M4c.+- Changing how titles or URLs are interpreted, or adding a rule form.+- Removing `Entry.hostname`, `Work.siteHostname`, or the cited ids and versions. They stay as immutable evidence and as the archive's reference format.+- Changing the backup archive format. The archive already references by hostname and UUID, which is what these relationships are derived from.+- Changing what the reader sees, beyond records resolving that previously did not.+- Changing Site deletion semantics. `Site.patterns` and `Site.urlRules` stay `.cascade`, so deleting a Site still removes the rules an Entry may cite. There is no delete-Site flow in the app, so the cascade is reachable only from tests and fixtures; a requirement the code cannot satisfy, for a path that does not exist, was withdrawn rather than designed around.++---++### 1. References Are Modelled++**User Story:** As the reader, I want the app to hold real links between my records, so that a record whose target has not loaded yet is simply incomplete rather than broken.++**Acceptance Criteria:**++1. <a name="1.1"></a>An Entry and a Work SHALL each hold an optional relationship to their Site, alongside the existing hostname string.+2. <a name="1.2"></a>A cited title pattern or URL rule SHALL resolve through the citing record's Site relationship — among the rules that Site owns — rather than by searching every Site row for a hostname. The cited id and version stay as the citation itself.+3. <a name="1.3"></a>Every relationship SHALL be optional and SHALL have an inverse, so the schema stays mirrorable.+4. <a name="1.4"></a>Writing a record that names a Site SHALL set both the relationship and the hostname string in the same save, so the two cannot diverge.++---++### 2. The Existing Library Converts Without Loss++**User Story:** As the reader, I want my library to come through the conversion intact, so that a schema change costs me nothing.++**Acceptance Criteria:**++1. <a name="2.1"></a>Migration SHALL populate every relationship from its existing string counterpart, and SHALL leave a relationship nil only where no matching record exists.+2. <a name="2.2"></a>After migration, every record SHALL retain the field values, provenance, and timestamps it held before, and record counts per type SHALL be unchanged.+3. <a name="2.3"></a>Migration SHALL run in the app only, and the share extension SHALL decline to open the library until it has completed.+4. <a name="2.4"></a>IF migration is interrupted, THEN the next app launch SHALL either complete it or fail with a named reason, and SHALL NOT certify a partially converted library as ready.+5. <a name="2.5"></a>An archive exported before this milestone SHALL import afterwards, with its relationships derived from the hostnames and cited ids it carries.+6. <a name="2.6"></a>Migration SHALL complete within 10 s over the 5,000-Entry fixture, measured by the project's current protocol with the median asserted. **Breached at the fixture's 5,000-entry worst case — see Decision 6 and Q60.**++---++### 3. Resolution Gets Simpler, Not Just Different++**User Story:** As the reader, I want this change to remove machinery rather than add a second way of doing the same thing, so that the app gets easier to reason about.++**Acceptance Criteria:**++1. <a name="3.1"></a>Resolving the Site for an Entry or Work SHALL follow its relationship, and SHALL NOT select a winner among rows sharing a hostname. **Amended by Decision 5:** this governs *provenance* — which record a citation came from, and which row's rules a record's fields and identity are replayed against. A surface answering a *hostname-level* question — what this hostname currently teaches, how it presents a title, which actions it offers — MAY resolve the hostname winner, and every such surface SHALL resolve it the same way.+2. <a name="3.2"></a>Resolving a cited title pattern or URL rule SHALL search only the rules owned by the Site the citing record points at, and SHALL NOT search the union across every Site row for a hostname.+3. <a name="3.3"></a>Selecting a Site by hostname SHALL remain where no record identifies one yet — capture of a new URL, and teaching — and, per Decision 5, where the question asked is about the hostname rather than about a record: what its teaching says, and what that teaching presents and offers.+4. <a name="3.4"></a>A nil relationship SHALL leave its record renderable and marked as needing attention, and SHALL NOT fail a screen, quarantine a hostname, or prevent export. This SHALL hold specifically for the two citation-replay paths that throw today — Recent's unresolved-assignment replay and Entry detail's provenance disclosure.+5. <a name="3.5"></a>A record whose relationship resolves SHALL be unaffected by any other record sharing its hostname. **Amended by Decision 5:** this governs *provenance* — the citations a record replays and the row its fields and identity are replayed against, which follow a fixed pointer and so survive a winner flip. It does not govern *presentation*: what a hostname's teaching says, how it cleans a title, and which actions it offers are hostname-level answers, so teaching committed on another row of the same hostname does change what a record's screens show, and must, or Recent and Entry detail would disagree about the same Entry.++---++### 4. Provenance Survives++**User Story:** As the reader, I want the record of which rule produced which field to keep working, so that the forensic trail the app is built around is not the price of this change.++**Acceptance Criteria:**++1. <a name="4.1"></a>An Entry citing a superseded title pattern SHALL keep resolving to that pattern, not to the site's active one.+2. <a name="4.2"></a>The cited version integer SHALL continue to be recorded and displayed, and SHALL be checked against the related record's version.+3. <a name="4.3"></a>Entry detail's provenance disclosure SHALL show the same facts it shows today.++---++### 5. Scale++**User Story:** As the reader, I want the app to be no slower for holding real links, so that correctness is not paid for in latency.++**Acceptance Criteria:**++1. <a name="5.1"></a>The extension's open-and-validate path SHALL stay within its 1 s budget and Recent's publish-to-interactive path within its 2 s budget over the 5,000-Entry fixture, measured by the project's current protocol — median asserted on every run, p95 only under `CONTROLLED=1`.+2. <a name="5.2"></a>Capture rule application SHALL stay within its existing 100 ms budget.+3. <a name="5.3"></a>Store-level validation SHALL not become slower than the diagnosis budget already recorded for that path, and the measurement SHALL state whether following relationships made it faster or slower than resolving strings.
specs/relational-references/tasks.md Added +205 / -0
diff --git a/specs/relational-references/tasks.md b/specs/relational-references/tasks.mdnew file mode 100644index 0000000..0ae25a4--- /dev/null+++ b/specs/relational-references/tasks.md@@ -0,0 +1,205 @@+---+references:+    - specs/relational-references/requirements.md+    - specs/relational-references/design.md+    - specs/relational-references/decision_log.md+---+# Relational References — Implementation Tasks++## Schema++- [x] 1. Establish whether V4 must be frozen <!-- id:vpqm7oy -->+  - Add a temporary property to a live model class and open a store recorded as V4 under the unchanged AsterismSchemaV4 declaration. Record the observed result — container refuses to open, opens with a mismatched hash, or silently succeeds — in the decision log against Q20, then revert the probe property.+  - This decides whether tasks 2 and 3 are needed at all. If redefining V4 in place is observably safe, skip them and go straight to task 4.+  - Not TDD: the deliverable is a recorded observation, not behaviour.+  - Stream: 1+  - Requirements: [2.2](requirements.md#2.2)++- [x] 2. Freeze V4 as nested snapshot classes <!-- id:vpqm7oz -->+  - Only if task 1 shows redefining V4 in place is unsafe.+  - Mirror AsterismSchemaV3: nested snapshots of all five models carrying the same SwiftData entity names, top-level typealiases. The live classes in Models.swift move to a V5 extension.+  - Repoint LibraryRepository+BackupImportV4.swift:18, which builds its in-memory validation container from AsterismSchemaV4 while materializeV4Payload inserts live classes — after the freeze those are different entities and it mismatches at runtime.+  - Repoint the four test files pinning AsterismSchemaV4.self / AsterismV4MigrationPlan.self: V4ValidatorToleranceTests, LibraryToleranceScanTests, BackupImportTransactionTests, IdentityResolutionTests.+  - Config/type work; no behaviour change, so no preceding test task.+  - Blocked-by: vpqm7oy (Establish whether V4 must be frozen)+  - Stream: 1+  - Requirements: [2.2](requirements.md#2.2)++- [x] 3. Declare schema V5 with the two relationships and their internal inverses <!-- id:vpqm7p0 -->+  - Entry.site: Site? and Work.site: Site?, both optional. Inverses Site.entries and Site.works, both .nullify, both declared internal while Site stays public (Q17).+  - Do not add entryValues / workValues accessors beside the existing patternValues / urlRuleValues — the absent accessor is what keeps the traversal out of reach.+  - Migration plan gains the [V4, V5] lightweight stage, or a V5 plan declaring [V3, V4, V5]. Note the design's open question: whether a V3-recorded store traverses both stages in one open.+  - Types and configuration; behaviour arrives in tasks 6-11.+  - Blocked-by: vpqm7oy (Establish whether V4 must be frozen), vpqm7p2 (Split validateV4MarkerContent into app-side and extension-side checks)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3)++## Marker contract++- [x] 4. Write tests for the split marker check <!-- id:vpqm7p1 -->+  - App side accepts markers "4" and "5"; extension side accepts only "5" and declines a "4" marker with the existing 'containing app has not initialized' message.+  - Assert the extension declines before constructing a ModelContainer — the check exists to stop ModelContainer.init performing the lightweight conversion in a process holding only a shared lock.+  - Beside the existing V4MigrationBootstrapTests.+  - Stream: 2+  - Requirements: [2.3](requirements.md#2.3)++- [x] 5. Split validateV4MarkerContent into app-side and extension-side checks <!-- id:vpqm7p2 -->+  - One function accepting {4,5} for both processes would let the extension open an unmigrated store; one demanding "5" would make the app throw on every library this migration exists for (Q14).+  - Must land before task 3's schema ships, so no build exists where the extension can reach a V5 container over a V4 store.+  - Blocked-by: vpqm7p1 (Write tests for the split marker check)+  - Stream: 2+  - Requirements: [2.3](requirements.md#2.3)++## Citation replay++- [x] 6. Write tests that citation replay renders instead of throwing <!-- id:vpqm7p3 -->+  - Recent publishes a feed containing entries whose cited pattern does not resolve, and Entry detail renders the same, neither throwing.+  - replayRecentCandidate currently throws corruptLibrary uncaught (LibraryRepository+RecentPresentation.swift:299-318) and it propagates out of the whole publication; +EntryDetail.swift:46 has the same shape.+  - These are the regression tests for Q13 and must exist before task 14 changes the search space.+  - Stream: 3+  - Requirements: [3.4](requirements.md#3.4)++- [x] 7. Make both citation-replay paths return rather than throw <!-- id:vpqm7p4 -->+  - An unresolvable citation produces the needs-attention rendering used elsewhere for degraded records.+  - Order is load-bearing: with the union lookup still in place these paths are unreachable, so this is a safe no-op change today and a prerequisite once resolution follows entry.site (Q13).+  - Blocked-by: vpqm7p3 (Write tests that citation replay renders instead of throwing)+  - Stream: 3+  - Requirements: [3.4](requirements.md#3.4)++## Migration++- [x] 8. Write tests for the relationship pass <!-- id:vpqm7p5 -->+  - Losslessness over the 5,000-Entry fixture: field values, provenance tuples, timestamps and per-type counts unchanged (Req 2.2). Pair it with an assertion that each record's cited rules resolve within the Site it was assigned — counts and tuples are identical whichever row is chosen, so losslessness alone cannot catch a wrong-row assignment.+  - Determinism: two runs over a fixture carrying duplicate rows for one hostname pin records to the same row (Q16).+  - Interruption: a store left unmarked converges on the next run and is never certified partway (Req 2.4). Cover the exact state an interruption leaves — the store already converted to 5.0.0, because ModelContainer.init committed the schema conversion on the way in, with the marker still "4". The re-run must converge on that store and publish "5" only afterwards (Q28). A test that starts from an unconverted store is testing a state this path cannot be interrupted in.+  - Property-based via Swift Testing @Test(arguments:) over generated graph shapes for losslessness and determinism; no new PBT dependency.+  - Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/v4-recorded-4.0.0.sqlite is the only genuinely 4.0.0-recorded store in the repo (see V4RecordedStoreTests) — use it wherever the test needs a real pre-conversion store rather than one built at the current schema.+  - Blocked-by: vpqm7p0 (Declare schema V5 with the two relationships and their internal inverses)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.4](requirements.md#2.4)++- [x] 9. Implement the relationship pass <!-- id:vpqm7p6 -->+  - Resolve each hostname through SiteResolutionOrder, not a last-write-wins map over an unsorted FetchDescriptor<Site> as V4Migration.swift:71-74 does (Q16).+  - One save at the end, marker published last — Req 2.4 rests on atomicity, not on resumption (Q15). Nothing batched.+  - A hostname matching no Site row leaves the relationship nil; that is the tolerated state, not an error.+  - Blocked-by: vpqm7p5 (Write tests for the relationship pass)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.4](requirements.md#2.4)++- [x] 10. Write tests that all three certification paths run the pass <!-- id:vpqm7p7 -->+  - V4-marker present, V3-marker present, and sidecar-resume. The latter two reach publishV4Readiness through certifyMigration, which would otherwise stamp "5" on a library whose relationships were never populated — an M3-era library upgrading in one launch would certify with every relationship nil.+  - Assert the marker reads "5" only after relationships are populated.+  - Blocked-by: vpqm7p6 (Implement the relationship pass)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.4](requirements.md#2.4)++- [x] 11. Wire the pass into all three certification paths in openV4ForApp <!-- id:vpqm7p8 -->+  - V4-marker branch (:89) validates, runs the pass, publishes "5", opens. V3-marker (:135) and sidecar-resume (:103) run the V4 completion pass then the V5 pass before certifyMigration publishes.+  - Ordering inside the V4-marker branch is open and must be settled here: the pass has to run BEFORE validateV4Store computes diagnostics, or the session runs on a quarantine map built from the pre-pass graph — every relationship still nil — and the library opens with quarantines the pass has just made obsolete.+  - The fourth readiness-publishing path, mark-at-birth for an empty store, is already handled: it publishes "5" directly, because an empty store has nothing to migrate (Q26). It needs no pass and must not grow one.+  - Blocked-by: vpqm7p7 (Write tests that all three certification paths run the pass)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4)++## Resolution++- [x] 12. Write resolution-parity tests for the eight cited-rule call sites <!-- id:vpqm7p9 -->+  - For each of V4LibraryValidator, +RecentPresentation (:44), +ReparseCapture (:334, :335) and +EntryDetail (:46): the relationship form resolves the same record the union form did.+  - Task 13 refactored the validator's four sites, so the union now lives in exactly two helpers there — resolves(citedRule:in:on:) at V4LibraryValidator.swift:314 and citedPattern(id:version:in:on:) at :328, each calling CitedRuleResolution.resolves at :319 and :332. The four sites reach them from :464 (Work rule identity) and :815 (requiredReference) for the rule helper, and :615 (v3 name contributor) and :715 (pattern chapter provenance) for the pattern helper. Parity is asserted at the four sites; the conversion in task 14 is two helper bodies.+  - Cover the superseded-pattern case (Req 4.1) and a version mismatch (Req 4.2) — the resolution must test id and version together, as every existing call site does.+  - Cover a nil entry.site: resolution yields nothing and the caller renders rather than throws.+  - At the four validator sites specifically, the nil-site case must be asserted one layer up as well: a nil relationship produces a tolerated diagnosis and the hostname is NOT quarantined (Req 3.4, Q27). Parity of what the call site resolves is not enough — a quarantine is computed above the call site and would pass a resolution-only comparison.+  - CitedPatternResolutionTests is converted here, not deleted (Decision 4). Its two central properties must hold in relationship form: an Entry citing the LOSING Site row's rules still replays clean, and it keeps replaying after a committed change flips the winner. Its fixture assigns each record to the row owning that record's citations rather than running V5RelationshipPass, which is what makes both properties expressible.+  - Blocked-by: vpqm7p0 (Declare schema V5 with the two relationships and their internal inverses), vpqm7p4 (Make both citation-replay paths return rather than throw), vpqm7pe (Set the relationship at every write site)+  - Stream: 1+  - Requirements: [1.2](requirements.md#1.2), [3.2](requirements.md#3.2), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3)++- [x] 13. Make the four validator cited-rule sites tolerate a nil site relationship <!-- id:vpqm7pi -->+  - The four sites are V4LibraryValidator :389 (Work rule identity), :528 (v3 identity name contributor), :611 (pattern chapter provenance) and :697 (requiredReference). Each throws when CitedRuleResolution finds nothing; the throw becomes a diagnosis and the diagnosis quarantines the hostname.+  - Task 14 narrows resolution to entry.site, and a nil relationship is a state Req 2.1 explicitly permits. Left as they are, every record whose Site did not resolve would quarantine its hostname — the same failure Q13 fixed for the replay paths, one layer down, and the one Req 3.4's quarantine clause forbids by name: a nil relationship SHALL NOT quarantine a hostname, fail a screen, or prevent export.+  - Must land before task 14, not with it (Q27). Task 13's parity tests compare what each call site resolves; a quarantine is computed a layer above them and would not show up.+  - Tests must cover: a nil entry.site produces a tolerated diagnosis rather than a quarantine, at each of the four sites; and a present relationship whose citation genuinely does not resolve keeps whatever the closed tuple table already specifies.+  - This task inherits the pass-before-validate ordering regression test (Q35). Task 11 settled that the relationship pass runs before validateV4Store, but the ordering is behaviourally inert until the validator reads entry.site — with the validator still resolving through the union there is no store where swapping the two produces a different result, so task 11 could not pin it. Once these four sites read the relationship, the test is constructible: a store where a nil relationship would quarantine and a populated one would not, opened through openV4ForApp, must come back unquarantined. Reversing the two calls in the V4-marker branch must fail it.+  - Blocked-by: vpqm7p4 (Make both citation-replay paths return rather than throw)+  - Stream: 1+  - Requirements: [3.4](requirements.md#3.4)++- [x] 14. Resolve cited rules through the record's Site and delete CitedRuleResolution <!-- id:vpqm7pa -->+  - Each call site becomes a lookup among entry.site's own rules, matching id and version.+  - In V4LibraryValidator the change is two helper bodies, not four call sites: resolves(citedRule:in:on:) (:314) and citedPattern(id:version:in:on:) (:328) each drop their CitedRuleResolution.resolves ownership test (:319, :332) in favour of the citing record's own site. CitationContext already carries citingSite.+  - Delete Packages/AsterismCore/Sources/AsterismCore/CitedRuleResolution.swift once all eight are converted.+  - The +ReparseCapture Entry assignment this task once had to make already landed in task 18 (`entry.site = site`, +ReparseCapture.swift:303, before the tuple validation and to the row the lookup returned). Do NOT re-add it. The ordering constraint it exists for still holds and is worth reading: +ReparseCapture.swift:260-263 may insert a Site moments before validating in the same transaction, and SiteResolutionOrder sorts a temporary PersistentIdentifier last, so siteRows.first is the pre-existing row.+  - CitedPatternResolutionTests must still pass after the conversion (Decision 4). It is the proof that the relational form covers what the union covered — including the losing row's citations and survival of a committed winner flip — rather than a suite the union's removal makes obsolete.+  - Blocked-by: vpqm7p4 (Make both citation-replay paths return rather than throw), vpqm7p9 (Write resolution-parity tests for the eight cited-rule call sites), vpqm7pi (Make the four validator cited-rule sites tolerate a nil site relationship), vpqm7pe (Set the relationship at every write site)+  - Stream: 1+  - Requirements: [1.2](requirements.md#1.2), [3.2](requirements.md#3.2), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2)++- [x] 15. Convert the two record-shaped Site lookups to relationship reads <!-- id:vpqm7pb -->+  - +EntryDetail (entered from an Entry) becomes entry.site; +WorkMerge's work.siteHostname call becomes work.site.+  - The other 12 fetchSites callers stay as hostname lookups — they are the capture and teaching moments Req 3.3 preserves.+  - No new test task: this is a refactor of behaviour the existing EntryDetail and WorkMerge suites already cover, and those suites must stay green through it. If either lacks coverage for the path being changed, add it before converting.+  - RE-SCOPED 2026-07-28 by Decision 5 (presentation follows the hostname winner; provenance follows the relationship). The first pass converted the WHOLE of entryTeachingDetail, which left Recent and Entry detail disagreeing about the same Entry on a duplicated hostname and added a corruptLibrary throw for an illegal tuple on a non-winning row. Reverted for the presentation half only: siteMode, active and historical pattern summaries, displayTitle, hasCurrentURLRule and availableActions resolve fetchSites again. KEPT relational: the citation replay (assignment settlement and unresolved candidate title) through entry.site, and buildWorkURLBasis through work.site. Recent needed no code change. Req 3.1 and 3.3 are amended to carry the split; Req 4.3 needed no change, because the disclosure half is precisely what stayed relational.+  - The discriminating test lives in EntryDetailAndMergeToleranceTests: two rows teaching different active patterns, the Entry pinned to the loser, asserting the winner's pattern in the summary AND the loser's pattern in the replay. Either assertion alone passes for the wrong reason.+  - Blocked-by: vpqm7p0 (Declare schema V5 with the two relationships and their internal inverses)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.3](requirements.md#3.3), [3.5](requirements.md#3.5)++- [x] 16. Classify and convert each of the five fetchSites calls in +ReparseCapture <!-- id:vpqm7pc -->+  - Decide per call, not by a blanket rule: :14, :75, :260, :292, :405. A call reached from an existing Entry follows entry.site; a capture-shaped call keeps the hostname lookup.+  - Record the classification for each of the five in the decision log, so the reasoning survives the diff.+  - No new test task for the same reason as task 15 — the re-parse suites cover these paths and must stay green. Any of the five without existing coverage gets a test before conversion.+  - Blocked-by: vpqm7pa (Resolve cited rules through the record's Site and delete CitedRuleResolution)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.3](requirements.md#3.3)++## Write sites++- [x] 17. Write tests that write sites set both halves <!-- id:vpqm7pd -->+  - capture sets entry.hostname and entry.site; create-Work sets work.siteHostname and work.site, and when it inserts a Site because none exists the new Work points at that row.+  - materializeV4Payload wires both relationships from its existing sitesByHostname map, so a 4/4 archive imports with relationships derived from the hostnames it carries (Req 2.5).+  - Blocked-by: vpqm7p0 (Declare schema V5 with the two relationships and their internal inverses)+  - Stream: 1+  - Requirements: [1.4](requirements.md#1.4), [2.5](requirements.md#2.5)++- [x] 18. Set the relationship at every write site <!-- id:vpqm7pe -->+  - LibraryRepository.capture, LibraryRepository create-Work, and materializeV4Payload in +BackupImportV4.+  - Without this the app's own writes leave relationships unset, which the migration would then have to repair on a later launch.+  - Q29's constraint was that this land before or with task 11, and it did not — task 11 shipped first, so HEAD can already produce a "5"-marked library with every relationship nil (import). Decision 2 resolves that by ordering rather than repair: this task now blocks tasks 12 and 14, so no read follows entry.site until every write sets it.+  - B1 regression test, required before this task closes: import a backup into a library already marked "5" (confirmImportReplace and confirmImportFillEmpty both reach materializeV4Payload) and assert every materialized Entry and Work comes back with its relationship populated. That import publishes no marker and the pass never runs again, so this test is the only thing standing between the import path and a permanently nil graph.+  - The app's writes and the pass must agree on the winner. Both must route hostname → Site through SiteResolutionOrder (fetchSites), or a capture and a later migration re-run would pin the same Entry to different rows of a duplicated hostname. Pin the agreement with a test: over a hostname carrying duplicate Site rows, the row a write site assigns is the same row V5RelationshipPass.run assigns.+  - Blocked-by: vpqm7pd (Write tests that write sites set both halves)+  - Stream: 1+  - Requirements: [1.4](requirements.md#1.4), [2.5](requirements.md#2.5)++- [x] 19. Update the fixtures to construct relationships <!-- id:vpqm7pf -->+  - M2PerformanceFixture, M3PerformanceFixture, M4PerformanceFixture set entry.site and work.site, or the scale suites measure a graph the app never produces.+  - ToleratedStateFixture: every kind sets the relationship EXCEPT .siteMissing, whose correct state is a nil relationship — that kind exists to model an Entry whose Site is absent, which is the central case of this milestone.+  - The sweep is wider than the three performance fixtures. 14 test files publish a "5" marker over a store they built themselves (grep `publishV5Readiness` and a literal `"5\n"` marker write across Packages/AsterismCore/Tests and Asterism/AsterismTests): CitedPatternResolutionTests, QuarantineScopingTests, M4ScaleFixtureTests, EntryDetailAndMergeToleranceTests, RecentPresentationToleranceTests, M4ToleratedScalePerformanceTests, M4ToleratedFixtureTests, M4ScalePerformanceTests, RefreshUnionInvariantTests, BackupV4ExportTests, IdentityLookupToleranceTests, FailClosedRegressionTests, IntegrationSafetyNetTests, CrossViewRefreshTests. Each declares a migrated library whose relationships were never populated, which is inert today and becomes a nil-relationship graph under test the moment task 14 makes reads follow entry.site. Every one either sets the relationship or states in a comment which records are deliberately nil and why.+  - One carve-out from running the pass (Decision 4): CitedPatternResolutionTests builds two taught Site rows on one hostname, each owning the rules its own Entry and Work cite. The pass would assign both records to the winner, which owns neither of the losing row's ids. That fixture assigns inline, per record, to the row owning that record's citations — the shape mirroring produces. Checked against the other two duplicate-row suites: IdentityLookupToleranceTests and RefreshUnionInvariantTests seed bare rows whose Entries cite nothing, so the pass is correct for them.+  - Blocked-by: vpqm7p0 (Declare schema V5 with the two relationships and their internal inverses)+  - Stream: 1+  - Requirements: [2.2](requirements.md#2.2), [3.4](requirements.md#3.4)++## Scale++- [x] 20. Measure the scale budgets and record the direction of change <!-- id:vpqm7pg -->+  - Extension open-and-validate against 1 s and Recent publish-to-interactive against 2 s over the 5,000-Entry fixture; capture rule application against 100 ms, which +Capture.swift:135 sits on.+  - Store-level validation against the recorded diagnosis budget, reporting the number rather than only asserting a bound — Req 5.3 asks whether following relationships made it faster or slower than resolving strings.+  - Project protocol: median asserted every run, p95 only under CONTROLLED=1, sync quiesced. Physical-device runs need approval at the time.+  - MEASURE THE VALIDATOR FIRST. It is the likely mover of the 1 s open-and-validate budget and Req 5.3's direct answer: validate(graph:) now reads work.site and entry.site unconditionally, once per record (V4LibraryValidator ~:438 and ~:489), and every cited-rule read scans the citing Site's relationship arrays (urlRuleValues / patternValues) per reference rather than hitting a prebuilt dictionary. That is a to-one fault per record plus a linear scan per citation, against string dictionary lookups before. If it breaches, the fix is to index the citing Site's rules once per record rather than per reference, before anything larger is contemplated.+  - Blocked-by: vpqm7p8 (Wire the pass into all three certification paths in openV4ForApp), vpqm7pa (Resolve cited rules through the record's Site and delete CitedRuleResolution), vpqm7pe (Set the relationship at every write site), vpqm7pf (Update the fixtures to construct relationships)+  - Stream: 1+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3)++- [x] 21. Measure the migration against its 10 s budget <!-- id:vpqm7ph -->+  - The relationship pass over the 5,000-Entry fixture, median asserted. The unknown is inverse-array maintenance: setting entry.site 5,000 times populates Site.entries, and no comparable pass exists to extrapolate from.+  - The measurement must start from an UNLINKED graph or it measures nothing. Task 19 made M4PerformanceFixture set both halves inline (Q43), and the pass skips an assignment whose winner is already identical (Q33), so running it straight over the seeded fixture dirties zero objects, maintains zero inverse arrays, and would clear a 10 s budget by doing no work. Before timing, either null Entry.site and Work.site across the whole store, or seed from the genuinely pre-pass Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/v4-recorded-4.0.0.sqlite fixture — whichever, assert the pre-timing nil count so a future fixture change cannot silently restore the no-op.+  - The budget is measured against the single-site worst case. The M4 fixture is ONE Site carrying all 5,000 Entries (`m4FixtureHostname`, `M4PerformanceFixture.swift`), not the ~40 Sites the design first assumed — so every assignment appends to one inverse array. That is the most expensive shape available, which makes it the right one to budget against; do not "fix" the fixture to spread the Entries across hostnames in order to meet the number.+  - If it breaches, the batching decision (Q15) reopens — and with it the atomicity that Req 2.4 rests on.+  - Four ways this measurement silently measures nothing, all observed shapes rather than hypotheticals. (a) The pass is SKIPPED when the marker reads "5": openV4ForApp runs it only on a "4" marker (Q31) and every seeded fixture publishes "5", so either call V5RelationshipPass.run directly or write a "4" marker before opening.+  - (b) Nulling the relationships must be SAVED, the container closed, and the store REOPENED before timing. Nulling in the same context leaves the inverse arrays warm and the objects registered, so the timed run measures a hot graph the app never has at certification.+  - (c) The .siteMissing fixture kind is NOT a pre-pass graph: its hostname carries no Site row at all, so the pass correctly does nothing for it — a fast, meaningless number.+  - (d) Assert the POST-pass non-nil count as well as the pre-timing nil count. The pre-count alone cannot tell a pass that did the work from one that iterated 5,000 records and assigned none.+  - Blocked-by: vpqm7p8 (Wire the pass into all three certification paths in openV4ForApp)+  - Stream: 1+  - Requirements: [2.6](requirements.md#2.6)

Things to double-check

Device installs are one-way.

Any build containing the V5 schema converts the store on first open; the previously installed build then fails with CoreData 134504. Download the device container before the first run (Q30). Three measurements (Recent publish-to-interactive, extension open, migration duration) remain device-pending and need explicit approval at the moment of running.

The mirroring spec must reconcile duplicate Sites.

Resolving citations through entry.site is correct while a hostname has one row locally and sync delivers pointers. If duplicate-Site reconciliation is ever dropped from specs/cloudkit-mirroring, Q11's resolution model must be revisited (Q21 records the standing dependency).

The validator's per-citation scan is unmeasured at many retained versions.

Resolution went from a global dictionary to a scan of the citing Site's rule arrays. Invisible at the fixture's two-rule Site; a Site with dozens of retained rule versions is the shape the measurement does not cover. The per-record index remedy is recorded, unapplied.

make test-performance-m4 now takes ~30 minutes.

The migration measurement joined the existing target rather than getting one nobody remembers to run (Q58). Hand-run swift test needs --no-parallel — parallel runs crash on SwiftData's global entity registry (Q34, documented in docs/agent-notes/testing.md).