Schema V9 drops the 36 columns V8 kept unread plus the Work.site/Site.works pair, inverts the citation write path onto the blob, retires the migration chain below V8, and gates MembershipReconciler.heal on the diagnosis. 18 commits against origin/T-2280/drop-superseded-columns (the spec commit).
V8PopulationPass gone; observation-pass known issue retired at 1.01 s; eight known issues remain, all with ceilings.Entry.init seeds it, provenance accessors are read-only views, editCitations is the one write door. A nil blob is counted, never quarantined (Q25, Q32).[V8, V9], one lightweight removing stage; app opens "8"/"9" and publishes "9" after validation; extension opens "9" only.backup-projection-duplicate-free +14% from the Q18 membership fault, recorded on T-2280, not budgeted.Ready to push
All ten spec tasks are implemented, every divergence has a decision row (Q13–Q37), three design-critic passes and this four-agent review have been applied, and make test-core, make test-quick and make verify-identity pass with no new warnings after the final fix commit. The frozen V8 snapshot was verified byte-for-byte against the pre-feature shape. What remains is the owner's: the gated device install and the pre-V9 archive export in prerequisites.md.
106a955 T-2280: implementation explanation, listed in the overview f3e2565 T-2280: pre-push review fixes — one blob decode per row, dead code out 1015e1f T-2280: changelog for phase 3, spec status Done d01168b T-2280: review fixes — the rollback is a reinstall, not a downgrade 294cc2a T-2280: the agent notes, CLAUDE.md and the overview describe V9 3296833 T-2280: the full-tier reconcile is 36x cheaper, and one known issue retires dd12a4c T-2280: a 7/8 archive recorded before V9 restores into a V9 library 8dfbccf T-2280: the V7-shaped merge facade and the dead strict gate are gone 2379e23 T-2280: changelog for phase 2 823514e T-2280: phase-2 review fixes — the V8 fixture crosses the stage full 54c9410 T-2280: a populated V8 store migrates to V9 with every live row intact e04df48 T-2280: schema V9 drops the superseded columns, the plan is [V8, V9] a442478 T-2280: changelog for phase 1, spec status In Progress 979c2e0 T-2280: phase-1 review fixes — the nil blob is a report, and an unreadable one is repaired ebb8385 T-2280: the reconcile runs no conversion pass, and heals only on the count d3f230b T-2280: the app opens "8" and "9", the extension "9" only 2e41d78 T-2280: membership and definition writers stop mirroring the columns be6a685 T-2280: every citation is written to the blob, and only to the blob The app keeps its library in a local database (SwiftData, Apple's wrapper over Core Data), whose tables have columns like a spreadsheet has named fields.
Earlier features moved three facts out of columns into better homes: a Work's site presence became a set of membership rows (so one story read on two sites is one Work); an Entry's seventeen citation columns — which parsing rules produced its title and at which version — became one JSON blob; a title pattern's ten definition columns became another blob.
The previous release (V8) added those homes but kept the old columns, because a migration that dropped them would have destroyed the originals before the one-time conversion pass could copy them. This branch is the second half: the columns are gone, the code mirroring data into them is gone, and the conversion pass is gone with them. Nothing the reader sees changes.
Two copies of one fact is a standing tax: every writer updates both, every reader must know which is authoritative, and the code keeping them in step ran on every launch and every iCloud sync — 1.07 s per pass over a 5,000-chapter library. That pass now costs 0.030 s (36× cheaper), and ten other measured operations got 14–27% faster because each row is seventeen columns lighter to fetch.
The catch: the removal is irreversible in place, and an older build installed afterwards cannot open what it finds. prerequisites.md makes the owner export a backup archive before upgrading — restored onto a freshly-installed old build, that archive is the only rollback.
AsterismSchemaV9.swift)..lightweight stage run inside ModelContainer.init; lightweight means SwiftData infers the change from the two shapes."8" (and upgrades it) or "9"; the share extension accepts "9" only. That digit is the only thing stopping a share-sheet capture from running a destructive conversion behind your back.| Area | Change |
|---|---|
| Schema | AsterismSchemaV8.swift frozen as stored-columns-only; live models move into AsterismSchemaV9; AsterismSchemaV5/V6/V7.swift deleted |
| Plan | AsterismV9MigrationPlan = [V8, V9], one .lightweight stage — the first in the project's history that removes anything (36 attributes + the Work.site ↔ Site.works inverse pair — 38 names) |
| Markers | extensionOpenableMarkerVersion = "9", appOpenableMarkerVersions = ["8", "9"] — a substitution; "7" retired |
| Write paths | Every citation writer builds an EntryCitations and calls setCitations/editCitations; LegacyColumns (≈390 lines of mirror code) deleted |
| Reconcile | V8PopulationPass deleted; MembershipReconciler.heal gated on worksWithoutMembershipCount > 0, on both tiers |
| Reads | GroupOrdering faults one Work.primaryMembership per row where six columns sat |
| Tests | V5/V6/V7RecordedStoreFixture + suites, V8PopulationPassTests, MembershipColumnMirrorTests, SiteRelationshipPopulationPassTests, MigratedStoreCitationRewriteTests deleted; V8RecordedStoreFixture/Tests (1,119 lines) and CitationSurgery.swift added |
| Docs | schema-migration.md rewritten to V9; CLAUDE.md re-counted; verification-run.md added |
The order is forced by one fact: the stage runs inside ModelContainer.init, so no code can read a column after the conversion. Everything had to stop reading and writing the columns in a build that still had them; only then could a second build drop them.
LegacyColumns.refreshCitations derived the blob from them. Now each writer constructs the citation and writes the blob directly (LibraryRepository.swift:1322-1420, +ComposedTeaching, +Articles, +ReparseCapture, +DuplicateResolution, DuplicateReconciler, +ConfirmImport, SiteReconciler.rewriteCitations). Entry.init seeds the default blob so nil means exactly one thing (Q13), and the derived provenance properties become read-only views with editCitations as the one write door (Q14).AsterismSchemaV8 keeps stored columns and @Relationship macros only; live classes move under extension AsterismSchemaV9, top-level names stay typealiases. Site.works goes with Work.site because it is declared as its inverse.validateStoreStrictly gate, then two make test-performance-m4 runs.The bootstrap arm shrank to the smallest thing that can certify a destructive stage: open (which converts) → validateStore → publishReadiness("9") → clearResidualEvidence. No data pass, no reconciler (Q9). Validation is the certificate; a throw leaves "8" on disk and the next open re-enters the arm over an already-converted store, which is safe because dropping columns that are already gone is a no-op.
GroupOrdering.sortedWorkRows now faults one to-one relationship per Work where six columns sat, on a path every write takes (Q18). Measured: backup-projection-duplicate-free rose 14% (1.301 s → 1.485/1.484 s over two runs). No budget covers it; the smolspec said in advance this would be reported, not bounded."7" substituted rather than added, defensible only because every device was confirmed at "8" on 2026-08-27 (Q2); and SiteRelationshipPopulationPass survived the deletion list with 22 live callers, losing only its Work.site loop and its suite (Q27).The registry hazard, and the bug it caused. SwiftData's entity registry is process-global and keyed by entity name, so a scratch ModelContainer over a frozen snapshot wins that registration for the rest of the process. LibraryRepository+BackupImportGates.swift:22-40 built its in-memory validation container over AsterismSchemaV8 — live when written, a snapshot after the freeze. While snapshots only added, the symptom was saves silently dropping a column; with V9 removing, the whole test process aborts with NSUnknownKeyException on Site.works (Q29).
V8RecordedStoreFixture takes the same hazard deliberately and survives by ordering: write(at:) creates the snapshot container, seeds, saves and releases it before returning, so snapshot and live containers are never in use at once; --no-parallel extends that across suites. V9's shape being a strict subset of V8's is belt-and-braces — the next version that adds loses that.
editCitations is a three-way state machine, not a setter (Models.swift:238-278). Decodable: apply, value-guard, write on change. Undecodable: apply over the default and always write, so the row stops being unreadable (Q24) — skipping it would land the caller's surrounding write with .none provenance, which the validator refuses. Nil: write only if something changed (Q23). That last arm is load-bearing: SiteReconciler.rewriteCitations walks every Entry on a consolidation, so an || citationsData == nil clause would have zeroed entriesWithoutCitationBlobCount on the first reconcile after the upgrade.
The validator's exceptions are for absence, never corruption. A nil blob is a report, not a quarantine (Q25): LibraryValidator.swift:802-820 throws unreadableCitations for bytes that will not decode and stays silent about bytes that are absent. Task 6's review found the second half — validateAssignment's .none arm required work == nil, so the first V9 open on a device holding one extension capture would have quarantined that hostname. Now work == nil || citationsAreAbsent (:1085-1115, Q32).
Heal gating collapsed to one question. multi-site-works kept the conversion passes unconditional on the full tier because the population pass's value guard saw staleness no counter could count. With the pass deleted that reason evaporates, so worksWithoutMembership gates the heal on both tiers (Q20, LibraryRepository.swift:182-196, 274-286). Deliberate consequence: a Work whose membership never arrives is counted by one refresh and healed by the next reconcile. Dedupe stays unconditional — that is the residual 30 ms.
The frozen snapshot is frozen by reference, not only by file (AsterismSchemaV8.swift:34-62): its stored shape includes the top-level value types it names and every enum raw value baked into a default. WorkType is the sharp one — V9 dropped Work.typeRaw, so the snapshot's default is that enum's only live referent, and it looks unused to every tool.
EntryCitations types each arm, so "a provenance kind without its cited rule" is unspellable (Q14) — which is why the derived accessors keep their getters and lose their setters.clearResidualEvidence was split out of validateAndClearResidualEvidence so the lagging arm runs it after the publish (+Bootstrap.swift:494-520).BackupArchiveProjection.swift:193-220).MembershipTestSupport's column doors are gone, and CitationSurgery.swift centralises the deliberate-corruption helpers..private scratch container without an entitlement constructs but never syncs. The real risk — a V8 device still writing dropped fields while the other has migrated — is a two-device state no host test can hold; prerequisites.md closes it procedurally.Entry.init seeds the default blob and export does not refuse nil-blob rows (Q26), so a post-V9 export → import round trip hands them default citations and zeroes the counter. Read entriesWithoutCitationBlobCount before any post-V9 export.reconcile-noop-coherent is still 3.0× its ceiling (0.0296–0.0302 s against 10 ms), now entirely membership dedupe fetching 1,000 rows per pass. Recorded as a T-2280 comment; no ticket.schema-migration.md:189-207.Fully implemented, with one recorded divergence and two stale comments.
"8" library opens intact; "9" published only after validation — Fully. V8RecordedStoreTests (552 lines) takes a genuinely 8.0.0-recorded store through the stage and asserts every Work, membership, Entry field, blob, TitlePattern, URL rule, Site, Character, suppression and WorkDistinctPair survives; MarkerGenerationNineTests:94-195 pins the sequence and that a validation failure leaves "8" in place."9" only — Fully; both halves of the fork at MarkerGenerationNineTests:229-270.ModelContractTests.droppedColumns (:228-300) pins their absence from Schema(...).entities, and a second test pins that the only file naming them is the frozen snapshot.citationValues / editCitations, validator report).[V8, V9] and the listed deletions — Fully except one documented divergence: SiteRelationshipPopulationPass survives minus its Work.site loop (Q27, 22 live callers; smolspec amended in-branch), its 773-line suite deleted. AsterismSchemaV5/V6/V7, V8PopulationPass, the "7" arm, LegacyColumns, validateStoreStrictly and the Q101 facade are gone by grep.BackupGoldenExportTests.recordedArchiveRestoresIntoAV9Library imports the pre-V9 golden bytes into a real store, validates strict and re-exports byte-identical. No golden re-recorded.MembershipReconciler.run(…, heals:); on both tiers per Q20 (a deliberate divergence from multi-site-works Decision 5's tier shape).make test-core/test-quick clean; test-performance-m4 exits 0, both blocks re-measured — Fully per verification-run.md (two runs, exit 0, 8 known issues; observation pass retired at 1.008–1.025 s; reconcile-noop-coherent kept at 0.0296–0.0302 s, comment naming the residual cost). No test was run in this review: that claim rests on the task list.entriesWithoutCitationBlobCount kept as a report — Fully in behaviour (LibraryRepository.swift:182-196 calls both blob counters "reports, not gates").Previously flagged, now resolved: two stale doc comments (LibraryDiagnostics.swift on the counters, LibraryValidator.swift on the unrecognised spelling) named V8PopulationPass and LegacyColumns after both were deleted; the pre-push review fix (f3e2565) retargeted them.
Not verifiable here, by design: the device install, the two-device update window and the only CloudKit probe this change gets (Q31, verification-run.md §6) — all owner steps under CLAUDE.md's device rule.
Packages/AsterismCore/Sources/AsterismCore/Models.swift
Why it matters. A nil blob is a diagnosis, not an ambiguity. Get the nil arm wrong and the spec's only mitigation for its headline risk (entriesWithoutCitationBlobCount) silently zeroes on the first consolidation.
What to look at. Models.swift — Entry.init, editCitations, citationsAreAbsent, EntryCitations.defaultBlob
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift
Why it matters. With no data pass left to self-certify, validation is the certificate and marker ordering is the whole safety argument. A failed publish must leave "8" and its recovery evidence intact.
What to look at. act(on: .markerLagging), clearResidualEvidence, laggingOpenableMarkerVersion
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift
Why it matters. The 36× fall in the full-tier no-op reconcile (1.07 s → 0.030 s), and it changes when a membership-less Work is repaired: counted by one refresh, healed by the next reconcile.
What to look at. reconcileAfterSync, MembershipReconciler.run(heals:)
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift
Why it matters. Over the frozen V8 snapshot it aborts the whole process with NSUnknownKeyException on Site.works once the stage removes columns — a crash that additive snapshots only showed as silently dropped columns.
What to look at. validateImportPlanPayload scratch ModelContainer
Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift
Why it matters. Without the second relaxation the first V9 open on a device holding one share-sheet capture would have withdrawn the manual re-assignment that is the row's only repair.
What to look at. validateCitations nil-blob count, validateAssignment .none arm reading entry.citationsAreAbsent
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV8.swift
Why it matters. The only host-side evidence the removing stage preserves live data. The snapshot was diffed mechanically against the pre-feature Models.swift; the fixture populates both sides of every supersession, composites, a WorkDistinctPair and a nil-blob Entry.
What to look at. AsterismSchemaV8 (frozen), V8RecordedStoreFixture, V8RecordedStoreTests
Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift
Why it matters. The unbudgeted read-path regression and the one measurement that got worse (backup-projection-duplicate-free +14%). primaryMembership uses min rather than a sort and is passed into authoredContent so representativeComponents pays once.
What to look at. sortedWorkRows, authoredContent(of:primary:typeAssignment:), Work.primaryMembership
Pre-V9 archive restores on a kept V8 build; population is one user (Q1)
Every device confirmed at marker "8" — the retire-migration-chain Decision 6 precondition for every stage below V8 (Q2)
openContainer passes the plan for both roles; only the marker keeps a destructive stage out of the extension (Q3)
Needs a second container over the frozen snapshot — rejected by multi-site-works Decision 3, and it poisons the entity registry (Q4)
It exists to derive the blob from columns that no longer exist (Q5)
Throwing on an ordinary read makes one exposed row fail a screen (Q6)
Predates V8, unattributed (T-2093); V9 only recovers the mirror's headroom (Q7)
The launch reconcile runs moments later on the same store (Q9)
A Work whose membership never arrives is still worth one repair (Q10)
Two nil rows hold the same nothing; prefix kept distinct from raw: (Q11)
Half the 1.09 s was the heal walk; a measurement decides whether the block retires (Q12)
Typed arms make "a kind without its cited rule" unspellable (Q14)
Enum tolerance is for a newer build's spelling; a malformed row is a fault in the row (Q15)
The stored shape is unchanged there, and the V7-shaped fixtures write it deliberately (Q17)
Phase 1 emptied the fields; phase 2 removed them, and a field that no longer exists cannot be recorded as empty (Q19, Q30)
Nothing to convert from on a live library, but the V5/V6/V7 fixtures needed the conversion until they were deleted (Q21)
No production cost is left for the number to describe (Q22)
Refusing recovers nothing; a partially-degraded archive beats no archive (Q26)
All six suites derived to untyped, which is what a Work with no workTypeID already is (Q28)
Pure removal, and an unentitled .private container proves only that it constructs (Q31)
The reading that reaches it is still "below V5"; StoreMetadata positively identifies nothing else (Q35)
Both tiers now measure the same thing; re-scaling would invent a property rather than pin one (Q34)
A stage that only removes changes no wire shape (smolspec Req 6; schema-migration.md:206)
A publish failing on a full disk must leave the recovery evidence in place (inline, +Bootstrap.swift:508-518)
Gating the dedupe phases is a design decision this spec does not take (verification-run.md §2.1, §4.2)
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | MembershipReconciler.swift | Three dead `touched: [UUID: Work]` dictionaries survived the refreshColumns deletion; the dedupe one faulted membership.work per group for a value nothing read. | Deleted all three and their writes; healCreatedAt reads siteMemberships without sorting. |
| major | Citation blob decodes per row | Provenance accessors became JSON decodes; the teach, reparse, duplicate-resolution and duplicate-reconcile writers decoded 2–5 times and encoded twice per Entry. | One citationValues hoist per row and one editCitations per row; applyAssignmentProvenance takes the blob inout; the identity switch folded into one. |
| minor | LibraryValidator.swift | validateAssignment grew a citationsAreAbsent: Bool parameter and was the only reader of raw citationsData bytes outside Models.swift. | Entry.citationsAreAbsent accessor; parameter dropped. |
| minor | Models.swift Entry.init | try? JSONBlob.encode(EntryCitations()) conflated an encode failure with the nil-blob sentinel. | EntryCitations.defaultBlob static, pinned by EntryCitationsTests.defaultBlobDecodes. |
| minor | LibraryRepository+Bootstrap.swift | act/certifyForApp still carried an unused saveStrategy parameter with doc claiming the population pass commits through it; "8" was a raw literal beside a named constant. | Parameter removed; laggingOpenableMarkerVersion added; docs corrected. |
| minor | Stale doc comments | Nine comments still named LegacyColumns / V8PopulationPass / "lagging V7 writer", including the doc of entriesWithoutCitationBlobCount — the spec's headline mitigation. | Retargeted to Q6/Q23/Q25/Q32; tombstones that name deleted symbols on purpose left alone. |
| minor | Test gaps | clearResidualEvidence ordering, Work.primaryMembership tie-break, and the Q32 nil-blob-beside-Work validator arm had no focused test. | Three tests added (aFailedPublishKeepsTheResidualEvidence, tie-break line, nilCitationBlobKeepsItsWorkLink). |
| minor | Spec/doc bookkeeping | Golden archive not re-recorded and two suite renames unrecorded; column count stated as ~36 / 37 / 35 across OVERVIEW, CHANGELOG and the smolspec; prerequisites Before-Release item unticked at ~25 min; OVERVIEW status a bare "Done". | Q36/Q37 added; count unified to 36 attributes + the Work.site/Site.works pair (38 names); prerequisites ticked at ~21 min; OVERVIEW status names the owner's remaining device steps. |
| minor | LibraryRepository+BackupImportGates.swift | Review suspected the validateStoreStrictly deletion unwrapped validator errors. | Verified: the gate already called validateStrict directly before this branch; the deleted function had no callers. No change. |
| minor | LibraryRepository.swift worksWithoutMembership | Review called it an alias of diagnostics.shape.worksWithoutMembershipCount. | Not an alias — LibraryShape carries no such count and union drops it; only the membershipHealRuns local alias was collapsed. |
| minor | LibraryRepository+ConfirmImport.swift | try? entry.setCitations(record.citations) swallows an encode failure. | Enclosing apply does not throw; the value came out of JSONDecoder as the same type moments earlier — comment added, behaviour unchanged. |
Click to expand.
diff --git a/Asterism/AsterismTests/StatsDerivationTests.swift b/Asterism/AsterismTests/StatsDerivationTests.swiftindex 98a3ac0..02a012b 100644--- a/Asterism/AsterismTests/StatsDerivationTests.swift+++ b/Asterism/AsterismTests/StatsDerivationTests.swift@@ -1683,21 +1683,21 @@ private struct StatsStoreFixture { // simplification and is wrong; the presentation has already // collapsed the group to its carrier's assignment. splitRows[0].work = target- splitRows[0].workAssignmentProvenance = .manual+ splitRows[0].editCitations { $0.workAssignment = .manual } splitRows[1].work = source- splitRows[1].workAssignmentProvenance = .manual+ splitRows[1].editCitations { $0.workAssignment = .manual } let blank = work(id: UUID(), title: "", in: context) let blankEntry = entry(title: "Untitled Work Chapter", path: "blank-1", day: 6) context.insert(blankEntry) blankEntry.work = blank- blankEntry.workAssignmentProvenance = .manual+ blankEntry.editCitations { $0.workAssignment = .manual } } let keptEntry = entry(title: "Kept Chapter", path: "kept-1", day: 7) context.insert(keptEntry) keptEntry.work = target- keptEntry.workAssignmentProvenance = .manual+ keptEntry.editCitations { $0.workAssignment = .manual } // Decision 6's unhydrated row: it counts in the notes total and appears // in no bar. let foldedEntry = entry(@@ -1705,7 +1705,7 @@ private struct StatsStoreFixture { capturedAt: Date(timeIntervalSince1970: 0)) context.insert(foldedEntry) foldedEntry.work = source- foldedEntry.workAssignmentProvenance = .manual+ foldedEntry.editCitations { $0.workAssignment = .manual } try context.save() }
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex b5bc55f..51ec479 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Deletions and verification (drop-superseded-columns, phase 3,+ T-2280).** The V7-shaped test facade (`MembershipTestSupport`'s column+ doors, `WorkMergeOutcome.workURL/identityEvidence/identityDisposition`)+ and the dead `validateStoreStrictly` gate are gone. The 7/8 golden+ archive exported by the V8 build imports into a V9 library and+ re-exports byte-identical. `make test-performance-m4` re-measured on+ V9 (`specs/drop-superseded-columns/verification-run.md`): the+ full-tier no-op reconcile fell from ~1.08 s to ~0.030 s and its+ regression floor is 100 ms (Q33); the observation-pass known issue is+ retired at 1.01 s; eight known issues remain, all with ceilings. Ten+ labels fell 14–27% with Entry seventeen columns lighter;+ `backup-projection-duplicate-free` rose 14% from the Q18 membership+ fault (recorded on T-2280, no budget). Agent notes, `CLAUDE.md`,+ `prerequisites.md` (rollback to V8 needs a wiped store plus the pre-V9+ archive; read the nil-blob count before any post-V9 export) and the+ specs overview describe V9.++- **Schema V9 (drop-superseded-columns, phase 2, T-2280).** The live+ schema is `AsterismSchemaV9` (9.0.0) and the plan is `[V8, V9]`, one+ lightweight stage dropping the 36 columns V8 retained unread plus the+ `Work.site` / `Site.works` pair — 38 names in all+ (`ModelContractTests.droppedColumns`). `AsterismSchemaV8` is the frozen+ snapshot (verified byte-for-byte against the pre-feature shape);+ `AsterismSchemaV5/V6/V7`, their recorded-store fixtures, `LegacyColumns`+ and `V8Shape` are deleted. `V8RecordedStoreTests` takes a real+ 8.0.0-recorded store — composites, identity keys, a `WorkDistinctPair`+ and a nil-blob Entry included — through the stage and pins that the+ extension refuses `"8"` before the container can drop a column.+ `SiteRelationshipPopulationPass` survives minus its `Work.site` loop+ (Q27); the backup-import scratch container is built over the live+ schema (Q29 — over V8 it aborted the process once the stage removed+ columns); a nil-blob Entry that kept its Work link no longer trips+ the validator's `.none` arm (Q32). Graph baseline re-recorded as+ format 5 (Q30).++- **Blob-only write paths (drop-superseded-columns, phase 1, T-2280).**+ Every Entry citation is written to the blob and only the blob:+ `Entry.init` seeds the default blob (Q13), the provenance properties+ are read-only views over it with `editCitations` as the single+ read-modify-write door (Q14), and `LegacyColumns.refreshCitations` /+ `refreshMembership` have no callers. A nil blob reads as the default,+ is counted by `entriesWithoutCitationBlobCount` and never quarantines+ its hostname (Q25); a no-op edit leaves it nil so a consolidation+ cannot zero the count (Q23); undecodable bytes are repaired from the+ default on the next write (Q24). `GroupOrdering` reads memberships+ and blobs only, faulting one primary membership per Work (Q18), and a+ TitlePattern with no definition orders under the Q11 constant key.+ The app opens markers `"8"` and `"9"` and publishes `"9"` after+ validation with no data pass; the extension opens `"9"` only.+ `V8PopulationPass` and the conversion phases are deleted;+ `reconcileAfterSync` heals memberships only when the diagnosis counts+ a Work without one (Q20). The columns still exist and are written by+ nothing; they go at V9 in phase 2.+ - **Membership-scale measurements and tiered reconciliation (multi-site-works, phase 6: Performance, T-2230).** New `M4MembershipScalePerformanceTests` (in `make test-performance-m4`,
diff --git a/CLAUDE.md b/CLAUDE.mdindex 5cb0c89..3b83d3a 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -43,7 +43,7 @@ invocations where a target exists. - `make test-core` — AsterismCore package tests (host, fast, safe). Since `rule-suggestion` the package has a second product, `AsterismIntelligence` (linked by the app and `AsterismTests` only — never the share extension), and its tests include **two live Apple Intelligence calls** — one per pipeline, decoding into `RuleProposal` and (since `character-extraction`) into `ExtractionResult` — both of which degrade to a `withKnownIssue` when the host has no model available. On a host that does have the model, a transient `GenerationError` (rate limited, assets unavailable) is also a known issue — only a response that will not decode into the expected structure fails the target, so the pre-commit bar stays deterministic either way. - `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. **~25 minutes** (1,494 s measured 2026-08-26, including a ~190 s release build): 62% of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **nine** since `multi-site-works` (it was four, plus an intermittent fifth). Four are long-standing: Req 10.1's settling pass and Req 5.5's three diagnosis re-derivations. Five joined with `multi-site-works` and are recorded, with their causes and the decisions they are waiting on, in `specs/multi-site-works/verification-run.md` §4 and §7: the **full**-tier no-op reconcile (1.82–2.00 ms → ~1.07 s, since `reconcileAfterSync` gained two whole-library conversion passes), Req 10.1's observation pass (~0.94 s → ~2.69 s, derivative of it), and Req 5.4's three capture-projection arms (0.093–0.102 s → 0.166–0.178 s, the one on a path the reader waits on). Task 22's review halved the capture arms and gated the conversion passes off the **arrival** tier (Decision 5 of that spec), so the debounce measures 0.030 s; the three that remain are full-tier passes — launch and the duplicate follow-up — and go with the columns at V9 (T-2272). Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/multi-site-works/verification-run.md` for the current numbers, `specs/retire-migration-chain/verification-run.md` for the previous ones, and `docs/agent-notes/testing.md` for recording a band.+- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, plus a ~190 s release build): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **eight** since `drop-superseded-columns` (four before `multi-site-works`, nine after it). Four are long-standing: Req 10.1's settling pass and Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s, the one on a path the reader waits on). The eighth is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table. Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget). Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/drop-superseded-columns/verification-run.md` for the current numbers, `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band. - `make test-performance-chunks` — host-only calibration sweep of the shared bulk chunk constant (import commits and the reconciler re-pin). No device, safe to run, but gated on `ASTERISM_RUN_CHUNK_SWEEP=1` and **~20 minutes per run**, so it is deliberately *not* part of `make test-performance-m4`. It asserts nothing — a calibration is reported, not budgeted. Re-run it when the bulk write paths change (Q53 and the task 25 section of `specs/cloudkit-mirroring/implementation.md`). - `make test-performance-m4-recent` — **physical device, see above**
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift b/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swiftindex ce027c3..c8ae59f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift@@ -87,17 +87,14 @@ internal enum ArchiveRecordBuilders { return row } - /// The Work row alone. A 7/8 record names no site (Req 9.1), so the- /// superseded `siteHostname` column starts empty and- /// `LegacyColumns.refreshMembership` fills it from the membership records- /// once they are wired — the same mirror every other Work writer keeps- /// (Decision 4), **until V9 (T-2272)** drops the columns and the mirror- /// with them.+ /// The Work row alone. A 7/8 record names no site (Req 9.1), and since V9 a+ /// Work has no site column to name: its site presence is its+ /// `WorkSiteMembership`, which `makeMembership` below builds from the+ /// archive's own records. static func makeWork(_ record: BackupV7Work) -> Work { let work = Work( id: record.id, displayTitle: record.displayTitle,- siteHostname: "", timestamp: record.createdAt ) LibraryRepository.apply(record, to: work)
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swiftdeleted file mode 100644index 60d0290..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift+++ /dev/null@@ -1,175 +0,0 @@-import Foundation-import SwiftData--/// The frozen M4a schema — the shape every installed library was written by-/// before `configurable-work-types`, and the `from` version of the V5 → V6-/// lightweight stage.-///-/// V5 carries the two relational references M4a 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). It does **not** carry `Work.workTypeID` or `WorkTypeEntity`; those are-/// V6's additions.-///-/// V5 is frozen because *any* edit to its body makes a V5-recorded store refuse-/// to open: `addPersistentStore` fails with `NSCocoaErrorDomain` 134504,-/// "Cannot use staged migration with an unknown model version" (measured for the-/// V4 snapshot this file is modelled on, Q20 of `retire-migration-chain`). The-/// live classes therefore moved on, and this declaration exists only to give-/// `AsterismV8MigrationPlan` the `from` version of its first stage.-///-/// The classes are nested here so they can carry the same SwiftData entity names-/// ("Entry", "Site", …) as the live V6 classes without a top-level collision:-/// the only top-level references are typealiases, and two *top-level* `@Model`s-/// sharing an entity name crash `ModelContext` (`docs/agent-notes/schema-migration.md`).-/// Nothing reads a V5-shaped object at runtime, so these carry stored columns-/// only — no accessors, no business logic.-///-/// # 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 live V6 classes. Editing any of them changes the stored shape of this-/// frozen schema silently — and 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 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]- }-}--extension AsterismSchemaV5 {- @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 site: Site?- 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 site: Site?- 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]?- /// Inverse of `Entry.site`, present only because CloudKit requires every- /// relationship to have one. Internal for the same reason the live class- /// keeps it internal (Q17): traversing it faults every Entry for a- /// hostname.- @Relationship(deleteRule: .nullify, inverse: \Entry.site)- var entries: [Entry]?- /// Inverse of `Work.site`. Same reasoning as `entries`.- @Relationship(deleteRule: .nullify, inverse: \Work.site)- var works: [Work]?- public var urlIdentityRule: URLIdentityRule?- public var junkSuffixRule: JunkSuffixRule?-- public init() {}- }-- @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() {}- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swiftdeleted file mode 100644index a3da2b7..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swift+++ /dev/null@@ -1,186 +0,0 @@-import Foundation-import SwiftData--/// The frozen `configurable-work-types` schema — the shape every installed-/// library was written by before `character-extraction`, and the `from` version-/// of the V6 → V7 lightweight stage.-///-/// V6 is V5 plus the `WorkTypeEntity` table and `Work.workTypeID`. It does-/// **not** carry `Character`, `CharacterSuppression`, or the two extraction-/// fingerprint columns; those are V7's additions.-///-/// V6 is frozen for the same reason V5 is: *any* edit to its body makes a-/// V6-recorded store refuse to open with `NSCocoaErrorDomain` 134504, "Cannot-/// use staged migration with an unknown model version". The live classes-/// therefore moved on (V7 is frozen beside this file and V8 is live), and this-/// declaration exists only to give `AsterismV8MigrationPlan` a `from` version —-/// and to let `V6RecordedStoreFixture` seed a genuinely 6.0.0-recorded store-/// in-process.-///-/// The classes are nested so they can carry the same SwiftData entity names-/// ("Entry", "Site", …) as the live V7 classes without a top-level collision:-/// the only top-level references are typealiases, and two *top-level* `@Model`s-/// sharing an entity name crash `ModelContext`-/// (`docs/agent-notes/schema-migration.md`). Nothing reads a V6-shaped object at-/// runtime, so these carry stored columns only — no accessors, no business-/// logic.-///-/// # 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 live classes. Editing any of them changes the stored shape of this-/// frozen schema silently — and that is exactly what makes a recorded store-/// refuse to open (134504).-public enum AsterismSchemaV6: VersionedSchema {- public static let versionIdentifier = Schema.Version(6, 0, 0)-- public static var models: [any PersistentModel.Type] {- [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self,- WorkTypeEntity.self]- }-}--extension AsterismSchemaV6 {- @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 site: Site?- 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 site: Site?- 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 workTypeID: UUID?- 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]?- /// Inverse of `Entry.site`, present only because CloudKit requires every- /// relationship to have one. Internal for the same reason the live class- /// keeps it internal (Q17): traversing it faults every Entry for a- /// hostname.- @Relationship(deleteRule: .nullify, inverse: \Entry.site)- var entries: [Entry]?- /// Inverse of `Work.site`. Same reasoning as `entries`.- @Relationship(deleteRule: .nullify, inverse: \Work.site)- var works: [Work]?- public var urlIdentityRule: URLIdentityRule?- public var junkSuffixRule: JunkSuffixRule?-- public init() {}- }-- @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() {}- }-- @Model- public final class WorkTypeEntity {- public var id: UUID = UUID()- public var name: String = ""- public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)- public var stateRaw: String = WorkTypeState.active.rawValue- public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)- public var canonicalID: UUID?- public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var modifiedAt: Date = Date(timeIntervalSince1970: 0)-- public init() {}- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV7.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV7.swiftdeleted file mode 100644index 9fd3f01..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV7.swift+++ /dev/null@@ -1,222 +0,0 @@-import Foundation-import SwiftData--/// The frozen `character-extraction` schema — the shape every installed library-/// was written by before `multi-site-works`, and the `from` version of the-/// V7 → V8 lightweight stage.-///-/// V7 is V6 plus the `Character` and `CharacterSuppression` tables and the two-/// extraction fingerprint columns. It does **not** carry `WorkSiteMembership`,-/// `WorkDistinctPair`, `Entry.citationsData` or `TitlePattern.definitionData`;-/// those are V8's additions.-///-/// V7 is frozen for the same reason V5 and V6 are: *any* edit to its body makes-/// a V7-recorded store refuse to open with `NSCocoaErrorDomain` 134504, "Cannot-/// use staged migration with an unknown model version". The live classes-/// therefore moved to `AsterismSchemaV8`, and this declaration exists only to-/// give `AsterismV8MigrationPlan` a `from` version — and to let-/// `V7RecordedStoreFixture` seed a genuinely 7.0.0-recorded store in-process.-///-/// The classes are nested so they can carry the same SwiftData entity names-/// ("Entry", "Site", …) as the live V8 classes without a top-level collision:-/// the only top-level references are typealiases, and two *top-level* `@Model`s-/// sharing an entity name crash `ModelContext`-/// (`docs/agent-notes/schema-migration.md`). Nothing reads a V7-shaped object at-/// runtime, so these carry stored columns only — no accessors, no business-/// logic.-///-/// # 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 live classes. Editing any of them changes the stored shape of this-/// frozen schema silently — and that is exactly what makes a recorded store-/// refuse to open (134504).-public enum AsterismSchemaV7: VersionedSchema {- public static let versionIdentifier = Schema.Version(7, 0, 0)-- public static var models: [any PersistentModel.Type] {- [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self,- WorkTypeEntity.self, Character.self, CharacterSuppression.self]- }-}--extension AsterismSchemaV7 {- @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 site: Site?- 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 var characterExtractionFingerprint: String?-- 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 site: Site?- 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 workTypeID: UUID?- 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)- public var genericNotesExtractionFingerprint: String?- @Relationship(deleteRule: .nullify, inverse: \Entry.work)- public var entries: [Entry]?- @Relationship(deleteRule: .nullify, inverse: \Character.work)- public var characters: [Character]?- @Relationship(deleteRule: .nullify, inverse: \CharacterSuppression.work)- public var characterSuppressions: [CharacterSuppression]?-- 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]?- /// Inverse of `Entry.site`, present only because CloudKit requires every- /// relationship to have one. Internal for the same reason the live class- /// keeps it internal (Q17): traversing it faults every Entry for a- /// hostname.- @Relationship(deleteRule: .nullify, inverse: \Entry.site)- var entries: [Entry]?- /// Inverse of `Work.site`. Same reasoning as `entries`.- @Relationship(deleteRule: .nullify, inverse: \Work.site)- var works: [Work]?- public var urlIdentityRule: URLIdentityRule?- public var junkSuffixRule: JunkSuffixRule?-- public init() {}- }-- @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() {}- }-- @Model- public final class WorkTypeEntity {- public var id: UUID = UUID()- public var name: String = ""- public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)- public var stateRaw: String = WorkTypeState.active.rawValue- public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)- public var canonicalID: UUID?- public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var modifiedAt: Date = Date(timeIntervalSince1970: 0)-- public init() {}- }-- @Model- public final class Character {- public var id: UUID = UUID()- public var name: String = ""- public var nameKey: String = ""- public var aliases: [String] = []- public var note: String = ""- public var factsData: Data?- public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var modifiedAt: Date = Date(timeIntervalSince1970: 0)- public var work: Work?-- public init() {}- }-- @Model- public final class CharacterSuppression {- public var id: UUID = UUID()- public var work: Work?- public var kindRaw: String = CharacterSuppressionKind.candidate.rawValue- public var nameKey: String = ""- public var sourceKindRaw: String?- public var sourceEntryID: UUID?- public var evidence: String?- public var statusRaw: String = CharacterSuppressionStatus.active.rawValue- public var actionAt: Date = Date(timeIntervalSince1970: 0)-- public init() {}- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV8.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV8.swiftindex fb9411c..fdf9fc8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV8.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV8.swift@@ -1,26 +1,65 @@ import Foundation import SwiftData -/// The runtime schema. Its body is `Models.swift`, which opens-/// `extension AsterismSchemaV8`.+/// The frozen `multi-site-works` schema — the shape every installed library was+/// written by before `drop-superseded-columns`, and the `from` version of the+/// V8 → V9 lightweight stage. ///-/// V8 is V7 plus what `multi-site-works` needs: the `WorkSiteMembership` table-/// (Q3 — a Work's site presence becomes a row per hostname), the-/// `WorkDistinctPair` table (Q20/Q27 — a dismissed cross-site duplicate-/// candidate), and the two Codable blob columns `Entry.citationsData` and-/// `TitlePattern.definitionData` (Q25).+/// V8 is V7 plus the `WorkSiteMembership` and `WorkDistinctPair` tables and the+/// two Codable blob columns `Entry.citationsData` and+/// `TitlePattern.definitionData`. **It only added**: the columns those three+/// superseded stayed in the stored shape, because the lightweight stage runs+/// inside `ModelContainer.init` and a stage that dropped them would have+/// destroyed the source before `V8PopulationPass` could copy it. This snapshot+/// is what that shape looked like — `Work`'s six site/identity/URL columns and+/// `typeRaw`, `Entry.identityKeyVersion` and its seventeen citation columns,+/// `TitlePattern`'s ten definition columns, `Site.urlIdentityRule`, and the+/// `Work.site` ↔ `Site.works` inverse pair — all of which V9 drops. ///-/// **V8 only adds** (Decision 3). Every superseded column — `Work`'s six-/// site/identity/URL columns and `typeRaw`, `Entry.identityKeyVersion` and its-/// citation columns, `TitlePattern`'s ten definition columns,-/// `Site.urlIdentityRule` — stays in the stored shape with no live accessor and-/// no writer, because the lightweight stage runs inside `ModelContainer.init`-/// and a stage that dropped them would destroy the source before-/// `V8PopulationPass` could read it. V9 (T-2272) inherits the drop.+/// V8 is frozen for the same reason V5, V6 and V7 were: *any* edit to its body+/// makes a V8-recorded store refuse to open with `NSCocoaErrorDomain` 134504,+/// "Cannot use staged migration with an unknown model version". The live classes+/// therefore moved to `AsterismSchemaV9`, and this declaration exists only to+/// give `AsterismV9MigrationPlan` a `from` version — and to let+/// `V8RecordedStoreFixture` seed a genuinely 8.0.0-recorded store in-process. ///-/// Every addition is CloudKit-legal by construction: every property is defaulted-/// or optional, nothing is unique, and every new relationship is `.nullify` with-/// its inverse declared on the to-many side.+/// The classes are nested so they can carry the same SwiftData entity names+/// ("Entry", "Site", …) as the live V9 classes without a top-level collision:+/// the only top-level references are typealiases, and two *top-level* `@Model`s+/// sharing an entity name crash `ModelContext`+/// (`docs/agent-notes/schema-migration.md`). Nothing reads a V8-shaped object at+/// runtime, so these carry stored columns only — no accessors, no business+/// logic.+///+/// # These snapshots are frozen *by reference*, not only by file+///+/// The nesting freezes the class bodies; it does **not** freeze anything a body+/// *names*. Editing one of those changes the stored shape of this frozen schema+/// silently — and that is exactly what makes a recorded store refuse to open+/// (134504). Two families of referent, both live and both shared with V9:+///+/// * **The stored value types.** `SegmentRangeSpec`, `SegmentPositionSpec`,+/// `URLIdentityRule` and `JunkSuffixRule` are top-level types in+/// `ValueObjects.swift`. Their stored properties are this schema's stored+/// properties.+/// * **Every enum whose raw value is baked into a default.** A default is part+/// of the shape, so `WorkType.other.rawValue`, `PatternForm.segment.rawValue`,+/// `FieldProvenanceKind.none.rawValue`, `WorkURLIdentityState.none.rawValue`,+/// `SiteMode.untaught.rawValue`, `CaptureTitleSource.manual.rawValue`,+/// `EntryIdentityBasis.conservative.rawValue`, `TitleProvenance.manual`,+/// `URLRuleOrigin.readerTaught`, `WorkTypeState.active`,+/// `CharacterSuppressionKind.candidate` and+/// `CharacterSuppressionStatus.active` are all frozen *spellings* here, not+/// merely frozen references. Renaming a case, or reordering one whose raw+/// value is derived rather than written out, edits this file without touching+/// it.+///+/// `WorkType` is the one to watch: V9 dropped `Work.typeRaw`, so this+/// snapshot's default is the enum's **only** live referent. Nothing else in+/// the package reads it, which makes it look unused to every tool and to+/// every reader who has not read this paragraph. It is not — it is a frozen+/// persisted default, and it may not be edited or deleted while a store+/// recorded at 8.0.0 can still exist. public enum AsterismSchemaV8: VersionedSchema { public static let versionIdentifier = Schema.Version(8, 0, 0) @@ -31,33 +70,216 @@ public enum AsterismSchemaV8: VersionedSchema { } } -/// The migration plan: `[V5, V6, V7, V8]`, three lightweight stages.-///-/// **The V5 stage stays** (Q80 of `character-extraction`). Retiring it would-/// carry `retire-migration-chain` Decision 6's population precondition — every-/// device verified past the migration — for no gain here, and dropping it would-/// make every V5-seeded fixture unopenable, because declaring a plan makes a-/// store older than the plan's oldest schema fail closed rather than convert-/// implicitly (`docs/agent-notes/schema-migration.md`).-///-/// All three stages are `.lightweight`. V7 → V8 adds two tables and two columns-/// and changes nothing that exists, so `ModelContainer.init` runs the whole-/// conversion; the *data* pass that fills the new rows is `V8PopulationPass`,-/// run by the bootstrap's marker-lagging arm after the open (Decision 3, Q13).-/// None is `.custom`: a custom stage never fires between structurally identical-/// models, and it would also run inside the share extension, which must never-/// migrate.-public enum AsterismV8MigrationPlan: SchemaMigrationPlan {- public static var schemas: [any VersionedSchema.Type] {- [AsterismSchemaV5.self, AsterismSchemaV6.self, AsterismSchemaV7.self,- AsterismSchemaV8.self]- }-- public static var stages: [MigrationStage] {- [- .lightweight(fromVersion: AsterismSchemaV5.self, toVersion: AsterismSchemaV6.self),- .lightweight(fromVersion: AsterismSchemaV6.self, toVersion: AsterismSchemaV7.self),- .lightweight(fromVersion: AsterismSchemaV7.self, toVersion: AsterismSchemaV8.self),- ]+extension AsterismSchemaV8 {+ @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 site: Site?+ 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 var characterExtractionFingerprint: String?+ public var citationsData: Data?++ 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 site: Site?+ 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 workTypeID: UUID?+ 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)+ public var genericNotesExtractionFingerprint: String?+ @Relationship(deleteRule: .nullify, inverse: \Entry.work)+ public var entries: [Entry]?+ @Relationship(deleteRule: .nullify, inverse: \Character.work)+ public var characters: [Character]?+ @Relationship(deleteRule: .nullify, inverse: \CharacterSuppression.work)+ public var characterSuppressions: [CharacterSuppression]?+ @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.work)+ public var siteMemberships: [WorkSiteMembership]? = []++ 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]?+ /// Inverse of `Entry.site`, present only because CloudKit requires every+ /// relationship to have one. Internal for the same reason the live class+ /// keeps it internal (Q17): traversing it faults every Entry for a+ /// hostname.+ @Relationship(deleteRule: .nullify, inverse: \Entry.site)+ var entries: [Entry]?+ /// Inverse of `Work.site`. Same reasoning as `entries`. **V9 drops both+ /// halves of this pair**, which is the one relationship change the+ /// V8 → V9 stage makes.+ @Relationship(deleteRule: .nullify, inverse: \Work.site)+ var works: [Work]?+ /// Inverse of `WorkSiteMembership.site`. Same reasoning again.+ @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.site)+ var workMemberships: [WorkSiteMembership]?+ public var urlIdentityRule: URLIdentityRule?+ public var junkSuffixRule: JunkSuffixRule?++ public init() {}+ }++ @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 definitionData: Data?+ 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() {}+ }++ @Model+ public final class WorkTypeEntity {+ public var id: UUID = UUID()+ public var name: String = ""+ public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)+ public var stateRaw: String = WorkTypeState.active.rawValue+ public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)+ public var canonicalID: UUID?+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var modifiedAt: Date = Date(timeIntervalSince1970: 0)++ public init() {}+ }++ @Model+ public final class Character {+ public var id: UUID = UUID()+ public var name: String = ""+ public var nameKey: String = ""+ public var aliases: [String] = []+ public var note: String = ""+ public var factsData: Data?+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+ public var work: Work?++ public init() {}+ }++ @Model+ public final class CharacterSuppression {+ public var id: UUID = UUID()+ public var work: Work?+ public var kindRaw: String = CharacterSuppressionKind.candidate.rawValue+ public var nameKey: String = ""+ public var sourceKindRaw: String?+ public var sourceEntryID: UUID?+ public var evidence: String?+ public var statusRaw: String = CharacterSuppressionStatus.active.rawValue+ public var actionAt: Date = Date(timeIntervalSince1970: 0)++ public init() {}+ }++ @Model+ public final class WorkSiteMembership {+ public var id: UUID = UUID()+ public var hostname: String = ""+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var urlIdentity: String?+ public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue+ public var urlIdentityRuleID: UUID?+ public var workURLString: String?+ public var workID: UUID?+ public var work: Work?+ public var site: Site?++ public init() {}+ }++ @Model+ public final class WorkDistinctPair {+ public var id: UUID = UUID()+ public var lowerWorkID: UUID = UUID()+ public var higherWorkID: UUID = UUID()+ public var recordedAt: Date = Date(timeIntervalSince1970: 0)++ public init() {} } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV9.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV9.swiftnew file mode 100644index 0000000..a733c74--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV9.swift@@ -0,0 +1,59 @@+import Foundation+import SwiftData++/// The runtime schema. Its body is `Models.swift`, which opens+/// `extension AsterismSchemaV9`.+///+/// V9 is V8 **minus** everything V8 superseded but could not drop. The+/// `WorkSiteMembership` row is a Work's site presence, the `Entry.citationsData`+/// blob is its citations and `TitlePattern.definitionData` is a pattern's+/// definition surface — so `Work`'s six site/identity/URL columns and its+/// `typeRaw`, `Entry.identityKeyVersion` and its seventeen citation columns,+/// `TitlePattern`'s ten definition columns and `Site.urlIdentityRule` are gone,+/// along with the `Work.site` ↔ `Site.works` inverse pair.+///+/// The drop is safe only because the population it undoes is finished: the+/// readiness marker read `"8"` on every device before this schema shipped, which+/// is `V8PopulationPass`'s completion certificate (decision_log.md Q2 of+/// `drop-superseded-columns`). The pass itself is deleted — it had nothing left+/// to convert from.+///+/// The entity *list* is unchanged: V9 adds no table and removes none.+public enum AsterismSchemaV9: VersionedSchema {+ public static let versionIdentifier = Schema.Version(9, 0, 0)++ public static var models: [any PersistentModel.Type] {+ [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self,+ WorkTypeEntity.self, Character.self, CharacterSuppression.self,+ WorkSiteMembership.self, WorkDistinctPair.self]+ }+}++/// The migration plan: `[V8, V9]`, one lightweight stage.+///+/// **Every stage below V8 is retired** (decision_log.md Q2 of+/// `drop-superseded-columns`). Every device is confirmed at marker `"8"`, which+/// is `retire-migration-chain` Decision 6's population precondition for each of+/// them, so `AsterismSchemaV5`, `AsterismSchemaV6` and `AsterismSchemaV7` went+/// with the stages that named them. A store older than V8 fails closed —+/// `NSCocoaErrorDomain` 134504, "Cannot use staged migration with an unknown+/// model version" — and the recovery is the backup archive, which is what+/// `V4RecordedStoreTests` pins.+///+/// The single stage is `.lightweight`. **This is the first stage that removes+/// anything**: 35 attributes and one inverse relationship pair. It runs inside+/// `ModelContainer.init`, which is why the columns had to be unwritten and every+/// reader moved onto the blob and the membership *before* this schema existed —+/// the conversion has no chance to read what it is about to destroy. `.custom`+/// is not an option: a custom stage would also run inside the share extension,+/// which must never migrate, and the extension is kept out by the marker instead+/// (Q3).+public enum AsterismV9MigrationPlan: SchemaMigrationPlan {+ public static var schemas: [any VersionedSchema.Type] {+ [AsterismSchemaV8.self, AsterismSchemaV9.self]+ }++ public static var stages: [MigrationStage] {+ [.lightweight(fromVersion: AsterismSchemaV8.self, toVersion: AsterismSchemaV9.self)]+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swiftindex dd1284e..16a5264 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift@@ -192,15 +192,14 @@ extension LibraryRepository { /// A Work's `typeRaw` is deliberately absent (Q8, Q34): V8 derives a type /// from the work-type identity alone, so the column is not read at all. ///- /// The Entry's three provenance raws are here for the reason every other- /// raw is, and Q53 does not reach them. Q53 retired the *validator's*- /// guards, where `EntryCitations` types the kind and the arm a guard would- /// have refused is unreachable. The export still reads the columns: an Entry- /// whose `citationsData` is nil falls back to `LegacyColumns.citations`- /// (Q36), which reads all three through `ToleratedEnum.read(default: .none)`- /// — so an unrecognised spelling a newer build wrote would be archived as- /// `.none`, silently, which is precisely the coercion Q8 refuses. The blob- /// is checked beside them, by its bytes.+ /// The Entry's three provenance raws used to be checked here, because a row+ /// whose `citationsData` was nil fell back to the columns and read all three+ /// through `ToleratedEnum.read(default: .none)` — an unrecognised spelling a+ /// newer build wrote would have been archived as `.none`, silently, which is+ /// the coercion Q8 refuses. Since V9 the blob is the only home a citation+ /// has, so the check is the blob's: bytes that will not decode are named+ /// here, and a kind this build has no case for is one of them. That is+ /// stricter than the raw guards were, not looser. private static func requireRepresentableValues( entries: [Entry], works: [Work], memberships: [WorkSiteMembership], sites: [Site], patterns: [TitlePattern], urlRules: [URLRulePattern], citations: EntryCitationsCache@@ -214,13 +213,6 @@ extension LibraryRepository { if let raw = entry.ratingRaw { try require(Rating(rawValue: raw), record, "rating", raw) }- try require(FieldProvenanceKind(rawValue: entry.chapterTitleProvenanceRaw),- record, "chapter provenance", entry.chapterTitleProvenanceRaw)- try require(FieldProvenanceKind(rawValue: entry.workAssignmentProvenanceRaw),- record, "work assignment provenance", entry.workAssignmentProvenanceRaw)- if let raw = entry.workURLAssignmentKindRaw {- try require(URLWorkAssignmentKind(rawValue: raw), record, "work URL assignment", raw)- } // The whole citation surface is one blob on the wire now (Q25), so a // row whose blob will not decode is named here by its bytes rather // than reached by a mapper that would throw a raw `DecodingError`.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swiftindex 6d44476..5dc0f5d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift@@ -782,10 +782,6 @@ enum DuplicateReconciler { removed += 1 } }- // The superseded columns mirror the primary membership (Decision 4), and- // a moved membership can change which one that is.- for row in survivor { LegacyColumns.refreshMembership(on: row) }- for pair in distinctPairs where !pair.isDeleted { let lower = loserIDs.contains(pair.lowerWorkID) ? target.id : pair.lowerWorkID let higher = loserIDs.contains(pair.higherWorkID) ? target.id : pair.higherWorkID@@ -1239,6 +1235,8 @@ enum DuplicateReconciler { /// variant and the set would not be here. private static func apply(_ carrier: Entry, to rows: [Entry]) -> Bool { var changed = false+ // One decode of the carrier's blob for the whole fan-out, not one per row.+ let carrierChapterIsManual = carrier.chapterTitleProvenance == .manual for row in rows where row !== carrier { if row.note != carrier.note { row.note = carrier.note@@ -1248,18 +1246,17 @@ enum DuplicateReconciler { row.rating = carrier.rating changed = true }- if carrier.chapterTitleProvenance == .manual {+ if carrierChapterIsManual { if row.chapterTitle != carrier.chapterTitle { row.chapterTitle = carrier.chapterTitle changed = true }- if row.chapterTitleProvenance != .manual {- row.chapterTitleProvenance = .manual- // The one citation column this fan-out writes; the blob- // mirrors it at the write site (Decision 4).- LegacyColumns.refreshCitations(on: row)- changed = true- }+ // The one citation this fan-out writes, in the blob that is now+ // its only home. `.manual` cites no rule, so the arm carries the+ // kind alone. `editCitations` is value-guarded and reports+ // whether it wrote, so a row already `.manual` needs no separate+ // read to leave it alone.+ if row.editCitations({ $0.chapterTitle = .manual }) { changed = true } } // Only ever set: a row holding the flag while the carrier does not // would be a second variant. `work` goes with it, because a row@@ -1317,19 +1314,12 @@ enum DuplicateReconciler { // the single-site assumption this feature removes. A hostname the // row is not on is skipped: propagation carries authored content // across a torn group, it does not mint site presence.- var movedWorkURL = false for carried in carrier.membershipValues { guard let url = carried.workURLString, let target = row.membership(for: carried.hostname), target.workURLString != url else { continue } target.workURLString = url- movedWorkURL = true- }- if movedWorkURL {- // The six superseded columns mirror the primary membership- // (Decision 4), and one of the writes above may have been it.- LegacyColumns.refreshMembership(on: row) changed = true } // The carrier's own ordering, not the normalised one the comparison
diff --git a/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift b/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swiftindex ea1e93c..7d64d9e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift@@ -141,6 +141,18 @@ public struct EntryCitations: Codable, Equatable, Sendable { self.workURLAssignmentKind = workURLAssignmentKind } + /// The encoded default value, encoded once.+ ///+ /// `Entry.init` seeds every new row with it (Q13), and it needs a `Data`,+ /// not a `Data?`: a `try?` there spells an encode failure exactly as it+ /// spells the V8-residue sentinel a nil blob means (Q6), so one would read+ /// as the other for the row's whole life. The `try!` is not a judgement+ /// call — `EntryCitations()` is a fixed value of `Codable` scalars and+ /// enums with no runtime input, so its encoding cannot vary, and+ /// `EntryCitationsTests.defaultBlobDecodes` decodes this constant back so a+ /// change that broke it fails the suite instead of the initializer.+ static let defaultBlob: Data = try! JSONBlob.encode(EntryCitations())+ /// Every citation this Entry holds, in the fixed order and under the labels /// the retired key-path table used. ///@@ -261,6 +273,11 @@ extension FieldProvenance { /// blob mean. public static let absent = try! FieldProvenance(kind: .none) + /// The reader's own value, citing no rule. Spelt once here because a+ /// `.manual` provenance is the commonest thing a citation writer sets and+ /// `FieldProvenance`'s initializer throws.+ public static let manual = try! FieldProvenance(kind: .manual)+ /// A provenance built from a stored `(kind, patternID, patternVersion)` /// triple that may not be legal. ///
diff --git a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swiftindex 978b34e..7bf00b1 100644--- a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift@@ -476,11 +476,13 @@ public enum GroupOrdering { // known: the cost is that a derived unattachment counts as authored, // which can classify a group as non-bare and send it to the reader — // the safe direction.- EntryAuthoredContent(+ // One decode of the blob, not two: both provenances come out of it.+ let citations = entry.citationValues+ return EntryAuthoredContent( note: entry.note, rating: entry.rating,- workAssignment: entry.workAssignmentProvenance == .manual ? entry.work?.id : nil,- chapterTitle: entry.chapterTitleProvenance == .manual ? entry.chapterTitle : nil,+ workAssignment: citations.workAssignment == .manual ? entry.work?.id : nil,+ chapterTitle: citations.chapterTitle.kind == .manual ? entry.chapterTitle : nil, intentionallyUnattached: entry.intentionallyUnattached) } @@ -491,21 +493,39 @@ public enum GroupOrdering { public static func authoredContent( of work: Work, types: WorkTypeDirectory ) -> WorkAuthoredContent {- // Q34: `titleProvenance` defaults to `.manual`, so provenance alone- // would make every Work non-bare and render silent Work resolution- // inert. The title is authored only when it is *also* something other- // than what parsing last produced.- // Q59: the confirmed Work URL is a *membership* field now, and the- // column is its mirror of the primary one. Authored content stays on the- // column deliberately — it is the value V7 compared and a row ordering- // must not move under a second site's URL — so it is read through the- // one sanctioned door rather than off the row.+ authoredContent(+ of: work, primary: work.primaryMembership,+ typeAssignment: types.canonicalized(WorkTypeAssignment.assignment(of: work)))+ }++ /// The body both readers share, over a primary membership the caller has+ /// already faulted.+ ///+ /// Q59: the confirmed Work URL is a *membership* field, read from the+ /// **primary** membership — the first in membership order. It used to be+ /// read from the column that mirrored exactly that row, and reads from the+ /// row itself since V9 dropped the mirror; the answer is the same one, so a+ /// row ordering still does not move under a second site's URL.+ ///+ /// The membership arrives as a parameter rather than being read here so+ /// that `representativeComponents`, which wants the same row for its+ /// hostname component, pays for one read per Work rather than two.+ ///+ /// Q34: `titleProvenance` defaults to `.manual`, so provenance alone would+ /// make every Work non-bare and render silent Work resolution inert. The+ /// title is authored only when it is *also* something other than what+ /// parsing last produced.+ private static func authoredContent(+ of work: Work,+ primary: WorkSiteMembership?,+ typeAssignment: WorkTypeAssignment+ ) -> WorkAuthoredContent { WorkAuthoredContent( genericNotes: work.genericNotes, manualTitle: manualTitle(of: work),- workURLString: LegacyColumns.membership(from: work).workURLString,+ workURLString: primary?.workURLString, genreTags: work.genreTags,- typeAssignment: types.canonicalized(WorkTypeAssignment.assignment(of: work)))+ typeAssignment: typeAssignment) } // MARK: Canonical definition serialisations (Q63)@@ -517,21 +537,21 @@ public enum GroupOrdering { /// The trims are part of it deliberately: a canonical form built from the /// arm alone would call two rows that derive different chapter titles /// converged (Q63). Undecodable bytes fall back to their own base64, which is- /// deterministic and cannot collide with a decodable definition's encoding;- /// a row that has not been through `V8PopulationPass` reads through the- /// legacy columns first, so a mid-migration store still orders.+ /// deterministic and cannot collide with a decodable definition's encoding,+ /// and a row with no blob at all takes the constant key below (Q11) — so+ /// every row orders, whatever its blob holds. public static func canonicalDefinition(_ pattern: TitlePattern) -> String { if let text = canonicalJSON(try? pattern.storedDefinition) { return text } // Bytes that will not decode: their own base64, which is deterministic // and cannot collide with a decodable definition's encoding. if let data = pattern.definitionData { return "raw:" + data.base64EncodedString() }- // **No blob at all**, and the ten columns do not form a legal arm- // (Q42) — the only remaining way `storedDefinition` throws. A constant- // here gave every such row one ordering key, so two rows broken in two- // different ways compared *converged*: the validator stopped reporting- // the group and the reconciler read it as needing nothing. The columns- // are what the row actually holds, so they are what orders it.- return "columns:" + LegacyColumns.canonicalColumns(of: pattern)+ // **No blob at all** (Q11). The `columns:` key that stood here told two+ // broken rows apart by what their ten definition columns held; with the+ // columns unwritten, two nil-blob rows hold the same nothing and one key+ // is the truthful order. The prefix stays distinct from the `raw:`+ // family above so a nil row and an undecodable row never compare+ // converged.+ return "no-definition:" } /// The URL rule's definition decoded and re-encoded with sorted keys, so two@@ -666,13 +686,21 @@ public enum GroupOrdering { /// contribute to a row ordering, and demanding it here would put a type /// fetch behind `sortedWorkRows`, which every write path calls. private static func representativeComponents(_ work: Work) -> [OrderComponent] {- let legacy = LegacyColumns.membership(from: work)- return [.string(legacy.hostname), .date(work.createdAt)]- + WorkAuthoredContent(- genericNotes: work.genericNotes,- manualTitle: manualTitle(of: work),- workURLString: legacy.workURLString,- genreTags: work.genreTags,+ // The primary membership, read **once** and used for both the hostname+ // component and the authored content, since V9 dropped the columns that+ // mirrored it.+ //+ // A Work with no membership at all — a lagging writer's row, healed on+ // the next reconcile — orders under the empty hostname. That is *not*+ // where the column put it: Q17 keeps `Work.init` writing `siteHostname`,+ // so such a row's column held its capture-time hostname and this key+ // genuinely moves for it. Nothing reads the key across the upgrade —+ // it is derived per call, never stored — so what moves is the order of+ // one membership-less row against its group, which the heal repairs.+ let primary = work.primaryMembership+ return [.string(primary?.hostname ?? ""), .date(work.createdAt)]+ + authoredContent(+ of: work, primary: primary, typeAssignment: WorkTypeAssignment.assignment(of: work) ).orderComponents + [.date(work.modifiedAt)]
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryDiagnostics.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryDiagnostics.swiftindex 6067fb8..53f5087 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryDiagnostics.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryDiagnostics.swift@@ -342,23 +342,27 @@ public enum LibraryToleranceScan { /// hydration's final batch). public let workTypeCollisionCount: Int - /// The three V8 conversion counters, and the arrival-tier gate for- /// `V8PopulationPass` and `MembershipReconciler.heal` (Decision 5).+ /// The three upgrade-residue counters, and the gate for+ /// `MembershipReconciler.heal` (Q10, Q20). /// /// Same bargain as `duplicateCandidateCount`: this walk already reads /// every Work, Entry and TitlePattern row and already indexes the /// memberships by `workID`, so the counts cost a scalar read per row and /// no extra traversal. Zero is the answer that matters — a converged- /// library gives it, and it means the whole-library conversion passes- /// have nothing to do on this arrival.+ /// library gives it, and it means the heal has nothing to do on this+ /// arrival, on either tier. /// /// **A count of rows, not of logical records.** Two rows of one split /// Work each missing a membership count twice; the gate only asks /// whether the number is zero. public let worksWithoutMembershipCount: Int- /// Entries whose `citationsData` is nil — a row a lagging V7 writer- /// synced in (Req 2.7, Q36), reading its citations through- /// `LegacyColumns` until the pass converts it.+ /// Entries whose `citationsData` is nil — a row a V8 build wrote that+ /// the V9 drop stage reached before anything converted it. **No pass+ /// converts it**: the columns it would have been derived from are gone,+ /// so the row reads as the default (Q6), is reported rather than+ /// quarantined (Q25, Q32), and converges only when a writer has+ /// something real to write (Q23). This count is how the reader is told+ /// how many rows lost their provenance at the upgrade. public let entriesWithoutCitationBlobCount: Int /// TitlePatterns whose `definitionData` is nil, on the same terms. public let patternsWithoutDefinitionBlobCount: Int@@ -458,7 +462,7 @@ public enum LibraryToleranceScan { try context.enumerate(FetchDescriptor<Entry>(), batchSize: batchSize) { entry in entriesByHostname[entry.hostname, default: 0] += 1 entryIdentities.record(entry.id)- if entry.citationsData == nil { entriesWithoutCitationBlob += 1 }+ if entry.citationsAreAbsent { entriesWithoutCitationBlob += 1 } if let key = DuplicateScan.entryBucketKey( hostname: entry.hostname, conservativeIdentityKey: entry.conservativeIdentityKey) { entryDuplicateKeys[key, default: 0] += 1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Articles.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Articles.swiftindex 8748860..5beb6f5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Articles.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Articles.swift@@ -114,42 +114,36 @@ extension LibraryRepository { for entry in group.rows { if !projection.chapterProtected { entry.chapterTitle = nil- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.none.rawValue- entry.chapterPatternID = nil- entry.chapterPatternVersion = nil } if !projection.assignmentProtected { entry.work = nil- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.none.rawValue- entry.workPatternID = nil- entry.workPatternVersion = nil entry.intentionallyUnattached = true } // Restore conservative keys, clear URL-derived sequence and assignment // provenance (Req 8.11: articles clears URL-derived fields) entry.identityBasisRaw = EntryIdentityBasis.conservative.rawValue- entry.identityURLRuleID = nil- entry.identityURLRuleVersion = nil entry.urlWorkIdentity = nil- entry.urlWorkRuleID = nil- entry.urlWorkRuleVersion = nil entry.chapterSequence = nil- entry.chapterSequenceRuleID = nil- entry.chapterSequenceRuleVersion = nil- entry.workURLRuleID = nil- entry.workURLRuleVersion = nil- entry.workURLAssignmentKindRaw = nil // Restore conservative identity key (raw URL is already validated) if let conservativeKey = try? EntryIdentityNormalizer.key(forRawURL: entry.rawURLString) { entry.entryIdentityKey = conservativeKey- entry.identityKeyVersion = 1 } - // Decision 4: the blob mirrors the columns this sweep just- // cleared, refreshed here so the commit's validation and the- // next reader see one answer.- LegacyColumns.refreshCitations(on: entry)+ // Every field this sweep cleared is a citation, so it clears+ // them where they live (V9): the identity falls back to the+ // raw URL, both URL-derived evidence citations go, and the+ // chapter and assignment arms go with the values above+ // unless the projection protected them.+ entry.editCitations { citations in+ if !projection.chapterProtected { citations.chapterTitle = .absent }+ if !projection.assignmentProtected { citations.workAssignment = .none }+ citations.identity = .rawURL+ citations.urlWork = nil+ citations.chapterSequence = nil+ citations.workURL = nil+ citations.workURLAssignmentKind = nil+ } entry.modifiedAt = timestamp } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swiftindex 6624767..984cbb7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift@@ -89,10 +89,6 @@ extension LibraryRepository { work: record.workID.flatMap { worksByID[$0] }, site: sitesByHostname[record.hostname])) }- // The superseded Work columns mirror the primary membership, as they do- // at every other write site (Decision 4, Q60) — until V9 (T-2272).- for work in worksByID.values { LegacyColumns.refreshMembership(on: work) }- // Pairs are UUID-only rows (Q27), so they import verbatim whether or not // the Works they name are here (Req 5.7, 8.3). for record in payload.distinctPairs {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swiftindex 5df2353..d4da7f5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift@@ -22,11 +22,16 @@ extension LibraryRepository { static func validateImportPlanPayload( _ payload: BackupImportPayload ) throws -> LibraryRecordCounts {- // The live schema. It is now the only one declared (Req 3.1), but the- // pinning is deliberate rather than incidental: the materializer inserts- // live classes, so validating against any snapshot schema would validate- // against different entities (Q20).- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ // **The live schema, and it has to be.** The materializer inserts live+ // classes, so validating against a snapshot schema would validate+ // against different entities (Q20) — and worse, a scratch container+ // built over a frozen snapshot wins SwiftData's name-keyed entity+ // registry for the rest of the process+ // (`docs/agent-notes/schema-migration.md`). While the snapshot only+ // *added* columns that showed up as saves silently dropping them; now+ // that V9 **removes**, it aborts the process instead, with+ // `NSUnknownKeyException` on a column the live entity no longer has.+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swiftindex b153094..b9c3173 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift@@ -4,33 +4,37 @@ import SwiftData /// Runtime opening of the live library, classified then acted on. ///-/// Every store the app can reach is recorded at V5 or above, and the conversions-/// left are the `.lightweight` V5 → V6 → V7 → V8 stages `ModelContainer.init`-/// runs: the sidecar, the V3 reader and the completion pass are retired-/// (Decision 1). What survives is the readiness contract. The app validates with+/// Every store the app can reach is recorded at V8 or above, and the one+/// conversion left is the `.lightweight` V8 → V9 stage `ModelContainer.init`+/// runs: the sidecar, the V3 reader, the completion pass and every stage below+/// V8 are retired (Decision 1; Q2 of `drop-superseded-columns`). What survives+/// is the readiness contract. The app validates with /// `LibraryValidator` and clears residual evidence; the marker it publishes-/// contains `"8"` (`extensionOpenableMarkerVersion`), the only version the+/// contains `"9"` (`extensionOpenableMarkerVersion`), the only version the /// extension opens (Q14). /// /// 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 `"8"` directly: there is nothing in it to bring forward (Q26).+/// It is marked at `"9"` directly: there is nothing in it to bring forward (Q26). ///-/// **There is one lagging generation: `"7"`.** `data-model-cleanups` Decision 2-/// had deleted the last of them — `"4"`, `"5"` and `"6"` — on the argument that-/// the population is one user whose every device carried `"7"`.-/// `multi-site-works` publishes `"8"`, so `"7"` is back as the lagging digit: a-/// device that has not launched this build yet is on it, and the `.markerLagging`-/// arm is the upgrade path. That arm converts the store, runs `V8PopulationPass`-/// to fill the new site-membership rows, runs `MembershipReconciler` over what-/// another device may already have written, certifies the pass with-/// `assertComplete`, and only then publishes `"8"`.+/// **There is one lagging generation: `"8"`.** V9 drops the columns V8+/// superseded, and the whole of that is the lightweight stage `ModelContainer`+/// runs — so the `.markerLagging` arm opens, validates, and publishes `"9"`,+/// with no data pass and no reconciler (Q9). The digit exists even though the+/// stage needs no help from it, because it is what keeps the *extension* out of+/// a conversion that destroys the source (Q3): `openContainer` passes the+/// migration plan for both roles, and only the marker check stops a concurrent+/// share-sheet invocation from running the drop.+///+/// `"7"` is **gone** rather than kept beside `"8"`: every device is confirmed at+/// `"8"` (Q2), and the substitution the schema-migration note allows only after+/// re-verifying the population is what that confirmation buys. /// /// A store found on any other digit is refused, naming it, and the recovery is /// the backup archive, exactly as for a store recorded below V5. The two roles /// differ in what they accept and in what they may do about it: the app opens-/// `"7"` and `"8"` (`appOpenableMarkerVersions`) and may create, convert and mark-/// a store; the extension opens `"8"` only and writes nothing.+/// `"8"` and `"9"` (`appOpenableMarkerVersions`) and may create, convert and mark+/// a store; the extension opens `"9"` only and writes nothing. public extension LibraryRepository { /// The result of evaluating the live library's fixed-path state under an /// exclusive lease.@@ -38,7 +42,7 @@ public extension LibraryRepository { case ready(LibraryRecordCounts) } - /// Extension-only readiness result. The extension opens only an `"8"` marker.+ /// Extension-only readiness result. The extension opens only a `"9"` marker. enum ExtensionResult: Equatable, Sendable { case ready(LibraryRecordCounts) }@@ -102,8 +106,7 @@ public extension LibraryRepository { mode: .exclusive, at: configuration.lockURL, timeout: bootstrapLockTimeout) defer { withExtendedLifetime(lease) {} } - let certification = try certifyForApp(- configuration, hooks: hooks, saveStrategy: saveStrategy)+ let certification = try certifyForApp(configuration, hooks: hooks) // The certification container died with that call's frame. Nothing here // holds a reference to it, which is what makes the construction below // the only live container over this store.@@ -123,14 +126,13 @@ public extension LibraryRepository { /// reference to a second container over the store) into the caller's frame. internal static func certifyForApp( _ configuration: LibraryConfiguration,- hooks: MirroringOpenHooks,- saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+ hooks: MirroringOpenHooks ) throws -> Certification { let state = try classify(configuration, fileManager: .default) hooks.bootstrapEventObserver?(.classified(state)) bootstrapLogger.debug("Bootstrap state: \(String(describing: state), privacy: .public)") - return try act(on: state, configuration, hooks: hooks, saveStrategy: saveStrategy)+ return try act(on: state, configuration, hooks: hooks) } /// The only code that writes. One case per `BootstrapState`, so the compiler@@ -148,15 +150,16 @@ public extension LibraryRepository { /// store file's prior absence (Req 2.4). `.sqlite` can be gone while a /// `-wal` holding rows is not, so "we just created it" is not evidence of /// anything.- /// `saveStrategy` is the repository's own, threaded through so the one- /// acting arm that writes *rows* — the population pass — commits through the- /// same seam every other bulk pass does, and a test can inject a refusing- /// one to prove the marker stays put (Req 2.4).+ ///+ /// **No acting arm writes rows any more**, so no save strategy is threaded+ /// in: the one that did was the `"7"` arm's population pass, and V9 deletes+ /// it along with the columns it filled. What the arms write is the readiness+ /// marker, `publishReadiness` writes that, and the empty store `.pristine`+ /// creates saves through its own context. internal static func act( on state: BootstrapState, _ configuration: LibraryConfiguration,- hooks: MirroringOpenHooks,- saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+ hooks: MirroringOpenHooks ) throws -> Certification { switch state { case .belowV5(let version):@@ -168,7 +171,7 @@ public extension LibraryRepository { + "version this build opens; restore from a backup archive") case .ready:- // open (which converts, V5 → V6 → V7) → validate → clear residual+ // open (nothing to convert at `"9"`) → validate → clear residual // evidence → counts. The marker already records the current // generation, so nothing is published: this is the only certification // sequence left, and it writes no marker at all.@@ -181,37 +184,34 @@ public extension LibraryRepository { return Certification(result: .ready(counts), diagnostics: diagnostics) case .markerLagging(let generation):- // open (which converts V7 → V8) → population pass → membership- // reconcile → assertComplete → publish `"8"` → validate.+ // open (which drops the superseded columns) → validate → publish+ // `"9"`. //- // **The marker goes after the work it certifies** (Q36), as every- // other acting arm does: a throw anywhere above leaves `"7"` on- // disk, fails the open, and the next open re-enters this arm — which- // is safe because every phase of the pass is idempotent by an- // existence guard (Req 2.4).+ // **No data pass and no reconciler** (Q9). The `"7"` arm ran+ // `V8PopulationPass` and `MembershipReconciler` because V8 *added*+ // tables and blobs that something had to fill; V9 only takes columns+ // away, and the lightweight stage does the whole of it inside+ // `ModelContainer.init`. The launch reconcile runs moments later on+ // the same store for anything else. //- // **The pass certifies itself** (Q35). `assertComplete` is the gate,- // not the validator: every state the pass repairs is one the- // validator tolerates, so a validator run over an unconverted- // library comes back clean and would certify nothing.+ // **Validation is the gate, and the marker goes after it.**+ // There is no pass to certify itself with, so what certifies the+ // conversion is the store validating: a throw here leaves `"8"` on+ // disk, fails the open, and the next open re-enters this arm over a+ // store the stage has already converted — which is safe, because+ // dropping columns that are already gone is a no-op. //- // **Validation runs after the marker**, and opens with diagnoses- // rather than refusing, exactly as the `.ready` arm does — a library- // that opened on V7 must open on V8.+ // The residual evidence goes **last**, after the publish rather than+ // with the validation: a publish that fails on a full disk leaves+ // the library at `"8"` with its historical marker and sidecar still+ // beside it, which is the state the next open wants to find. let container = try openCertificationContainer(configuration, hooks: hooks) let context = ModelContext(container) bootstrapLogger.debug(- "Marker generation \(generation, privacy: .public) is lagging; running the V8 population pass")- try V8PopulationPass.run(context: context, saveStrategy: saveStrategy)- // Between the pass and its certification: a library that arrived- // carrying duplicate or orphaned membership rows — the other device- // having migrated first (Req 2.6) — is resolved before the marker- // moves. The pass alone is what `assertComplete` certifies.- _ = try MembershipReconciler.run(context: context, saveStrategy: saveStrategy)- try V8PopulationPass.assertComplete(context: context)+ "Marker generation \(generation, privacy: .public) is lagging; certifying the V9 conversion")+ let diagnostics = try validateStore(context: context) try publishReadiness(at: configuration.readinessMarkerURL)- let diagnostics = try validateAndClearResidualEvidence(- configuration, context: context)+ clearResidualEvidence(configuration) let counts = try rowCounts(context: context) hooks.certificationContainerObserver?(container) return Certification(result: .ready(counts), diagnostics: diagnostics)@@ -227,7 +227,7 @@ public extension LibraryRepository { reason: kind.orphanedReason) case .unmarkedStore:- // open → counts → refuse if nonempty → publish `"8"`.+ // open → counts → refuse if nonempty → publish `"9"`. // // An *empty* unmarked store is the state a crash between store // creation and the marker leaves, or a `publishReadiness` that@@ -254,12 +254,12 @@ public extension LibraryRepository { return Certification(result: .ready(counts), diagnostics: .empty) case .pristine:- // open (which creates) → save → counts → publish `"8"`.+ // open (which creates) → save → counts → publish `"9"`. //- // Certified at `"8"`, the current generation: an empty store has- // nothing for the population pass to bring forward, so it is born- // in the state a certified library is in (Q26) rather than at the- // lagging digit with a pass ahead of it.+ // Certified at `"9"`, the current generation: a store created by+ // these classes is already V9-shaped, so it is born in the state a+ // certified library is in (Q26) rather than at the lagging digit+ // with a conversion ahead of it. let container = try openCertificationContainer(configuration, hooks: hooks) let context = ModelContext(container) do { try context.save() } catch {@@ -385,16 +385,18 @@ extension LibraryRepository { var quarantined: [String: LibraryValidationError] { diagnostics.quarantineMap() } } - /// Opens the fixed-path store with the live V8 schema and- /// `AsterismV8MigrationPlan`, which declares `[V5, V6, V7, V8]` and three- /// lightweight stages: this call is where an installed V5, V6 or V7 library- /// is converted, and the only place it happens.+ /// Opens the fixed-path store with the live V9 schema and+ /// `AsterismV9MigrationPlan`, which declares `[V8, V9]` and one lightweight+ /// stage: this call is where an installed V8 library is converted, and the+ /// only place it happens. ///- /// The V7 → V8 stage only adds (Decision 3). The rows and blobs the new- /// tables and columns need are filled afterwards by `V8PopulationPass`,- /// under the marker-lagging arm of `act(on:)`.+ /// **The V8 → V9 stage removes** — 35 attributes and the `Work.site` ↔+ /// `Site.works` inverse pair — which is why nothing may read any of them by+ /// the time this runs. It needs no data pass afterwards: the rows and blobs+ /// it leaves behind were filled by `V8PopulationPass` under the V8 build,+ /// and every device is confirmed past that marker (Q2). ///- /// A store recorded below V5 has no stage and is refused here — `classify`+ /// A store recorded below V8 has no stage and is refused here — `classify` /// already refuses one before any container is constructed (Req 2.9, /// Decision 1 of `retire-migration-chain`), so the refusal is a second /// closed door rather than a new one.@@ -409,12 +411,12 @@ extension LibraryRepository { at storeURL: URL, mirroring cloudKitDatabase: ModelConfiguration.CloudKitDatabase = .none ) throws -> ModelContainer {- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let storeConfiguration = ModelConfiguration( // **Frozen persisted state (Req 3.5).** This names the store // configuration *inside* the container, not the file — `url:` below is- // the locator. It is frozen anyway (Q13): the store it labels holds V8,- // so the name is five versions behind, and renaming it buys nothing on+ // the locator. It is frozen anyway (Q13): the store it labels holds V9,+ // so the name is six versions behind, and renaming it buys nothing on // a path that opens the owner's only library. "AsterismV3", schema: schema,@@ -423,7 +425,7 @@ extension LibraryRepository { ) return try ModelContainer( for: schema,- migrationPlan: AsterismV8MigrationPlan.self,+ migrationPlan: AsterismV9MigrationPlan.self, configurations: [storeConfiguration] ) }@@ -490,19 +492,35 @@ extension LibraryRepository { /// The cleanup goes after the validation for the reason Q36 gives about the /// marker: the historical marker and the migration artefact are the recovery /// evidence for the state this call is leaving, so they are removed only once- /// the library they belong to has been established as openable. Both are- /// absent on most calls, which `try?` covers along with a removal that fails.+ /// the library they belong to has been established as openable.+ ///+ /// This composed form is for `.ready`, which publishes nothing. The+ /// `.markerLagging` arm calls the two halves separately, so the evidence+ /// outlives a failed publish. static func validateAndClearResidualEvidence( _ configuration: LibraryConfiguration, context: ModelContext ) throws -> LibraryDiagnostics { let diagnostics = try validateStore(context: context)+ clearResidualEvidence(configuration)+ return diagnostics+ }++ /// Removes the recovery evidence the current marker supersedes. Both files+ /// are absent on most calls, which `try?` covers along with a removal that+ /// fails.+ ///+ /// **The caller decides when.** On an arm that publishes a marker this has+ /// to run *after* the publish: a validated store whose `publishReadiness`+ /// then fails on a full disk is still at the old digit, and taking its+ /// historical marker and sidecar away first would leave the next open with+ /// the lagging state and none of the evidence for it.+ static func clearResidualEvidence(_ configuration: LibraryConfiguration) { try? FileManager.default.removeItem(at: configuration.historicalMarkerURL) try? FileManager.default.removeItem(at: configuration.migrationSidecarURL)- return diagnostics } - /// Store-level validation on the open path — over V5, the only schema this+ /// Store-level validation on the open path — over V9, the only schema this /// package declares. 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@@ -517,21 +535,6 @@ extension LibraryRepository { } } - /// Store-level validation for the backup import gates, which refuse anything- /// the open paths now tolerate: an imported library must be wholly legal- /// (Decision 3). Separate from `validateStore` so the boundary holds by- /// construction rather than by each gate remembering to ask for it.- static func validateStoreStrictly(- context: ModelContext- ) throws -> [String: LibraryValidationError] {- do {- return try LibraryValidator.validateStrict(context: context)- } catch {- throw LibraryRepositoryError.libraryUnavailable(- operation: "validating the store", reason: String(describing: error))- }- }- /// Marker generations the app opens: the current one and its predecessor. /// /// **This is the live acceptance test**, not a description of one: the@@ -541,19 +544,25 @@ extension LibraryRepository { /// /// `data-model-cleanups` Decision 2 had reduced this to one digit, on the /// argument that the population was one user whose every device carried- /// `"7"`. `multi-site-works` publishes `"8"`, so `"7"` is **added back**- /// rather than substituted: a device that has not launched the new build yet- /// is on `"7"`, and this set is what keeps it openable- /// (`docs/agent-notes/schema-migration.md`). Retiring it again is the same- /// argument, made again about the population as it is then.+ /// `"7"`. `multi-site-works` added `"7"` back beside `"8"`, and+ /// `drop-superseded-columns` **substitutes** rather than adding: `"7"` goes+ /// and `"8"` takes its place, because every device is confirmed at `"8"`+ /// (Q2) and a lagging arm no device can reach is a path nothing tests. That+ /// substitution is what `docs/agent-notes/schema-migration.md` allows only+ /// after re-verifying the population, which Q2 records. static let appOpenableMarkerVersions: Set<String> = [- "7", extensionOpenableMarkerVersion,+ laggingOpenableMarkerVersion, extensionOpenableMarkerVersion, ] + /// The one lagging generation the app still opens: a library certified by a+ /// V8 build, which `.markerLagging` converts and re-marks. Frozen persisted+ /// state, like its successor below.+ static let laggingOpenableMarkerVersion = "8"+ /// The only version the **extension** opens, and the one `publishReadiness` /// writes (Q14). Frozen persisted state — these are the bytes on disk in an /// installed library (Req 3.5).- static let extensionOpenableMarkerVersion = "8"+ static let extensionOpenableMarkerVersion = "9" // The app-side counterpart of `validateMarkerContentForExtension` stood // here. It restated the acceptance test the classifier performs, and@@ -579,11 +588,13 @@ extension LibraryRepository { /// the fork back the moment it opened two. `multi-site-works` is that /// moment. ///- /// * A generation the app *does* open — `"7"`, the update window: the app is+ /// * A generation the app *does* open — `"8"`, the update window: the app is /// updated and not yet launched, the library still records the previous- /// digit, and opening the app runs the population pass. The message says+ /// digit, and opening the app completes the conversion. The message says /// so, and `configurable-work-types` Req 8.7 requires the capture to fail- /// safely rather than convert a store under a shared lock.+ /// safely rather than convert a store under a shared lock — which matters+ /// more at V9 than it did at V8, because the conversion *drops* columns+ /// and a concurrent extension open mid-drop would be destructive (Q3). /// * Anything else — a retired or unknown digit: nothing the reader can do /// from here, and the recovery is the backup archive. The wording is the /// extension's own — "the containing app has not initialized the current
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swiftindex e90949a..ec482e7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift@@ -12,18 +12,31 @@ import Foundation /// * every outcome is assertable from a seeded temp directory, without provoking /// an error and without opening the library the assertion is about. enum BootstrapState: Equatable, Sendable {- /// The store positively identifies itself as recorded below V5. Refused- /// (Req 2.9, Decision 1): the migration that would raise it is gone, and the- /// recovery is the backup archive.+ /// A store below the plan's floor, refused (Req 2.9, Decision 1): the+ /// migration that would raise it is gone, and the recovery is the backup+ /// archive.+ ///+ /// **The floor is V8, not V5** — the plan is `[V8, V9]` — so V5, V6 and V7+ /// stores are equally beyond raising. The name is inherited from when V5+ /// *was* the floor and is kept deliberately (Q35 of+ /// `drop-superseded-columns`); the refusal it stands for has widened under+ /// it. What has *not* widened is the reading that reaches this case:+ /// `StoreMetadata` positively identifies only a below-V5 store, because+ /// `NSStoreModelVersionIdentifiers` is advisory and a reader that refused+ /// on anything it did not recognise would lock the owner's only library.+ /// A V5–V7 store is therefore refused a row later, by its retired marker+ /// digit (`"5"`, `"6"` and `"7"` all fall to `.unrecognised`), with the+ /// same recovery. Both paths refuse; only this one names a store version. case belowV5(version: String) /// A certified library: the readiness marker records the current- /// generation, `"8"`, and a store is present.+ /// generation, `"9"`, and a store is present. case ready- /// A library certified at the **previous** generation, `"7"`, with a store- /// present: V8's schema stage will convert the store on the way in, and the- /// data pass that fills what the stage could only add still owes its work- /// (Req 2.2, Decision 3). The app runs it and republishes; the extension- /// refuses and says to open the app (Req 2.3).+ /// A library certified at the **previous** generation, `"8"`, with a store+ /// present: V9's schema stage drops the superseded columns on the way in,+ /// which is the whole of the conversion, so all this arm owes is validating+ /// it and republishing (Q9). The app does that; the extension refuses and+ /// says to open the app (Req 2.3), which is what keeps the drop out of a+ /// process that holds a shared lock (Q3). case markerLagging(generation: String) /// Evidence that a library existed, with no store file of any kind to go with /// it. Refused so the evidence survives for a restore (Req 2.6).@@ -74,22 +87,21 @@ extension LibraryRepository { /// overlapping evidence resolvable at all (Q15). The rows, in order: /// /// 1. a positively below-V5 recorded version (Req 2.9)- /// 2. marker `"8"` and a store present (Req 2.2)- /// 3. marker `"7"` — a generation the app still opens — and a store present+ /// 2. marker `"9"` and a store present (Req 2.2)+ /// 3. marker `"8"` — a generation the app still opens — and a store present /// 4. any evidence with no store present (Req 2.6) /// 5. a store present with no readiness marker of any generation (Req 2.4) /// 6. nothing on disk (Req 2.5) /// 7. anything else, including a marker recording a retired generation /// (Req 2.7) ///- /// **Row 3 is back**, for the first time since `data-model-cleanups`- /// Decision 2 retired `"4"`, `"5"` and `"6"`. `multi-site-works` publishes- /// `"8"` and the population it ships to is on `"7"`, so the predecessor is- /// *added* to `appOpenableMarkerVersions` rather than substituted — a device- /// that has not launched the new build yet is on the old marker, and the set- /// is what keeps it openable (`docs/agent-notes/schema-migration.md`). A- /// digit outside the set still falls to the last row and is refused naming- /// itself, with the backup archive as the recovery.+ /// **Row 3 holds one digit at a time.** `multi-site-works` brought it back+ /// for `"7"`; `drop-superseded-columns` substitutes `"8"` for it rather than+ /// adding a third, because every device is confirmed past `"7"` (Q2) and a+ /// lagging arm no device can reach is a path nothing tests. Substituting is+ /// what `docs/agent-notes/schema-migration.md` permits only after that+ /// confirmation. A digit outside the set still falls to the last row and is+ /// refused naming itself, with the backup archive as the recovery. /// /// Store presence is the disjunction over the SQLite family — `.sqlite`, /// `-wal`, `-shm` (Req 2.10). A main file that is gone while its companions@@ -124,7 +136,10 @@ extension LibraryRepository { // 1. The advisory reading, which can refuse and can never authorise // (Decision 5). Absent, unreadable, merged and unrecognised readings all- // fall through to the marker's word, which is what ships today.+ // fall through to the marker's word, which is what ships today. The+ // reading only ever names a *below-V5* store even though the plan's+ // floor is now V8 (Q35): a V5–V7 store falls through to row 7 on its+ // retired marker digit, which refuses it just the same. if case .below(let version) = StoreMetadata.recordedVersion(at: storeURL) { return .belowV5(version: version) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swiftindex 0b7bbde..4e014c6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift@@ -430,12 +430,18 @@ extension LibraryRepository { let derivation = ComposedDeriver.derive( captureTitle: entry.captureTitle, rawURL: entry.rawURLString, hostname: hostname, titleRule: titleRule, urlRule: urlRule)+ // One decode of the blob per row, not two: `keyVersion` and the+ // chapter-title provenance both live in it.+ let citations = entry.citationValues if entry.conservativeIdentityKey != entry.rawURLString { return true } if entry.entryIdentityKey != derivation.identity.key { return true }- if entry.identityKeyVersion != derivation.identity.version { return true }+ if citations.identity.keyVersion != derivation.identity.version {+ return true+ } if entry.chapterSequence != derivation.chapterSequence?.value { return true } if entry.urlWorkIdentity != derivation.workIdentity?.value { return true }- if entry.chapterTitleProvenance != .manual, entry.chapterTitle != derivation.chapterTitle {+ if citations.chapterTitle.kind != .manual,+ entry.chapterTitle != derivation.chapterTitle { return true } switch projection.assignment {@@ -655,98 +661,89 @@ extension LibraryRepository { let derivation = ComposedDeriver.derive( captureTitle: entry.captureTitle, rawURL: entry.rawURLString, hostname: hostname, titleRule: composedTitleRule, urlRule: composedURLRule)- applyEntryIdentityAndChapter(- to: entry, derivation: derivation, titleRuleID: titleRuleID,- titleVersion: titleVersion, url: url)- applyAssignment(- to: entry, assignment: projection.assignment, derivation: derivation,- hostname: hostname,- titleRuleID: titleRuleID, titleVersion: titleVersion, url: url,- createdByKey: createdByKey, workGroups: workGroups, timestamp: timestamp)+ // One decode and one encode of the blob per row: the derivation+ // and the assignment both write citations, and the chapter+ // guard reads one.+ entry.editCitations { citations in+ applyEntryIdentityAndChapter(+ to: entry, citations: &citations, derivation: derivation,+ titleRuleID: titleRuleID, titleVersion: titleVersion, url: url)+ applyAssignment(+ to: entry, citations: &citations,+ assignment: projection.assignment, derivation: derivation,+ hostname: hostname,+ titleRuleID: titleRuleID, titleVersion: titleVersion, url: url,+ createdByKey: createdByKey, workGroups: workGroups, timestamp: timestamp)+ } entry.modifiedAt = timestamp } } } + /// Writes the whole derivation — the stored keys *and* the citations that+ /// describe them — against citations the caller already decoded (V9).+ ///+ /// The blob arrives `inout` rather than being edited here so that one row's+ /// derivation and its assignment share a single decode/encode: the identity+ /// basis, both pieces of URL evidence, the chapter provenance and the+ /// assignment are all citations, and a value the commit's own validation+ /// reads has to be consistent across every one of them. static func applyEntryIdentityAndChapter(- to entry: Entry, derivation: ComposedDerivation,+ to entry: Entry, citations: inout EntryCitations, derivation: ComposedDerivation, titleRuleID: UUID, titleVersion: Int, url: (id: UUID, version: Int, definition: URLRuleDefinition)? ) { // The conservative-key alias is always the immutable raw URL (Q21). entry.conservativeIdentityKey = entry.rawURLString - switch derivation.identity {- case .conservative(let raw):- entry.entryIdentityKey = raw- entry.identityKeyVersion = 1- entry.identityBasis = .conservative- entry.identityURLRuleID = nil- entry.identityURLRuleVersion = nil- entry.identityNameTitleRuleID = nil- entry.identityNameTitleRuleVersion = nil- case .identitySequence(let key):- entry.entryIdentityKey = key- entry.identityKeyVersion = 2- entry.identityBasis = .urlRule- entry.identityURLRuleID = url?.id- entry.identityURLRuleVersion = url?.version- entry.identityNameTitleRuleID = nil- entry.identityNameTitleRuleVersion = nil- case .sequenceName(let key):- entry.entryIdentityKey = key- entry.identityKeyVersion = 3- entry.identityBasis = .urlRule- entry.identityURLRuleID = url?.id- entry.identityURLRuleVersion = url?.version- entry.identityNameTitleRuleID = titleRuleID- entry.identityNameTitleRuleVersion = titleVersion- }+ let urlCited = url.flatMap { CitedRule(id: $0.id, version: $0.version) }+ let titleCited = CitedRule(id: titleRuleID, version: titleVersion)++ // One switch, not two: the stored key, the basis column and the cited+ // identity are three answers to the same question.+ let derived: (key: String, basis: EntryIdentityBasis, cited: EntryCitations.Identity) =+ switch derivation.identity {+ case .conservative(let raw):+ (raw, .conservative, .rawURL)+ case .identitySequence(let key):+ (key, .urlRule, urlCited.map { .rule(url: $0, nameTitle: nil) } ?? .rawURL)+ case .sequenceName(let key):+ (key, .urlRule, urlCited.map { .composed(url: $0, nameTitle: titleCited) } ?? .rawURL)+ }+ entry.entryIdentityKey = derived.key+ entry.identityBasis = derived.basis+ citations.identity = derived.cited // Extracted Work identity (evidence), present only when the rule supplies it.- if let workIdentity = derivation.workIdentity, let url {+ if let workIdentity = derivation.workIdentity, url != nil { entry.urlWorkIdentity = workIdentity.value- entry.urlWorkRuleID = url.id- entry.urlWorkRuleVersion = url.version } else { entry.urlWorkIdentity = nil- entry.urlWorkRuleID = nil- entry.urlWorkRuleVersion = nil } // Chapter sequence (settlement evidence), present when extracted.- if let sequence = derivation.chapterSequence, let url {+ if let sequence = derivation.chapterSequence, url != nil { entry.chapterSequence = sequence.value- entry.chapterSequenceRuleID = url.id- entry.chapterSequenceRuleVersion = url.version } else { entry.chapterSequence = nil- entry.chapterSequenceRuleID = nil- entry.chapterSequenceRuleVersion = nil- }-- // Chapter title from the title rule, unless a manual chapter protects it.- if entry.chapterTitleProvenance != .manual {- if let chapter = derivation.chapterTitle {- entry.chapterTitle = chapter- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = titleRuleID- entry.chapterPatternVersion = titleVersion- } else {- entry.chapterTitle = nil- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.none.rawValue- entry.chapterPatternID = nil- entry.chapterPatternVersion = nil- } } - // Decision 4: the columns were just written, so the blob is re-derived- // from them before anything reads it — this commit's own validation- // included, which is what a post-migration re-teach used to fail on.- LegacyColumns.refreshCitations(on: entry)+ citations.urlWork = derivation.workIdentity != nil ? urlCited : nil+ citations.chapterSequence = derivation.chapterSequence != nil ? urlCited : nil++ // Chapter title from the title rule, unless a manual chapter protects+ // it — read off the citations already in hand.+ guard citations.chapterTitle.kind != .manual else { return }+ entry.chapterTitle = derivation.chapterTitle+ citations.chapterTitle = derivation.chapterTitle == nil+ ? .absent+ : FieldProvenance.tolerant(+ kind: .pattern, patternID: titleCited.id,+ patternVersion: titleCited.version) } private static func applyAssignment(- to entry: Entry, assignment: ComposedAssignmentProjection, derivation: ComposedDerivation,+ to entry: Entry, citations: inout EntryCitations,+ assignment: ComposedAssignmentProjection, derivation: ComposedDerivation, hostname: String, titleRuleID: UUID, titleVersion: Int, url: (id: UUID, version: Int, definition: URLRuleDefinition)?, createdByKey: [ProspectiveWorkKey: Work], workGroups: [UUID: WorkGroup], timestamp: Date@@ -778,38 +775,35 @@ extension LibraryRepository { work.refreshParsedTitle(to: derivation.workName, commit: true) work.modifiedAt = timestamp }- applyAssignmentProvenance(to: entry, derivation: derivation,+ applyAssignmentProvenance(to: &citations, derivation: derivation, titleRuleID: titleRuleID, titleVersion: titleVersion, url: url) case .create(let key): guard let work = createdByKey[key] else { break } entry.work = work- applyAssignmentProvenance(to: entry, derivation: derivation,+ applyAssignmentProvenance(to: &citations, derivation: derivation, titleRuleID: titleRuleID, titleVersion: titleVersion, url: url) } } + /// The assignment's own citations, written into a blob the caller already+ /// decoded so a row pays one decode and one encode for its whole write. static func applyAssignmentProvenance(- to entry: Entry, derivation: ComposedDerivation,+ to citations: inout EntryCitations, derivation: ComposedDerivation, titleRuleID: UUID, titleVersion: Int, url: (id: UUID, version: Int, definition: URLRuleDefinition)? ) {- if let _ = derivation.workIdentity, let url {+ if derivation.workIdentity != nil, let url { // Identity-based assignment (URL rule supplied the Work identity).- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.urlRule.rawValue- entry.workURLRuleID = url.id- entry.workURLRuleVersion = url.version- entry.workURLAssignmentKindRaw = URLWorkAssignmentKind.identity.rawValue- entry.workPatternID = nil- entry.workPatternVersion = nil+ let cited = CitedRule(id: url.id, version: url.version)+ citations.workAssignment = .urlRule(cited)+ citations.workURL = cited+ citations.workURLAssignmentKind = .identity } else { // Title-based assignment (the composed name grouped the Work).- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.workPatternID = titleRuleID- entry.workPatternVersion = titleVersion- entry.workURLRuleID = nil- entry.workURLRuleVersion = nil- entry.workURLAssignmentKindRaw = nil+ citations.workAssignment = .pattern(+ CitedRule(id: titleRuleID, version: titleVersion))+ citations.workURL = nil+ citations.workURLAssignmentKind = nil }- LegacyColumns.refreshCitations(on: entry) } static func nonEmpty(_ value: String?) -> String? {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swiftindex c0aa1b9..96f4ddc 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift@@ -460,17 +460,6 @@ extension LibraryRepository { } try saveStrategy.save(context) }-- // The superseded Work columns mirror the primary membership wherever one- // moved (Decision 4, Q60) — until V9 (T-2272). Every row of the identity- // group, because the mirror is a per-row column.- var dirty = false- for workID in touched {- for work in workRows[workID] ?? [] where LegacyColumns.refreshMembership(on: work) {- dirty = true- }- }- if dirty { try saveStrategy.save(context) } } /// The reader's dismissed pairs (Req 5.5, 5.8). Matched by row UUID and@@ -551,12 +540,18 @@ extension LibraryRepository { entry.lastSharedAt = record.lastSharedAt entry.modifiedAt = record.modifiedAt entry.intentionallyUnattached = record.intentionallyUnattached- // A 7/8 record carries the citations as one blob (Q25), so the columns- // are written from it and the blob re-encoded from them — Decision 4's- // invariant, stated at the write site as every other citation writer- // states it, so an import into a **non-empty** library cannot leave the- // two homes disagreeing for its own validation to read. Until V9- // (T-2272), which deletes the columns and this call with them.- LegacyColumns.applyCitations(record.citations, on: entry)+ // A 7/8 record carries the citations as one blob (Q25) and the store has+ // exactly one home for them, so the record's value is written straight+ // in. This is a **whole-value** write, not an edit: the record replaces+ // everything the row cited, bytes that would not decode included.+ //+ // Swallowed deliberately, and this function stays non-throwing: the+ // value being encoded came out of `JSONDecoder` moments ago as the same+ // `EntryCitations` type, so the encode has no runtime input that could+ // fail it — the same invariant `EntryCitations.defaultBlob` rests on.+ // Making the whole record-application path throwing to carry an error+ // that cannot arise would put a `try` on every sibling `apply` for+ // nothing.+ try? entry.setCitations(record.citations) } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swiftindex 1bebfa4..fe84968 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift@@ -548,37 +548,42 @@ extension LibraryRepository { ) { row.note = note row.rating = content.rating- // Decision 4: this writes provenance columns on both arms and returns- // early on one of them, so the mirror is refreshed on the way out.- defer { LegacyColumns.refreshCitations(on: row) }-- if content.chapterTitle != nil {- row.chapterTitle = carrier.chapterTitle- row.chapterTitleProvenance = carrier.chapterTitleProvenance- } else if row.chapterTitleProvenance == .manual {- row.chapterTitle = nil- row.chapterTitleProvenance = .none- }-- if content.intentionallyUnattached {- // Q85: every write path that sets the flag nils the pointer, and a- // row that is intentionally unattached and still points somewhere is- // a state nothing else produces.- row.intentionallyUnattached = true- row.work = nil- return- }- row.intentionallyUnattached = false- if content.workAssignment != nil {- // The carrier's own pointer — never the normalised equality key,- // which names a survivor rule's candidate rather than anybody's- // decision (Q67), and never a re-fetch by the content's UUID, which- // throws when that Work has since gone and would fail the whole- // resolution over an assignment.- row.work = carrier.work- row.workAssignmentProvenance = carrier.workAssignmentProvenance- } else if row.workAssignmentProvenance == .manual {- row.workAssignmentProvenance = .none+ // The carrier's citation arms are copied whole rather than kind by kind:+ // the blob's arms *are* the provenance, and copying a kind without its+ // cited rule is a state it cannot spell (Q39).+ let carried = carrier.citationValues++ // One decode and one encode of the survivor's blob for both arms, with+ // the `.manual` guards read off the value already in hand.+ row.editCitations { citations in+ if content.chapterTitle != nil {+ row.chapterTitle = carrier.chapterTitle+ citations.chapterTitle = carried.chapterTitle+ } else if citations.chapterTitle.kind == .manual {+ row.chapterTitle = nil+ citations.chapterTitle = .absent+ }++ if content.intentionallyUnattached {+ // Q85: every write path that sets the flag nils the pointer, and+ // a row that is intentionally unattached and still points+ // somewhere is a state nothing else produces.+ row.intentionallyUnattached = true+ row.work = nil+ return+ }+ row.intentionallyUnattached = false+ if content.workAssignment != nil {+ // The carrier's own pointer — never the normalised equality key,+ // which names a survivor rule's candidate rather than anybody's+ // decision (Q67), and never a re-fetch by the content's UUID,+ // which throws when that Work has since gone and would fail the+ // whole resolution over an assignment.+ row.work = carrier.work+ citations.workAssignment = carried.workAssignment+ } else if citations.workAssignment.provenanceKind == .manual {+ citations.workAssignment = .none+ } } } @@ -652,12 +657,10 @@ extension LibraryRepository { // so each site's answer lands on that site's membership. A hostname // the fold produced no URL for is left alone rather than cleared — // the fold only ever adopts, so "no answer" means "nobody had one",- // never "discard the one this row holds". The superseded column- // mirrors the primary membership beside it (Decision 4).+ // never "discard the one this row holds". for (hostname, url) in union.workURLsByHostname { row.membership(for: hostname)?.workURLString = url }- LegacyColumns.refreshMembership(on: row) // The chosen variant's assignment, written through the one write // table so the compatibility column stays what a pre-feature build // can read (Req 6.11). The fold never moves the type.@@ -743,7 +746,7 @@ extension LibraryRepository { // The character's Work's primary site (Req 1.2): a character has no // hostname of its own, and the site is only needed to name the // quarantine key the resolution validates against.- let hostname = rows.compactMap { $0.work?.membershipValues.first?.hostname }.first ?? ""+ let hostname = rows.compactMap { $0.work?.primaryMembership?.hostname }.first ?? "" if let refusal = try commitResolution( context: context, hostnames: [hostname], operation: "resolution") { return (refusal, [], nil)
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swiftindex 2c9a100..92bd774 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift@@ -114,8 +114,9 @@ extension LibraryRepository { /// /// Cheap by construction. Only a *split* group whose rows carry two /// different **manual** assignments can be read torn by an assignment, so- /// the guard is two scalar reads per row and a relationship fault only for- /// the manual rows of a split group. A library in which no group disagrees —+ /// the guard is one citation decode per row of a split group and a+ /// relationship fault only for its manual rows. A library in which no group+ /// disagrees — /// which is nearly all of them — never fetches a Work at all. internal static func canonicalWorkIDs( normalising rows: [Entry], context: ModelContext
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swiftindex 5572485..06c139a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift@@ -333,8 +333,12 @@ extension LibraryRepository { context.insert(entry) // The conservative-key alias is always the immutable raw URL (Q21). entry.conservativeIdentityKey = contract.request.rawURLString- entry.identityKeyVersion = contract.outcome.composedIdentityKeyVersion entry.identityBasis = contract.outcome.composedIdentityKeyVersion == 1 ? .conservative : .urlRule+ // The basis *version* is a citation now, so it is not written here:+ // `init` seeded the blob at `.rawURL`, which is what every arm below+ // that does not apply a rule leaves it at, and+ // `applyEntryIdentityAndChapter` writes the cited arm for the one+ // that does. // Apply the approved Site-specific outcome. A quarantined Site or a // taught Site with no active title rule (transitional M3 Work-only)@@ -367,20 +371,25 @@ extension LibraryRepository { captureTitle: contract.request.captureTitle, rawURL: contract.request.rawURLString, hostname: validated.hostname, titleRule: titleRule, urlRule: composedURL) - Self.applyEntryIdentityAndChapter(- to: entry, derivation: derivation,- titleRuleID: activePattern.id, titleVersion: activePattern.version, url: url)- // Predicated, not a full-table fetch filtered in memory: this is- // the share extension's commit path.+ // the share extension's commit path. Read before the blob edit+ // below so the derivation and the assignment share one decode. let allWorks = try Self.worksOn( hostname: validated.hostname, context: context)- Self.applyCaptureAssignment(- to: entry, assignment: contract.outcome.composedAssignment, derivation: derivation,- titleRuleID: activePattern.id, titleVersion: activePattern.version, url: url,- allWorks: allWorks, types: try Self.workTypeDirectory(context: context),- hostname: validated.hostname, site: site,- context: context, timestamp: timestamp)+ let types = try Self.workTypeDirectory(context: context)+ entry.editCitations { citations in+ Self.applyEntryIdentityAndChapter(+ to: entry, citations: &citations, derivation: derivation,+ titleRuleID: activePattern.id, titleVersion: activePattern.version,+ url: url)+ Self.applyCaptureAssignment(+ to: entry, citations: &citations,+ assignment: contract.outcome.composedAssignment, derivation: derivation,+ titleRuleID: activePattern.id, titleVersion: activePattern.version,+ url: url, allWorks: allWorks, types: types,+ hostname: validated.hostname, site: site,+ context: context, timestamp: timestamp)+ } } // Validate the written tuple only (Req 6.5, Q9): no full-graph pass.@@ -420,7 +429,8 @@ extension LibraryRepository { /// Applies a composed capture assignment to a single new Entry (Req 6.2). private static func applyCaptureAssignment(- to entry: Entry, assignment: ComposedAssignmentProjection?, derivation: ComposedDerivation,+ to entry: Entry, citations: inout EntryCitations,+ assignment: ComposedAssignmentProjection?, derivation: ComposedDerivation, titleRuleID: UUID, titleVersion: Int, url: (id: UUID, version: Int, definition: URLRuleDefinition)?, allWorks: [Work], types: WorkTypeDirectory, hostname: String, site: Site?, context: ModelContext, timestamp: Date@@ -431,7 +441,7 @@ extension LibraryRepository { case .ambiguous: // The rule processed this Entry but could not resolve a single Work; // record the processing provenance with no Work, matching M3 (Req 9.3).- applyAssignmentProvenance(to: entry, derivation: derivation,+ applyAssignmentProvenance(to: &citations, derivation: derivation, titleRuleID: titleRuleID, titleVersion: titleVersion, url: url) case .reuse(let workID), .claim(let workID): // **Every row of the Work group, not the one row this Entry points@@ -460,7 +470,7 @@ extension LibraryRepository { work.refreshParsedTitle(to: derivation.workName, commit: true) work.modifiedAt = timestamp }- applyAssignmentProvenance(to: entry, derivation: derivation,+ applyAssignmentProvenance(to: &citations, derivation: derivation, titleRuleID: titleRuleID, titleVersion: titleVersion, url: url) case .create(let key): guard let name = derivation.workName, !M2Unicode.isBlank(name) else { break }@@ -477,7 +487,7 @@ extension LibraryRepository { work.lastParsedTitle = name work.titleProvenanceRaw = TitleProvenance.parsed.rawValue entry.work = work- applyAssignmentProvenance(to: entry, derivation: derivation,+ applyAssignmentProvenance(to: &citations, derivation: derivation, titleRuleID: titleRuleID, titleVersion: titleVersion, url: url) } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swiftindex 03a57c4..8161665 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift@@ -129,23 +129,6 @@ extension LibraryRepository { context.delete(row) } - // The superseded columns mirror the **primary** membership- // (Decision 4), and removing a site can be removing that one. Left- // unrefreshed they keep naming the removed site — and- // `BackupArchiveProjection` reads the Work record's hostname from- // the surviving membership and its identity from the columns, so the- // export would then cite a rule belonging to a site the Work is no- // longer on and `BackupArchiveReferenceChecks` would refuse the whole- // archive. Before validation, so the refusal below can see the graph- // the save would write — and from the surviving rows explicitly,- // because `siteMemberships` still hands back the ones deleted above- // until the save.- for row in group.rows {- LegacyColumns.refreshMembership(- on: row,- memberships: row.membershipValues.filter { !removed.contains($0.id) })- }- // Task 8 review: **every** hostname of the Work, not just the one // being removed. A removal on a two-site Work changes what the other // site's graph looks like too — the Work is still on it — and
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+URLIdentity.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+URLIdentity.swiftindex 0e84ac9..ecf1e0d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+URLIdentity.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+URLIdentity.swift@@ -91,8 +91,7 @@ extension LibraryRepository { workID: group.carrier.work?.id, intentionallyUnattached: group.carrier.intentionallyUnattached, parsedWorkTitle: nil,- hasManualAssignment: group.carrier.workAssignmentProvenanceRaw- == FieldProvenanceKind.manual.rawValue+ hasManualAssignment: group.carrier.workAssignmentProvenance == .manual ) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swiftindex 9c149cd..0d0c211 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift@@ -110,11 +110,6 @@ extension LibraryRepository { // group that has not been healed onto the hostname yet simply has // nothing to write — the heal adds it, and the next commit lands. row.membership(for: hostname)?.workURLString = contract.outcome.resultingURL- // The superseded column mirrors the primary membership until V9- // (Decision 4): `GroupOrdering`'s authored content still reads it,- // so a confirmed URL that skipped it would stop counting as a- // reason two rows are torn.- LegacyColumns.refreshMembership(on: row) // Q74: one stamp for the whole group, or a write that was never // about which row represents it moves which row represents it. row.modifiedAt = urlTimestamp@@ -434,10 +429,6 @@ extension LibraryRepository { } } }- // The superseded columns mirror the primary membership (Decision 4),- // and a moved membership can change which one that is.- for row in targetGroup.rows { LegacyColumns.refreshMembership(on: row) }- // Req 3.4: the source's characters move to the target with their // entry citations intact, its generic-notes citations repointed to // the target's generic notes, its suppressions unioned, and the
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex 268ad06..4d02647 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -182,17 +182,16 @@ public actor LibraryRepository { if scan.duplicateCandidateCount > 0, duplicatePhaseSkipped { duplicateFollowUpNeeded = true }- // The same shape for the V8 conversion passes (Decision 5). No latch is- // needed here: an unconverted row that arrives after this refresh is- // still unconverted when the next scan sees it, and the full-tier pass- // that every launch, import and reader action runs is unconditional. A- // duplicate set is different — the phase that processed it clears the- // candidate, so a skipped pass has to be remembered.- var pending: V8PopulationPass.Phases = []- if scan.worksWithoutMembershipCount > 0 { pending.insert(.memberships) }- if scan.entriesWithoutCitationBlobCount > 0 { pending.insert(.citations) }- if scan.patternsWithoutDefinitionBlobCount > 0 { pending.insert(.definitions) }- pendingConversionPhases = pending+ // The same shape for the membership heal (Q10). No latch is needed+ // here: a Work that arrives without a membership after this refresh is+ // still without one when the next scan sees it. A duplicate set is+ // different — the phase that processed it clears the candidate, so a+ // skipped pass has to be remembered.+ //+ // The two blob counters are **reports**, not gates: nothing converts a+ // row any more, so a nil blob is a state the reader is told about and+ // the archive repairs.+ worksWithoutMembership = scan.worksWithoutMembershipCount > 0 } /// The chunk size every bulk pass commits in — the reconciler's re-pin and@@ -275,16 +274,13 @@ public actor LibraryRepository { /// candidates when the session's first pass runs, so that one pass runs the /// phase whatever the counters say; every pass after it consults them. internal var hasRunDuplicatePhaseThisSession = false- /// Which V8 conversion phases the last tolerance scan found unconverted- /// rows for — the arrival tier's gate for the population pass and the- /// membership heal (Decision 5).+ /// Whether the last tolerance scan counted a Work with no membership — the+ /// gate for `MembershipReconciler`'s heal phase (Q10). ///- /// The three counters this replaces were only ever read as `> 0`, once each,- /// to build exactly this set. Storing the set stores the answer rather than- /// three numbers and a rule for turning them into it. Empty from the open,- /// which is the right answer for the arrival tier: the launch pass runs- /// `.full` and is unconditional.- internal var pendingConversionPhases: V8PopulationPass.Phases = []+ /// False from the open, and that is the right answer: the heal is a repair+ /// for a state a lagging writer produced, so a library nothing has scanned+ /// has nothing to repair and the next refresh arms it if it has.+ internal var worksWithoutMembership = false /// Makes the Site graph coherent after records arrive (Req 1.1–1.8). ///@@ -348,8 +344,6 @@ public actor LibraryRepository { } let cachedTuples = diagnostics.tupleDiagnoses let runsDuplicatePhase = duplicatePhaseRuns(tier: tier)- let populationPhases = self.populationPhases(tier: tier)- let membershipHealRuns = self.membershipHealRuns(tier: tier) bulkOperationInProgress = true defer { bulkOperationInProgress = false }@@ -370,25 +364,19 @@ public actor LibraryRepository { context: context, saveStrategy: saveStrategy) - // Steps 3 and 4, inside the same exclusive context as the Site- // phase. The population pass converts anything a lagging V7 device- // synced in since the migration (Req 2.7, Q36) — memberships and- // both blobs, behind the same guards the bootstrap arm runs it with- // — and the membership reconciler then heals, de-duplicates and- // re-attaches what arrived (Req 8.1–8.3, 5.8).+ // Step 3, inside the same exclusive context as the Site phase: the+ // membership reconciler heals, de-duplicates and re-attaches what+ // arrived (Req 8.1–8.3, 5.8). //- // **Tiered** (Decision 5). The full tier is unconditional and keeps- // Decision 4's value guard, which is what re-derives a blob a- // column writer left stale. The arrival tier runs the whole-library- // phases only where the last tolerance scan counted rows for them —- // otherwise every remote-change debounce charged a converged- // library four whole-table walks for nothing (Req 8.6).- try V8PopulationPass.run(- context: context, batchSize: Self.bulkOperationBatchSize,- saveStrategy: saveStrategy, phases: populationPhases)+ // `V8PopulationPass` ran here until V9 and is gone with the columns+ // it converted from. Its **heal** gate survives it (Q10), on both+ // tiers rather than only the arrival one: heal walks the whole Entry+ // table — 0.505 s over the M4 fixture with nothing to do — and a+ // library whose every Work holds a membership has nothing for it to+ // find, whichever tier asked. pass.memberships = try MembershipReconciler.run( context: context, batchSize: Self.bulkOperationBatchSize,- saveStrategy: saveStrategy, heals: membershipHealRuns)+ saveStrategy: saveStrategy, heals: worksWithoutMembership) // The hostnames this pass wrote to. The Site half is filtered by the // cached tuple set — a hostname with nothing to shed costs nothing,@@ -534,37 +522,11 @@ public actor LibraryRepository { } } - /// Which `V8PopulationPass` phases this tier's pass runs (Decision 5).- ///- /// The **full** tier runs all three, and must: phase 2 is a *value* guard,- /// not an existence guard (Decision 4), so it is the only thing that- /// re-derives a blob a lagging V7 device left stale by rewriting the columns- /// under it — a state no counter can see, because the row has a blob. Launch,- /// import re-fire and every reader action are full-tier, so convergence is- /// bounded by those rather than by the debounce.- ///- /// The **arrival** tier runs a phase only where the last tolerance scan- /// counted unconverted rows for it. That is the Req 2.7 shape exactly — a- /// V7 writer syncs in a Work with no membership row and an Entry with no- /// citation blob, and both are counted — and it is what keeps a converged- /// library from paying three whole-table fetches per debounce (Req 8.6).- private func populationPhases(tier: ReconcilePassTier) -> V8PopulationPass.Phases {- tier == .arrival ? pendingConversionPhases : .all- }-- /// Whether this tier's pass runs `MembershipReconciler`'s heal phase- /// (Decision 5) — the one phase of that pass which walks the whole Entry- /// table, measured at 0.505 s over the M4 fixture with nothing to do.- ///- /// Gated on the same counter as the membership population phase, and- /// deliberately: `populateMemberships` mints from a Work's retained- /// hostname column, so on an arrival that carries the Req 2.7 shape it runs- /// first and heal only has to reach what it could not mint for — a Work with- /// a blank hostname, or one whose Entries are on a hostname its memberships- /// are not.- private func membershipHealRuns(tier: ReconcilePassTier) -> Bool {- tier == .full || pendingConversionPhases.contains(.memberships)- }+ // `populationPhases(tier:)` and `membershipHealRuns(tier:)` stood here.+ // The first chose which `V8PopulationPass` phases a tier ran; the second+ // made the heal unconditional on the full tier because the pass's own value+ // guard was. Both went with the pass: the heal's gate is now the diagnosis+ // counter alone, on every tier (Q10). /// Req 2.1's second half: the settled sets' losing members go, in a **fresh** /// context (Q61), with their saves chunked (Decision 29).@@ -1359,13 +1321,11 @@ public actor LibraryRepository { /// validates and so rolled the whole deletion back; `moveEntry` does not, /// and saved a graph the next open quarantines. internal static func applyManualAssignment(to row: Entry, at timestamp: Date) {- row.workAssignmentProvenance = .manual- row.workPatternID = nil- row.workPatternVersion = nil- row.workURLRuleID = nil- row.workURLRuleVersion = nil- row.workURLAssignmentKindRaw = nil- LegacyColumns.refreshCitations(on: row)+ row.editCitations { citations in+ citations.workAssignment = .manual+ citations.workURL = nil+ citations.workURLAssignmentKind = nil+ } row.modifiedAt = timestamp } @@ -1405,56 +1365,52 @@ public actor LibraryRepository { existingWorks: [UUID: Work], timestamp: Date ) {- // Apply chapter- switch projection.chapterProvenance {- case .preserved:- break // Keep existing- case .cleared:- entry.chapterTitle = nil- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.none.rawValue- entry.chapterPatternID = nil- entry.chapterPatternVersion = nil- case .pendingPattern:- entry.chapterTitle = projection.projectedChapter- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = patternID- entry.chapterPatternVersion = patternVersion- }+ let cited = CitedRule(id: patternID, version: patternVersion)++ // One decode and one encode of the blob for both arms: the chapter and+ // the assignment are two citations in the same value.+ entry.editCitations { citations in+ // Apply chapter+ switch projection.chapterProvenance {+ case .preserved:+ break // Keep existing+ case .cleared:+ entry.chapterTitle = nil+ citations.chapterTitle = .absent+ case .pendingPattern:+ entry.chapterTitle = projection.projectedChapter+ citations.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: cited.id, patternVersion: cited.version)+ } - // Apply assignment- switch projection.assignmentProvenance {- case .preserved:- break // Keep existing- case .cleared:- entry.work = nil- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.none.rawValue- entry.workPatternID = nil- entry.workPatternVersion = nil- entry.intentionallyUnattached = false- case .pendingPattern:- if let workOutcome = projection.workOutcome {- switch workOutcome {- case .reuse(let workID):- if let work = existingWorks[workID] {- entry.work = work- }- case .create(let title):- if let work = createdWorks[title] {- entry.work = work+ // Apply assignment+ switch projection.assignmentProvenance {+ case .preserved:+ break // Keep existing+ case .cleared:+ entry.work = nil+ citations.workAssignment = .none+ entry.intentionallyUnattached = false+ case .pendingPattern:+ if let workOutcome = projection.workOutcome {+ switch workOutcome {+ case .reuse(let workID):+ if let work = existingWorks[workID] {+ entry.work = work+ }+ case .create(let title):+ if let work = createdWorks[title] {+ entry.work = work+ }+ case .ambiguous:+ entry.work = nil }- case .ambiguous:- entry.work = nil }+ citations.workAssignment = .pattern(cited)+ entry.intentionallyUnattached = false }- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.workPatternID = patternID- entry.workPatternVersion = patternVersion- entry.intentionallyUnattached = false } - // Decision 4: the columns lead and the blob mirrors, refreshed at the- // write site so the re-parse commit's own validation reads what it wrote.- LegacyColumns.refreshCitations(on: entry) entry.modifiedAt = timestamp } @@ -1812,28 +1768,30 @@ public actor LibraryRepository { return true } - /// The four raw enum columns are read through the model's tolerant accessors+ /// The raw enum columns are read through the model's tolerant accessors /// (Q2): a spelling this build has no case for reads as the column's default /// — nil for `ratingRaw`, which is optional — rather than failing the whole /// screen with `corruptLibrary`. ///- /// `FieldProvenance`'s throwing init stays. It validates the *combination*- /// of kind, pattern id and version, which is an invariant about the row's- /// own columns rather than a spelling from a newer build, and nothing about- /// enum tolerance makes an id-less `.pattern` provenance legal. The one- /// thing it must not do is refuse a combination the coercion itself- /// manufactured — see `toleratedProvenance`.+ /// Both provenances come out of the citation blob, which types them: the+ /// kind *is* the case, and the case carries its own cited rule or carries+ /// none, so there is no combination left to validate and no unrecognised+ /// spelling to coerce. `toleratedProvenance` and its two coercion arms went+ /// with the columns.+ ///+ /// **Bytes that will not decode still fail closed here**, which is what the+ /// combination check used to do and for the same reason: enum tolerance is+ /// about a spelling a newer build wrote, while a malformed row is a fault in+ /// the row itself and the mapper must not invent a value for it. A *nil*+ /// blob is neither (Q6) — it is a row V9 has not rewritten, it reads as the+ /// default, and the validator reports it. internal static func snapshot(_ entry: Entry) throws -> EntrySnapshot {- let chapter = try Self.toleratedProvenance(- raw: entry.chapterTitleProvenanceRaw,- patternID: entry.chapterPatternID,- patternVersion: entry.chapterPatternVersion- )- let assignment = try Self.toleratedProvenance(- raw: entry.workAssignmentProvenanceRaw,- patternID: entry.workPatternID,- patternVersion: entry.workPatternVersion- )+ let citations = try entry.citations+ let chapter = citations.chapterTitle+ let assignment = FieldProvenance.tolerant(+ kind: citations.workAssignment.provenanceKind,+ patternID: citations.workAssignment.patternRule?.id,+ patternVersion: citations.workAssignment.patternRule?.version) return EntrySnapshot( id: entry.id, captureTitle: entry.captureTitle,@@ -1842,7 +1800,7 @@ public actor LibraryRepository { canonicalURLString: entry.canonicalURLString, hostname: entry.hostname, entryIdentityKey: entry.entryIdentityKey,- identityKeyVersion: entry.identityKeyVersion,+ identityKeyVersion: citations.identity.keyVersion, chapterTitle: entry.chapterTitle, chapterTitleProvenance: chapter, note: entry.note,@@ -1858,29 +1816,11 @@ public actor LibraryRepository { ) } - /// A provenance kind read tolerantly, with the citation columns dropped- /// where the raw spelling is one this build has no case for.- ///- /// The coercion is a presentation choice about the *kind* (Q2), and it- /// cannot be allowed to invent an illegal row. A newer build's provenance- /// kind legitimately cites a pattern id and version; coerced to `.none`,- /// which carries neither, the pair would fail `FieldProvenance`'s- /// combination check and take the whole snapshot down — exactly the- /// screen-wide refusal the tolerance exists to remove. So the citation- /// travels with the kind it belonged to: a coerced row presents as uncited.- ///- /// A spelling this build *does* know keeps the check untouched, illegal- /// combination and all: an id-less `.pattern` is a fault in the row's own- /// columns, not a message from a newer build.- private static func toleratedProvenance(- raw: String, patternID: UUID?, patternVersion: Int?- ) throws -> FieldProvenance {- guard let kind = FieldProvenanceKind(rawValue: raw) else {- return try FieldProvenance(kind: .none)- }- return try FieldProvenance(- kind: kind, patternID: patternID, patternVersion: patternVersion)- }+ // `toleratedProvenance` stood here: a provenance kind read out of a raw+ // column, coerced to `.none` for a spelling this build had no case for and+ // stripped of its citation so the coercion could not manufacture an illegal+ // `FieldProvenance`. The blob types the provenance, so neither half has+ // anything left to do (V9, T-2280). // `validateStore` stood here — the superseded opener's validation, via the // since-deleted `V2LibraryValidator` over a backup snapshot of the whole
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swiftindex 682328f..32d907b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryValidator.swift@@ -801,10 +801,18 @@ public enum LibraryValidator { let id = entry.id.uuidString let citation = CitationContext( citingSite: entry.site, tolerant: tolerateUnlinkedCitations)- // V8: every citation this arm reads comes out of the blob (Q25), which- // falls back to the legacy columns while a row is unconverted (Q36). A- // blob that will not decode is its own diagnosis, named after the Entry- // rather than reported as a fan of tuple failures.+ // V9: the blob is the only home a citation has (multi-site-works Q25).+ // Bytes that will not decode are a fault in the row itself and are+ // **thrown**, which quarantines the hostname the way every other+ // unreadable row does.+ //+ // **No bytes at all is a report, not a quarantine** (Req 19). It is a+ // row a V8 build wrote that the drop stage reached before anything+ // converted it: the row is intact apart from its provenance, it reads as+ // "cites nothing" everywhere else so a screen still renders, and+ // quarantining its hostname would take away every write on that site+ // over a loss no write can undo. `entriesWithoutCitationBlobCount`+ // counts it and the archive repairs it. let citations: EntryCitations do { citations = try entry.citations } catch {@@ -1035,9 +1043,9 @@ public enum LibraryValidator { /// The chapter arm, over the blob's `chapterTitle` provenance. /// /// The unrecognised-raw guard is gone with the column: the blob types the- /// kind, and a spelling a newer build wrote reads as `.none` through- /// `LegacyColumns` exactly as it does everywhere else in the schema (Q25,- /// T-2271 Q2). What is left is the pairing of value, kind and citation.+ /// kind, and a spelling a newer build wrote reads as `.none` exactly as it+ /// does everywhere else in the schema (Q25, T-2271 Q2). What is left is the+ /// pairing of value, kind and citation. private static func validateChapter( _ entry: Entry, id: String,@@ -1076,6 +1084,11 @@ public enum LibraryValidator { /// arm's citation and the "stray pattern reference" guards have nothing left /// to catch. The URL arm's own two citations — the cited rule and the /// assignment kind — are still paired here, because they are the arm's.+ ///+ /// The `.none` arm reads `entry.citationsAreAbsent`: no citation bytes at+ /// all is the same value as bytes decoding to `.none` and a different fact —+ /// the second is a partial write, the first is a row the V8 → V9 drop stage+ /// reached before anything converted it (Q6). private static func validateAssignment( _ entry: Entry, id: String,@@ -1088,7 +1101,16 @@ public enum LibraryValidator { switch citations.workAssignment { case .none:- guard work == nil, !hasURLReference,+ // `work == nil` is the invariant for a row whose citations were+ // actually written: nothing assigns an Entry to a Work without+ // recording how. A **nil blob** is the one exception, and it is the+ // same exception the unreadable-citations arm makes one call up+ // (Q25): the assignment provenance is what the drop stage took, the+ // relationship is not, and quarantining the hostname over it would+ // withdraw every write on the site — the manual re-assignment that+ // is the only repair included. The row is counted by+ // `entriesWithoutCitationBlobCount` instead.+ guard work == nil || entry.citationsAreAbsent, !hasURLReference, entry.intentionallyUnattached == (site.mode == .articles) else { throw invalid("Entry", id, "none assignment has incompatible relationship or provenance") }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swiftindex c863230..02ccebe 100644--- a/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift@@ -313,9 +313,10 @@ extension LibraryRepository { func attach(_ entry: Entry, to work: Work?) { guard let work else { return } entry.work = work- entry.workAssignmentProvenance = .pattern- entry.workPatternID = activePattern.id- entry.workPatternVersion = activePattern.version+ entry.editCitations {+ $0.workAssignment = .pattern(+ CitedRule(id: activePattern.id, version: activePattern.version))+ } } // 50 Work sets: two Works sharing one parsed title (§2.4's title arm —
diff --git a/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swiftindex bb399f2..5fddf28 100644--- a/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift@@ -121,10 +121,10 @@ enum MembershipReconciler { /// The `(work, hostname)` pair Req 1.1 forbids twice over. ///- /// Internal, and the package's only one: `V8PopulationPass` and- /// `BackupArchiveProjection` each declared their own copy of the same two- /// fields for the same group-by. One key, so a change to what "one- /// membership" means cannot land in two of the three places.+ /// Internal, and the package's only one: `BackupArchiveProjection` used to+ /// declare its own copy of the same two fields for the same group-by. One+ /// key, so a change to what "one membership" means cannot land in two+ /// places. struct Key: Hashable { var workID: UUID var hostname: String@@ -139,25 +139,11 @@ enum MembershipReconciler { /// (Req 2.6) still write the same date. static let healStep: TimeInterval = 0.001 - /// Mirrors the superseded columns of every Work a phase wrote to- /// (Decision 4).- ///- /// A phase that adds, re-attaches or deletes a membership can change which- /// one is *primary*, and the columns mirror the primary. Left unrefreshed- /// they name a site the Work may no longer be on first, which- /// `BackupArchiveProjection` reads for the identity half of a Work record- /// and `BackupArchiveReferenceChecks` then refuses — a reconcile that- /// repaired the graph would have broken the export.- private static func refreshColumns(- of works: [UUID: Work],- saveStrategy: any RepositorySaveStrategy,- context: ModelContext- ) throws {- guard !works.isEmpty else { return }- var dirty = false- for work in works.values where LegacyColumns.refreshMembership(on: work) { dirty = true }- if dirty { try saveStrategy.save(context) }- }+ // `refreshColumns` stood here: after every phase that could change which+ // membership is *primary*, it mirrored that membership back into the six+ // superseded Work columns, because `BackupArchiveProjection` read a Work+ // record's identity out of them. Both halves went at V9 — the projection+ // reads the membership, and nothing writes the columns at all. // MARK: - Phase 0: re-attach (Q37) @@ -184,7 +170,6 @@ enum MembershipReconciler { worksByID[work.id, default: []].append(work) } - var touched: [UUID: Work] = [:] var unarrived: Set<UUID> = [] for chunk in LibraryRepository.chunks(of: orphans, size: batchSize) { var dirty = false@@ -196,14 +181,12 @@ enum MembershipReconciler { continue } membership.work = work- touched[work.id] = work report.reattached += 1 report.touchedHostnames.insert(membership.hostname) dirty = true } if dirty { try saveStrategy.save(context) } }- try refreshColumns(of: touched, saveStrategy: saveStrategy, context: context) return unarrived } @@ -224,7 +207,7 @@ enum MembershipReconciler { /// already carries it, so a heal would *tie* with it and the `(createdAt, /// id)` order that decides the primary would fall through to two random /// UUIDs. A heal that won that toss would move the site the Work is- /// presented under (Req 6.4), move what the superseded columns mirror, and+ /// presented under (Req 6.4) and /// hand the export a Work record whose identity half names one site and /// whose hostname names another. So the heal takes the **latest** date the /// Work's memberships already hold, one `healStep` on: a repair joins the@@ -268,7 +251,6 @@ enum MembershipReconciler { // declared escaping because the rows this inserts are memberships, which // the Entry enumeration does not read. var pending = 0- var touched: [UUID: Work] = [:] var inserted: [WorkSiteMembership] = [] try context.enumerate( FetchDescriptor<Entry>(), batchSize: batchSize, allowEscapingMutations: true@@ -278,7 +260,6 @@ enum MembershipReconciler { // membership per `(work, hostname)` however many Entries ask. guard held.insert(Key(workID: work.id, hostname: entry.hostname)).inserted else { return }- touched[work.id] = work let membership = WorkSiteMembership( hostname: entry.hostname, createdAt: healCreatedAt(for: work),@@ -302,7 +283,6 @@ enum MembershipReconciler { } } if pending > 0 { try saveStrategy.save(context) }- try refreshColumns(of: touched, saveStrategy: saveStrategy, context: context) return inserted } @@ -311,7 +291,7 @@ enum MembershipReconciler { /// only case where a healed row is legitimately the primary, because it is /// the only one. private static func healCreatedAt(for work: Work) -> Date {- guard let latest = work.membershipValues.map(\.createdAt).max() else {+ guard let latest = (work.siteMemberships ?? []).map(\.createdAt).max() else { return work.createdAt } return max(latest, work.createdAt).addingTimeInterval(healStep)@@ -350,11 +330,9 @@ enum MembershipReconciler { } var losers: [WorkSiteMembership] = []- var touched: [UUID: Work] = [:] for (key, rows) in groups where rows.count > 1 { losers.append(contentsOf: survivorFirst(rows).dropFirst()) report.touchedHostnames.insert(key.hostname)- if let work = rows.compactMap(\.work).first { touched[work.id] = work } } guard !losers.isEmpty else { return } @@ -365,7 +343,6 @@ enum MembershipReconciler { } try saveStrategy.save(context) }- try refreshColumns(of: touched, saveStrategy: saveStrategy, context: context) } /// Q23's order: the strongest identity state first, then the earliest
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex b2f88a5..abd9f74 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -1,34 +1,35 @@ import Foundation import SwiftData -// The live model classes are V8's, nested inside `AsterismSchemaV8` (Decision 6,+// The live model classes are V9's, nested inside `AsterismSchemaV9` (Decision 6, // Q20). Top-level typealiases keep every call site (`Entry`, `Site`, …) unchanged. //-// The nesting is what makes the frozen `AsterismSchemaV5`, `AsterismSchemaV6`-// and `AsterismSchemaV7` snapshots possible: they carry nested classes with the-// same SwiftData entity names, which is legal only while there is exactly one-// *top-level* `@Model` per entity name — and there are none, because every-// top-level name here is a typealias. Two top-level `@Model`s sharing an entity-// name crash `ModelContext` (`docs/agent-notes/schema-migration.md`).-public typealias Entry = AsterismSchemaV8.Entry-public typealias Work = AsterismSchemaV8.Work-public typealias Site = AsterismSchemaV8.Site-public typealias TitlePattern = AsterismSchemaV8.TitlePattern-public typealias URLRulePattern = AsterismSchemaV8.URLRulePattern-public typealias WorkTypeEntity = AsterismSchemaV8.WorkTypeEntity+// The nesting is what makes the frozen `AsterismSchemaV8` snapshot possible: it+// carries nested classes with the same SwiftData entity names, which is legal+// only while there is exactly one *top-level* `@Model` per entity name — and+// there are none, because every top-level name here is a typealias. Two+// top-level `@Model`s sharing an entity name crash `ModelContext`+// (`docs/agent-notes/schema-migration.md`).+public typealias Entry = AsterismSchemaV9.Entry+public typealias Work = AsterismSchemaV9.Work+public typealias Site = AsterismSchemaV9.Site+public typealias TitlePattern = AsterismSchemaV9.TitlePattern+public typealias URLRulePattern = AsterismSchemaV9.URLRulePattern+public typealias WorkTypeEntity = AsterismSchemaV9.WorkTypeEntity /// V8: a Work's presence on one site (Q3). One row per hostname, carrying that-/// site's URL identity, the rule that derived it and the confirmed Work URL.-public typealias WorkSiteMembership = AsterismSchemaV8.WorkSiteMembership+/// site's URL identity, the rule that derived it and the confirmed Work URL —+/// and since V9 the only home any of them has.+public typealias WorkSiteMembership = AsterismSchemaV9.WorkSiteMembership /// V8: a reader's "not the same work" over an unordered pair of Works (Q20).-public typealias WorkDistinctPair = AsterismSchemaV8.WorkDistinctPair+public typealias WorkDistinctPair = AsterismSchemaV9.WorkDistinctPair // `Character` is deliberately **not** aliased at the top level: the stdlib owns // that name, and shadowing it module-wide would silently retype every // `[Character]` in `MarkdownExport` and `HTMLEntityDecoder` — and every one a // future file writes. The SwiftData entity is still named "Character" (that is // the nested class's name, and what the CloudKit record type and the archive // key on); only the Swift spelling call sites use is qualified.-public typealias CharacterRecord = AsterismSchemaV8.Character-public typealias CharacterSuppression = AsterismSchemaV8.CharacterSuppression+public typealias CharacterRecord = AsterismSchemaV9.Character+public typealias CharacterSuppression = AsterismSchemaV9.CharacterSuppression /// The presentation-enum tolerance policy for the **model accessors**, in one /// place (Q2).@@ -70,416 +71,6 @@ internal enum ToleratedEnum { } } -/// The **only** reader of the columns V8 superseded (Decision 3, Q34).-///-/// V8 stops using `Work`'s six site/identity/URL columns, `Entry`'s-/// `identityKeyVersion` and seventeen citation columns, and `TitlePattern`'s ten-/// definition columns — but it cannot drop them, because the lightweight stage-/// runs inside `ModelContainer.init` and would destroy the source before-/// `V8PopulationPass` could read it. Column names cannot change either: a rename-/// *is* a schema change.-///-/// So the mechanism is access control plus one reader. Each superseded column is-/// `internal`, which makes any reference from the app or extension targets a-/// compile error (the precedent is `Site.works`/`Site.entries`), and inside the-/// package these three functions are where they are read. `ModelContractTests`-/// pins both halves: a source grep that no other file names them, and a-/// `Schema(...).entities` check that every one is still in the V8 schema, so the-/// V9 drop has something to drop.-///-/// Every read function is pure and returns the V8 value the columns encode. They-/// serve `V8PopulationPass` and the nil-blob fallbacks, so an Entry or-/// TitlePattern written by a lagging V7 device (Req 2.7) is never read as empty.-/// The two `rewrite` helpers and `refreshCitations` are the transitional writers-/// Decision 4 leaves here, and go with the columns in V9.-enum LegacyColumns {-- /// A Work's site presence as the six superseded columns hold it — the- /// content of the single `WorkSiteMembership` the population pass mints for- /// it (Req 2.1).- struct Membership: Equatable, Sendable {- var hostname: String- var createdAt: Date- var urlIdentity: String?- var urlIdentityState: WorkURLIdentityState- /// The rule UUID only: memberships cite by identity, never by version- /// (Req 10.4, Q28), so the retired `urlIdentityRuleVersion` is dropped- /// here rather than carried into V8.- var urlIdentityRuleID: UUID?- var workURLString: String?- }-- static func membership(from work: Work) -> Membership {- Membership(- hostname: work.siteHostname,- createdAt: work.createdAt,- urlIdentity: work.urlIdentity,- urlIdentityState: ToleratedEnum.read(work.urlIdentityStateRaw, default: .none),- urlIdentityRuleID: work.urlIdentityRuleID,- workURLString: work.workURLString)- }-- /// The Site row a migrated membership pins to, mirroring what `Work.site`- /// held. Kept here because `Work.site` is one of the superseded columns.- static func site(from work: Work) -> Site? { work.site }-- /// The identity-state column **exactly as stored**, which `Membership`- /// cannot carry: it reads the column through `ToleratedEnum` (Q2), and a- /// spelling this build has no case for therefore arrives as `.none`. The- /// 6/7 export refuses such a row by name rather than archiving a value the- /// store does not hold (Q8), so it needs the raw string.- static func urlIdentityStateRaw(from work: Work) -> String { work.urlIdentityStateRaw }-- /// Writes that column **raw**, which `update` cannot: `Membership` types the- /// state, so a spelling this build has no case for has no way through it.- /// The tolerance suite writes one on purpose.- static func setURLIdentityStateRaw(_ raw: String, on work: Work) {- work.urlIdentityStateRaw = raw- }-- /// The write door for the six superseded columns, mirroring- /// `membership(from:)` in the other direction.- ///- /// The columns are `fileprivate` on `Work` (Q59), so this and `setSite` are- /// the only ways to write them from anywhere — including from a `@testable`- /// suite, which `fileprivate` shuts out exactly as it shuts out the app.- /// Since task 21 the only writers are the fixtures that seed a V7-shaped- /// Work: the 6/7 import mapper went with its format, and every production- /// write now lands on the membership with `refreshMembership` mirroring it- /// back. `createdAt` is not written back: it belongs to the Work, and- /// `Membership` only carries it so a minted membership can copy it.- static func update(_ work: Work, _ edit: (inout Membership) -> Void) {- var value = membership(from: work)- edit(&value)- work.siteHostname = value.hostname- work.urlIdentity = value.urlIdentity- work.urlIdentityStateRaw = value.urlIdentityState.rawValue- work.urlIdentityRuleID = value.urlIdentityRuleID- work.workURLString = value.workURLString- }-- /// `Work.site` alone: `Membership` cannot carry it, because a `Site` is a- /// model reference and `Membership` is a `Sendable` value.- static func setSite(_ site: Site?, on work: Work) { work.site = site }-- /// Mirrors the Work's **primary** membership back into the six superseded- /// site/identity/URL columns, and reports whether anything moved.- ///- /// The Work counterpart of `refreshCitations`, and the same bargain- /// (Decision 4): from tasks 12-13 the membership is the source of truth for a- /// Work's site presence, URL identity and confirmed Work URL, and the columns- /// are a derived mirror kept in step beside it until V9 drops them.- ///- /// The mirror is the *primary* membership — the first in membership order —- /// because the columns are single-site by construction and that is the site- /// every legacy reader means by "the Work's site". A second membership's- /// identity and URL live only on the membership, which is exactly the- /// difference V8 exists to represent; the readers that still consult the- /// columns (`GroupOrdering`'s authored content and row ordering,- /// `DuplicateScan`, `LibraryDiagnostics`) therefore see a multi-site Work as- /// its primary site, and move to the memberships with their own tasks.- ///- /// `urlIdentityRuleVersion` is mirrored too, and it is the one field the- /// membership cannot hand over: a membership cites by UUID alone (Req 10.4,- /// Q28), so the version is **resolved** from the rule rows of the primary- /// membership's own Site (Q60). Three arms, because a version that does not- /// describe the cited rule is what `LibraryValidator`'s `.rule` arm and- /// `BackupArchiveReferenceChecks` refuse over:- ///- /// * no rule cited — the column is cleared, as `applyURLIdentity` clears it;- /// * the cited row is there — the column takes its version;- /// * the cited row is **not** there (Q54's dangling citation, or a Site this- /// membership was never pinned to) — the column is left alone where the- /// citation has not moved, and cleared where it has, because the version- /// then describes a rule nothing cites any more.- ///- /// - Parameter memberships: the rows to mirror from, where the caller knows- /// better than the inverse array does. A commit that has *deleted* rows- /// and not yet saved is the case: `work.siteMemberships` can still hand- /// them back, and mirroring a row that is on its way out is how a removal- /// leaves the columns naming the site it just removed. Nil reads the- /// Work's own memberships, which is right everywhere else.- @discardableResult- static func refreshMembership(- on work: Work, memberships: [WorkSiteMembership]? = nil- ) -> Bool {- let primary = (memberships.map(Work.membershipOrder) ?? work.membershipValues).first- let hostname = primary?.hostname ?? work.siteHostname- let priorRuleID = work.urlIdentityRuleID- var changed = false- func assign<Value: Equatable>(_ keyPath: ReferenceWritableKeyPath<Work, Value>, _ value: Value) {- guard work[keyPath: keyPath] != value else { return }- work[keyPath: keyPath] = value- changed = true- }- assign(\.siteHostname, hostname)- assign(\.urlIdentity, primary?.urlIdentity)- assign(\.urlIdentityStateRaw, (primary?.urlIdentityState ?? .none).rawValue)- assign(\.urlIdentityRuleID, primary?.urlIdentityRuleID)- assign(\.workURLString, primary?.workURLString)- if let ruleID = primary?.urlIdentityRuleID {- if let version = primary?.site?.urlRuleValues.first(where: { $0.id == ruleID })?.version {- assign(\.urlIdentityRuleVersion, version)- } else if ruleID != priorRuleID {- assign(\.urlIdentityRuleVersion, nil)- }- } else {- assign(\.urlIdentityRuleVersion, nil)- }- if let site = primary?.site, work.site !== site {- work.site = site- changed = true- }- return changed- }-- static func citations(from entry: Entry) -> EntryCitations {- let identityURL = CitedRule(- id: entry.identityURLRuleID, version: entry.identityURLRuleVersion)- let nameTitle = CitedRule(- id: entry.identityNameTitleRuleID, version: entry.identityNameTitleRuleVersion)- let identity: EntryCitations.Identity- switch (entry.identityKeyVersion, identityURL) {- case (2, .some(let url)): identity = .rule(url: url, nameTitle: nameTitle)- case (3, .some(let url)): identity = .composed(url: url, nameTitle: nameTitle)- default: identity = .rawURL- }-- let workPattern = CitedRule(id: entry.workPatternID, version: entry.workPatternVersion)- let workURL = CitedRule(id: entry.workURLRuleID, version: entry.workURLRuleVersion)- let assignmentKind: FieldProvenanceKind = ToleratedEnum.read(- entry.workAssignmentProvenanceRaw, default: .none)- // **One rule, applied everywhere** (Q39): the stored kind picks the arm,- // the arm reads only its own citation column, and an arm whose citation- // is absent degrades to `.none`. A citation belonging to another arm —- // a work pattern under `.manual`, a work URL rule under `.pattern` — is- // dropped, because only a partial write produces one and the columns- // still hold it until V9. The identity switch above says the same thing- // in the same words: the key version picks the arm, and an arm with no- // cited URL rule degrades to `.rawURL`.- let assignment: EntryCitations.WorkAssignment- switch assignmentKind {- case .none:- assignment = .none- case .manual:- assignment = .manual- case .urlRule:- assignment = workURL.map { .urlRule($0) } ?? .none- case .pattern:- assignment = workPattern.map { .pattern($0) } ?? .none- }-- return EntryCitations(- identity: identity,- urlWork: CitedRule(id: entry.urlWorkRuleID, version: entry.urlWorkRuleVersion),- chapterSequence: CitedRule(- id: entry.chapterSequenceRuleID, version: entry.chapterSequenceRuleVersion),- chapterTitle: FieldProvenance.tolerant(- kind: ToleratedEnum.read(entry.chapterTitleProvenanceRaw, default: .none),- patternID: entry.chapterPatternID,- patternVersion: entry.chapterPatternVersion),- workAssignment: assignment,- workURL: workURL,- workURLAssignmentKind: ToleratedEnum.read(entry.workURLAssignmentKindRaw))- }-- /// Writes an `EntryCitations` value **into the columns**, then refreshes the- /// blob from them so the two homes agree by construction.- ///- /// The one caller is the archive import (task 21): a 7/8 record carries the- /// blob and nothing else, and every remaining column reader would otherwise- /// see the pre-import citations on a row the import just rewrote. Going- /// value → columns → blob rather than value → blob directly is deliberate —- /// `citations(from:)` is where Q39's normalisation lives, so a record whose- /// arms disagree lands in exactly the state a column writer would have- /// produced, and `refreshCitations` states the invariant once for every- /// writer there is.- static func applyCitations(_ citations: EntryCitations, on entry: Entry) {- entry.identityKeyVersion = citations.identity.keyVersion- entry.identityURLRuleID = citations.identity.urlRule?.id- entry.identityURLRuleVersion = citations.identity.urlRule?.version- entry.identityNameTitleRuleID = citations.identity.nameTitleRule?.id- entry.identityNameTitleRuleVersion = citations.identity.nameTitleRule?.version- entry.urlWorkRuleID = citations.urlWork?.id- entry.urlWorkRuleVersion = citations.urlWork?.version- entry.chapterSequenceRuleID = citations.chapterSequence?.id- entry.chapterSequenceRuleVersion = citations.chapterSequence?.version- entry.chapterTitleProvenanceRaw = citations.chapterTitle.kind.rawValue- entry.chapterPatternID = citations.chapterTitle.patternID- entry.chapterPatternVersion = citations.chapterTitle.patternVersion- entry.workAssignmentProvenanceRaw = citations.workAssignment.provenanceKind.rawValue- entry.workPatternID = citations.workAssignment.patternRule?.id- entry.workPatternVersion = citations.workAssignment.patternRule?.version- // The assignment's `.urlRule` arm and `workURL` are one column pair, so- // an arm carrying a citation the blob's own `workURL` does not is- // written from the arm rather than lost.- let workURL = citations.workAssignment.urlRuleRule ?? citations.workURL- entry.workURLRuleID = workURL?.id- entry.workURLRuleVersion = workURL?.version- entry.workURLAssignmentKindRaw = citations.workURLAssignmentKind?.rawValue- refreshCitations(on: entry)- }-- /// Re-encodes the citation blob from the columns, and reports whether the- /// bytes moved.- ///- /// **The invariant is "columns written ⇒ blob refreshed"**, and it is held at- /// the write site because it has to hold *inside* a commit (Decision 4).- /// Through phase 1 every citation writer in the repository writes the- /// columns; `Entry.citations` reads the blob wherever one exists and only- /// falls back to the columns while it is nil (Q36). A commit that wrote the- /// columns and left an existing blob alone would therefore hand its own- /// validation the *pre-write* citations against post-write values — which is- /// a re-teach refused on a library that had just migrated cleanly. The- /// population pass converges the two homes as well, but only between- /// commits, which is too late for the commit that caused the divergence.- ///- /// Value-guarded, like the pass: a writer that changed nothing dirties no- /// row. `V8PopulationPass.populateCitations` calls this, so the two homes are- /// reconciled by one piece of code rather than two spellings of it.- ///- /// The writer rewrite task 12 scheduled was not performed (Q68): readers- /// prefer the blob, the columns stay the write-side source of truth, and- /// V9 (T-2272) deletes this with the columns.- @discardableResult- static func refreshCitations(on entry: Entry) -> Bool {- let columns = citations(from: entry)- // Undecodable bytes compare unequal and are replaced, so a row whose- // blob will not read is repaired rather than left diagnosed for ever.- if entry.citationsData != nil, (try? entry.citations) == columns { return false }- do { try entry.setCitations(columns) } catch {- // Nothing honest to mirror. Dropping the blob is not data loss —- // the columns still hold every citation, and a nil blob is exactly- // the state Q36's fallback exists to read.- entry.citationsData = nil- }- return true- }-- /// Rewrites the cited *versions* in place, in the columns, and reports- /// whether anything moved.- ///- /// **Transitional, and deleted with the columns in V9.** In phase 1 the- /// columns are still the source of truth for citations (Decision 4): the- /// validator, the archive projection and the reference checks all resolve a- /// citation out of them, and the blob is a derived mirror. A rule-version- /// renumbering that wrote only the blob would therefore leave every one of- /// those readers citing a version no rule holds — which quarantines the- /// hostname and refuses the export. So the rewrite lands here- /// unconditionally, and the blob is kept in step beside it.- @discardableResult- static func rewriteVersions(_ rewrites: [UUID: Int], on entry: Entry) -> Bool {- var changed = false- func rewrite(_ id: UUID?, _ version: inout Int?) {- guard let id, let replacement = rewrites[id], version != replacement else { return }- version = replacement- changed = true- }- rewrite(entry.identityURLRuleID, &entry.identityURLRuleVersion)- rewrite(entry.identityNameTitleRuleID, &entry.identityNameTitleRuleVersion)- rewrite(entry.urlWorkRuleID, &entry.urlWorkRuleVersion)- rewrite(entry.chapterSequenceRuleID, &entry.chapterSequenceRuleVersion)- rewrite(entry.chapterPatternID, &entry.chapterPatternVersion)- rewrite(entry.workPatternID, &entry.workPatternVersion)- rewrite(entry.workURLRuleID, &entry.workURLRuleVersion)- return changed- }-- /// The ten definition columns as one value. Throws on a combination that is- /// not a legal arm, which is what the 55-line getter this replaces did —- /// substituting a rule the reader never taught did real damage once- /// (Decision 5 of `library-integrity-tolerance`).- static func definition(from pattern: TitlePattern) throws -> StoredPatternDefinition {- guard let form = PatternForm(rawValue: pattern.formRaw) else {- throw ModelInvariantError.invalidCombination(field: "title pattern form")- }- let arm: PatternDefinition- switch form {- case .segment:- guard let work = pattern.segmentWorkAnchor,- let ignored = pattern.segmentIgnoredAnchors,- pattern.phrasePrefix == nil,- pattern.phraseSeparator == nil,- pattern.phraseSuffix == nil,- pattern.fieldOrderRaw == nil else {- throw ModelInvariantError.invalidCombination(field: "segment pattern arm")- }- arm = pattern.chapterless- ? try PatternDefinition.chapterlessSegment(work: work, ignored: ignored).validated()- : try PatternDefinition.segment(work: work, ignored: ignored).validated()- case .phrase:- guard pattern.segmentWorkAnchor == nil,- pattern.segmentIgnoredAnchors == nil,- let prefix = pattern.phrasePrefix,- let separator = pattern.phraseSeparator,- let suffix = pattern.phraseSuffix,- let orderRaw = pattern.fieldOrderRaw,- let order = FieldOrder(rawValue: orderRaw) else {- throw ModelInvariantError.invalidCombination(field: "phrase pattern arm")- }- arm = pattern.chapterless- ? try PatternDefinition.chapterlessPhrase(- prefix: prefix, separator: separator, suffix: suffix, order: order).validated()- : try PatternDefinition.phrase(- prefix: prefix, separator: separator, suffix: suffix, order: order).validated()- case .wholeTitle:- guard pattern.segmentWorkAnchor == nil,- pattern.segmentIgnoredAnchors == nil,- pattern.phrasePrefix == nil,- pattern.phraseSeparator == nil,- pattern.phraseSuffix == nil,- pattern.fieldOrderRaw == nil else {- throw ModelInvariantError.invalidCombination(field: "whole-title pattern arm")- }- arm = .wholeTitle- }- return StoredPatternDefinition(- definition: arm, trimPrefix: pattern.trimPrefix, trimSuffix: pattern.trimSuffix)- }-- /// The ten definition columns as one canonically-encodable value — the- /// ordering key `GroupOrdering.canonicalDefinition` falls back to for a row- /// with **no blob** whose columns do not form a legal arm (Q42).- ///- /// It lives here rather than in `GroupOrdering` because this enum is the one- /// sanctioned reader of the retired columns (Q34, Q40) and the only file- /// `ModelContractTests` lets name them.- private struct DefinitionColumns: Encodable {- 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- }-- /// Canonically-encoded definition columns, or the empty string where even- /// that will not encode.- static func canonicalColumns(of pattern: TitlePattern) -> String {- let columns = DefinitionColumns(- 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)- let encoder = JSONEncoder()- encoder.outputFormatting = .canonical- guard let data = try? encoder.encode(columns),- let text = String(data: data, encoding: .utf8)- else { return "" }- return text- }-}- /// The one place a JSON blob column is turned into a value and back. /// /// Three model properties store a value as JSON bytes — `Entry.citationsData`,@@ -501,7 +92,7 @@ enum JSONBlob { } } -extension AsterismSchemaV8 {+extension AsterismSchemaV9 { @Model public final class Entry {@@ -518,41 +109,20 @@ public final class Entry { /// (Req 1.4). public var site: Site? public var entryIdentityKey: String = ""- internal var identityKeyVersion: Int = 1 /// M4 secondary lookup index: the conservative (v1) key for this Entry's raw /// URL, retained even after an S-rule rewrites `entryIdentityKey` to v3 so a /// title-less same-URL re-share still matches (Q21). Backfilled at migration. public var conservativeIdentityKey: String = "" public var identityBasisRaw: String = EntryIdentityBasis.conservative.rawValue- internal var identityURLRuleID: UUID?- internal var identityURLRuleVersion: Int?- /// M4 sequence+name (v3) basis: the active title rule that contributed the- /// Work name embedded in a v3 identity key (Req 4.2's dual-contributor- /// provenance). Nil for conservative and identity+sequence bases.- internal var identityNameTitleRuleID: UUID?- internal var identityNameTitleRuleVersion: Int? public var urlWorkIdentity: String?- internal var urlWorkRuleID: UUID?- internal var urlWorkRuleVersion: Int? public var chapterSequence: String?- internal var chapterSequenceRuleID: UUID?- internal var chapterSequenceRuleVersion: Int? public var chapterTitle: String?- internal var chapterTitleProvenanceRaw: String = FieldProvenanceKind.none.rawValue- internal var chapterPatternID: UUID?- internal 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?- internal var workAssignmentProvenanceRaw: String = FieldProvenanceKind.none.rawValue- internal var workPatternID: UUID?- internal var workPatternVersion: Int?- internal var workURLRuleID: UUID?- internal var workURLRuleVersion: Int?- internal var workURLAssignmentKindRaw: String? public var intentionallyUnattached: Bool = false /// V7: the fingerprint of the note text a character-extraction pass last /// covered (Q59). A **derived** field, excluded from authored content the@@ -561,13 +131,15 @@ public final class Entry { /// Nil means the note has never been covered. public var characterExtractionFingerprint: String? /// V8: the Entry's seven rule citations and its identity basis, as one- /// `EntryCitations` blob (Q25) — the column that supersedes- /// `identityKeyVersion` and the seventeen citation/provenance columns above.+ /// `EntryCitations` blob (Q25) — the column that superseded+ /// `identityKeyVersion` and the seventeen citation/provenance columns V9+ /// dropped, and now the **only** home any of them has. ///- /// Nil means the row has not been through `V8PopulationPass` yet: either a- /// V7 library mid-migration, or a row a lagging V7 device synced in- /// afterwards (Req 2.7). `citations` falls back to the legacy columns in- /// that state (Q36), so a nil blob is never read as "no citations".+ /// Every writer writes it and `init` seeds it, so nil means one thing: a row+ /// a V8 build wrote that this build has not rewritten. It reads as the+ /// default rather than throwing (Q6) and `entriesWithoutCitationBlobCount`+ /// counts it — a **report**, not a quarantine (Q25): the validator throws on+ /// bytes that will not decode and says nothing about bytes that are absent. public var citationsData: Data? public init(@@ -603,6 +175,16 @@ public final class Entry { lastSharedAt = capturedAt ?? timestamp modifiedAt = timestamp self.work = work+ // **Every code path that creates an Entry writes the blob at creation**+ // (V9 Req 4). Since the blob is the only home a citation has, a row born+ // without one would read as "cites nothing" for the rest of its life+ // rather than as "not converted yet" — so the default value is written+ // here, once, instead of at a dozen construction sites. A nil blob is+ // therefore only ever a row a V8 build wrote and this one has not+ // rewritten, which is exactly the state the validator reports — so the+ // default is encoded once into a constant rather than `try?`d here,+ // where a failed encode would spell itself the same way.+ citationsData = EntryCitations.defaultBlob } public var captureTitleSource: CaptureTitleSource {@@ -620,38 +202,88 @@ public final class Entry { set { identityBasisRaw = newValue.rawValue } } + /// The chapter title's provenance kind, **derived from the blob**. Read-only:+ /// the blob stores the whole citation, and a kind written without its cited+ /// rule is a state `EntryCitations` deliberately cannot spell (Q39). Writers+ /// go through `setCitations` or `editCitations`. public var chapterTitleProvenance: FieldProvenanceKind {- get { ToleratedEnum.read(chapterTitleProvenanceRaw, default: .none) }- set { chapterTitleProvenanceRaw = newValue.rawValue }+ citationValues.chapterTitle.kind } + /// How this Entry came to be on its Work, derived from the blob. Read-only,+ /// for the reason above. public var workAssignmentProvenance: FieldProvenanceKind {- get { ToleratedEnum.read(workAssignmentProvenanceRaw, default: .none) }- set { workAssignmentProvenanceRaw = newValue.rawValue }+ citationValues.workAssignment.provenanceKind } + /// Which half of a URL rule assigned the Work, derived from the blob. public var workURLAssignmentKind: URLWorkAssignmentKind? {- get { ToleratedEnum.read(workURLAssignmentKindRaw) }- set { workURLAssignmentKindRaw = newValue?.rawValue }+ citationValues.workURLAssignmentKind } /// Everything this Entry cites, as one value (Q25). ///- /// Nil bytes fall back to the legacy columns (Q36) rather than reading as- /// "cites nothing": a row a lagging V7 device syncs in after the migration- /// (Req 2.7) has its citations in the columns and nowhere else, and the next- /// reconcile converts it.+ /// Nil bytes read as the default — identity `.rawURL`, nothing cited (Q6).+ /// A nil blob means a row a V8 build wrote and V9 has not rewritten; it is+ /// reported by the validator and counted by the diagnosis, but an ordinary+ /// read of one must not throw or a single exposed row would fail a screen. /// /// Throws on bytes that will not decode, as `URLRulePattern.definition` /// does; the validator turns that into a reportable per-row state rather /// than a substituted value. public var citations: EntryCitations { get throws {- guard let citationsData else { return LegacyColumns.citations(from: self) }+ guard let citationsData else { return EntryCitations() } return try JSONBlob.decode(EntryCitations.self, from: citationsData) } } + /// Whether the row carries **no** citation bytes at all, as against bytes+ /// that decode to the default. The two read the same and are a different+ /// fact: the second is what a writer wrote, the first is a row the V8 → V9+ /// drop stage reached before anything converted it (Q6). The validator, the+ /// diagnosis and the counter all ask this question, so they ask it once.+ public var citationsAreAbsent: Bool { citationsData == nil }++ /// The citations as every ordinary reader wants them: bytes that will not+ /// decode read as the default rather than throwing, which is the same+ /// tolerance the enum columns had (Q2). The validator is the one caller that+ /// wants the throw, and it uses `citations`.+ public var citationValues: EntryCitations {+ (try? citations) ?? EntryCitations()+ }++ /// Reads the citations, applies `edit`, and writes them back — the door for+ /// a writer that owns some arms of the blob and must leave the rest alone.+ ///+ /// **Bytes that will not decode are repaired in place** (Q24): the edit is+ /// applied over the default value and written, which is what the retired+ /// column-derived refresh did — it replaced bytes that would not decode+ /// rather than leaving them diagnosed for ever. Skipping the write+ /// instead would land the caller's *surrounding* write (a manual+ /// assignment, a re-teach) with its provenance stuck at `.none`, which is a+ /// state the validator refuses on the next save. What the row loses is the+ /// citations nothing could read anyway.+ ///+ /// **A nil blob is not undecodable** (Q23). It reads as the default, so a+ /// real edit over one is written and a no-op edit over one leaves it nil —+ /// which is what keeps `entriesWithoutCitationBlobCount` meaning something+ /// after `SiteReconciler.rewriteCitations` has walked every Entry.+ ///+ /// Value-guarded, so a writer that changed nothing dirties no row.+ @discardableResult+ public func editCitations(_ edit: (inout EntryCitations) -> Void) -> Bool {+ let decoded = try? citations+ var edited = decoded ?? EntryCitations()+ edit(&edited)+ // Undecodable bytes: always write, even where the edit produced the+ // default — the point of the write is that the row stops being+ // unreadable.+ if let current = decoded, edited == current { return false }+ do { try setCitations(edited) } catch { return false }+ return true+ }+ /// Writes the citations, leaving the stored bytes untouched when encoding /// fails (Req 4.6) — the `URLRulePattern.setDefinition` guarantee. public func setCitations(_ citations: EntryCitations) throws {@@ -673,27 +305,7 @@ public final class Work { public var id: UUID = UUID() public var displayTitle: String = "" public var lastParsedTitle: String?- fileprivate 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`.- ///- /// **Retained legacy column** (Decision 3): superseded by- /// `WorkSiteMembership.site` and dropped in V9.- fileprivate var site: Site?- fileprivate var urlIdentity: String?- fileprivate var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue- fileprivate var urlIdentityRuleID: UUID?- internal var urlIdentityRuleVersion: Int?- fileprivate var workURLString: String? public var genericNotes: String = ""- /// The **compatibility** type column pre-feature builds read and write- /// (Decision 4). An updated build writes only `WorkType.other` here for a- /// configured assignment or an untype (Req 6.11), so any other value proves a- /// pre-feature build wrote it afterwards — which is what makes Req 6.10- /// detectable. Never read it directly to decide a work's type: derive the- /// assignment through `WorkTypeAssignment.assignment(of:)`.- internal var typeRaw: String = WorkType.other.rawValue /// V6: the `WorkTypeEntity` this Work is assigned, cited by UUID rather than /// modelled as a relationship (Decision 8 — an inverse would fault every /// Work assigned a type, the Q17 fan-out at larger scale). A dangling value@@ -735,10 +347,13 @@ public final class Work { /// mints the row and its first membership together. This initializer is the /// inside of that door, reachable from the package (and from `@testable` /// suites modelling a Req 2.7 membership-less arrival) and from nowhere else.- init(id: UUID = UUID(), displayTitle: String, siteHostname: String, timestamp: Date) {+ ///+ /// It takes no hostname since V9: `siteHostname` was the last column a+ /// Work's site presence had outside its `WorkSiteMembership`, and the row+ /// that carries it is minted by `createCarrying` in the same call.+ init(id: UUID = UUID(), displayTitle: String, timestamp: Date) { self.id = id self.displayTitle = displayTitle- self.siteHostname = siteHostname createdAt = timestamp modifiedAt = timestamp }@@ -748,15 +363,6 @@ public final class Work { set { titleProvenanceRaw = newValue.rawValue } } - /// **`fileprivate` with its column** (Q59): a live accessor on a superseded- /// column is a reader of that column, and Decision 3 leaves none. The V8- /// value is `WorkSiteMembership.urlIdentityState`; what the column still- /// holds reads through `LegacyColumns.membership(from:)`.- fileprivate var urlIdentityState: WorkURLIdentityState {- get { ToleratedEnum.read(urlIdentityStateRaw, default: .none) }- set { urlIdentityStateRaw = newValue.rawValue }- }- public var entryValues: [Entry] { entries ?? [] } public var characterValues: [Character] { characters ?? [] } public var characterSuppressionValues: [CharacterSuppression] { characterSuppressions ?? [] }@@ -768,15 +374,31 @@ public final class Work { Self.membershipOrder(siteMemberships ?? []) } + /// The **primary** membership: the first row in `membershipValues`, found+ /// without sorting the rest.+ ///+ /// `GroupOrdering` reads this once per Work on a path every write takes+ /// (Q18), and `membershipValues.first` charged that read a whole sort to+ /// answer a question `min` answers in one pass.+ public var primaryMembership: WorkSiteMembership? {+ (siteMemberships ?? []).min(by: Self.membershipPrecedes)+ }+ /// Req 1.2's order over an explicit set of rows, for the callers that hold /// one — a commit that has deleted a membership and not yet saved cannot /// trust the inverse array to have dropped it. static func membershipOrder(_ rows: [WorkSiteMembership]) -> [WorkSiteMembership] {- rows.sorted {- $0.createdAt == $1.createdAt- ? $0.id.uuidString < $1.id.uuidString- : $0.createdAt < $1.createdAt- }+ rows.sorted(by: membershipPrecedes)+ }++ /// Req 1.2's comparison itself, so the order and the `min` that picks its+ /// first element cannot disagree about which membership is primary.+ private static func membershipPrecedes(+ _ lhs: WorkSiteMembership, _ rhs: WorkSiteMembership+ ) -> Bool {+ lhs.createdAt == rhs.createdAt+ ? lhs.id.uuidString < rhs.id.uuidString+ : lhs.createdAt < rhs.createdAt } /// The membership for `hostname`, or nil where the Work holds none.@@ -826,22 +448,12 @@ public final class Site { /// `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]? /// V8: inverse of `WorkSiteMembership.site`, present because CloudKit /// requires every relationship to have one. Internal for the same reason /// `works` is (Q17): traversing it faults every membership for a hostname, /// and the repository fetches memberships by predicate instead. @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.site) var workMemberships: [WorkSiteMembership]?- /// A format-2 era column that is now permanently nil: `LibraryValidator`- /// requires it absent in every live store, and the legacy mapping that was- /// once meant to migrate it into historical `URLRulePattern` records went- /// with the retired import paths. It stays because dropping a column is a- /// schema version, not an edit.- internal var urlIdentityRule: URLIdentityRule? public var junkSuffixRule: JunkSuffixRule? public init(hostname: String, displayName: String? = nil) {@@ -873,28 +485,15 @@ public final class TitlePattern { public var version: Int = 1 public var isActive: Bool = false public var createdAt: Date = Date(timeIntervalSince1970: 0)- internal var formRaw: String = PatternForm.segment.rawValue- internal var segmentWorkAnchor: SegmentRangeSpec?- internal var segmentIgnoredAnchors: [SegmentPositionSpec]?- internal var phrasePrefix: String?- internal var phraseSeparator: String?- internal var phraseSuffix: String?- internal var fieldOrderRaw: String?- /// M4: exact leading affix trimmed from the capture title before parsing or- /// whole-title naming (Req 3.1); nil = no leading trim. Applied fail-open.- internal var trimPrefix: String?- /// M4: exact trailing affix trimmed from the capture title before parsing or- /// whole-title naming (Req 3.1); nil = no trailing trim. Applied fail-open.- internal var trimSuffix: String?- /// M4: discriminates the chapter-less segment/phrase arms from their- /// ordinary counterparts, which share the same `formRaw` and columns.- internal var chapterless: Bool = false /// V8: the pattern's whole definition surface — the arm *and* both trims —- /// as one `StoredPatternDefinition` blob (Q25), the column that supersedes- /// the ten decomposed columns above.+ /// as one `StoredPatternDefinition` blob (Q25), and since V9 the only home+ /// it has: the ten decomposed columns it superseded are gone. ///- /// Nil means the row has not been through `V8PopulationPass` yet;- /// `storedDefinition` falls back to the legacy columns in that state (Q36).+ /// Nil means a row a V8 build wrote that nothing has rewritten since; no+ /// pass converts one, because the ten columns it would be derived from are+ /// gone. `storedDefinition` throws for one, the validator reports it as+ /// `unreadableTitlePattern`, and `GroupOrdering` gives it its own constant+ /// ordering key (Q11). public var definitionData: Data? public var site: Site? @@ -953,16 +552,21 @@ public final class TitlePattern { ) } - /// The pattern's whole definition surface, read from the blob (Q25) or —- /// for a row `V8PopulationPass` has not reached — from the ten legacy- /// columns (Q36).+ /// The pattern's whole definition surface, read from the blob (Q25) — the+ /// only home it has since V9. /// /// Throws rather than substituting a rule the reader never taught (Req 4.3), /// exactly as `URLRulePattern.definition` does and for the same reason: a- /// fabricated definition validates cleanly and blames the captures.+ /// fabricated definition validates cleanly and blames the captures. **A nil+ /// blob throws for that reason too**: it used to fall back to the ten legacy+ /// columns, and with those unwritten there is nothing behind it. The+ /// validator reports such a row as `unreadableTitlePattern` and+ /// `GroupOrdering` gives it its own constant ordering key (Q11). public var storedDefinition: StoredPatternDefinition { get throws {- guard let definitionData else { return try LegacyColumns.definition(from: self) }+ guard let definitionData else {+ throw ModelInvariantError.invalidCombination(field: "title pattern definition")+ } return try JSONBlob.decode(StoredPatternDefinition.self, from: definitionData) } }@@ -1435,7 +1039,7 @@ public final class WorkDistinctPair { } } -} // extension AsterismSchemaV8+} // extension AsterismSchemaV9 /// A rule-derived URL identity a creation site already holds, passed through /// `Work.create` so the minted membership is born carrying it (Req 1.3, 3.1).@@ -1499,19 +1103,13 @@ extension Work { identity: WorkURLIdentityMint?, timestamp: Date ) -> Work {- let work = Work(id: id, displayTitle: title, siteHostname: hostname, timestamp: timestamp)+ // **The membership, and nothing else.** A Work's site presence, URL+ // identity and confirmed URL live on its `WorkSiteMembership` — which is+ // what Req 3.1 matches on and what every reader reads — and since V9+ // there is nowhere else for them to live: the six columns that mirrored+ // them, `siteHostname` included, left the stored shape.+ let work = Work(id: id, displayTitle: title, timestamp: timestamp) context.insert(work)- // The retained `Work.site` column is still written here so a V8 library- // and a V7-migrated one hold the same columns; V9 drops both with the- // rest of the superseded set (Decision 3).- work.site = site- // The identity goes into **both** homes here, for the same reason: a- // creation site that set the columns afterwards left the membership in- // state `none`, and the membership is what Req 3.1 matches on.- work.urlIdentity = identity?.value- work.urlIdentityState = identity == nil ? .none : .rule- work.urlIdentityRuleID = identity?.ruleID- work.urlIdentityRuleVersion = identity?.ruleVersion let membership = WorkSiteMembership( id: membershipID, hostname: hostname, createdAt: timestamp, urlIdentity: identity?.value,@@ -1530,25 +1128,18 @@ extension Work { /// the whole of Req 3.5 — a two-site Work keeps the other site's identity /// exactly as it was. ///- /// The superseded columns are refreshed from the primary membership beside- /// it (Decision 4), `urlIdentityRuleVersion` included — `refreshMembership`- /// resolves that one from the membership's own Site rules (Q60). The write- /// below is the backstop for the case it cannot resolve: a membership with- /// no `site` row yet (Req 1.5) has no rules to look the version up in, and- /// the mint being applied here carries it directly.- /// /// A Work holding no membership for the hostname is left alone: the write had /// no site to land on, and reconciliation heals the membership from the /// Entries (Req 8.1) before the next pass re-derives it.+ ///+ /// The membership is the only home the identity has since V9; the mirror+ /// into the six superseded columns, and the `urlIdentityRuleVersion`+ /// backstop beside it, went with the mirror. func applyURLIdentity(_ mint: WorkURLIdentityMint?, on hostname: String) { guard let membership = membership(for: hostname) else { return } membership.urlIdentity = mint?.value membership.urlIdentityState = mint == nil ? .none : .rule membership.urlIdentityRuleID = mint?.ruleID- LegacyColumns.refreshMembership(on: self)- if membershipValues.first?.hostname == hostname {- urlIdentityRuleVersion = mint?.ruleVersion- } } /// Req 3.21's one rule, in one place: a reuse or claim refreshes the Work's
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swiftindex 930a25c..aee4ca3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift@@ -746,24 +746,6 @@ public struct WorkMergeOutcome: Equatable, Sendable { public let targetIdentityEvidence: WorkIdentityEvidence public let issues: [WorkMergeIssue] - /// The primary site's answers, for the surfaces that speak of "the" merged- /// Work's URL and identity. A same-site merge has exactly one site, so these- /// are that site's.- ///- /// **The V7-shaped compatibility facade, and it is test-only from task 19.**- /// Every production surface reads `sites` — the merge preview renders a row- /// per site (Q74) and the commit applies a disposition per site — so on a- /// cross-site merge these three answer about whichever site sorted first,- /// which is a question no screen asks. They survive because the suites still- /// read them as the single-site answer, and they are deleted with the rest- /// of the single-site surface at V9 (T-2272).- public var workURL: String? { sites.first?.workURL }- public var identityEvidence: WorkIdentityEvidence {- sites.first?.identityEvidence ?? .noEntries(previousIdentity: .none)- }- public var identityDisposition: WorkIdentityDisposition {- sites.first?.identityDisposition ?? .retain(.none)- } public let retainedFields: [WorkMergeField] public let discardedFields: [WorkMergeField] public let sourceDeleted: Bool
diff --git a/Packages/AsterismCore/Sources/AsterismCore/SiteReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/SiteReconciler.swiftindex 35b8045..ae4886f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/SiteReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/SiteReconciler.swift@@ -321,16 +321,9 @@ enum SiteReconciler { /// union's map. Rule UUIDs are unique, so the new version is a lookup and /// provenance replay keeps resolving across the merge (Req 1.4). ///- /// **Both places, every time** (Decision 4). Through phase 1 the legacy- /// columns are the source of truth — `LibraryValidator`,- /// `BackupArchiveProjection` and the archive reference checks all resolve a- /// citation out of them — and the blob is a derived mirror. A rewrite that- /// wrote only the blob would leave every one of those readers citing a- /// version no rule holds, which quarantines the hostname and refuses the- /// export; a rewrite that wrote only the columns would leave the blob stale- /// for the readers that have moved. So the columns are rewritten- /// unconditionally and the blob is re-encoded beside them. Task 12 inverts- /// it and V9 deletes the column half.+ /// **One place** since V9: the blob is where a citation lives, so+ /// `EntryCitations.rewritingVersions` is the whole rewrite and the column+ /// half is gone. /// /// Internal rather than private because `DuplicateReconciler` rewrites the /// same citations after a rule identity group converges on one version@@ -340,20 +333,26 @@ enum SiteReconciler { /// There is no Work counterpart any more: a `WorkSiteMembership` cites by /// UUID and carries no version (Req 10.4, Q28), and since task 21 neither /// the validator nor the archive reads `Work.urlIdentityRuleVersion`.- /// Nothing here reads the blob, and that is the point: this used to decode- /// `entry.citations` first, so **one** Entry whose bytes will not decode- /// threw out of the enumeration and abandoned the consolidation — every- /// later Entry left citing a version its rule no longer holds, on a pass- /// whose whole job is to stop exactly that. The columns are rewritten, and- /// `refreshCitations` re-derives the blob from them; its value guard skips a- /// row already in step and its `catch` replaces bytes that will not decode,- /// so a corrupt row is repaired by the pass rather than aborting it.+ ///+ /// **One unreadable row must not abandon the consolidation.** An earlier+ /// spelling decoded `entry.citations` and let the throw escape, so a single+ /// Entry whose bytes will not decode left every later Entry citing a version+ /// its rule no longer holds — on the pass whose whole job is to stop exactly+ /// that. `editCitations` repairs such a row from the default instead (Q24).+ ///+ /// A **nil**-blob Entry is left nil (Q23): `rewritingVersions` has nothing+ /// to rewrite in the default value, so the edit is a no-op and the value+ /// guard writes nothing. This pass walks every Entry on a consolidation, so+ /// a version of it that converted nil blobs would zero+ /// `entriesWithoutCitationBlobCount` on the first reconcile after the+ /// upgrade — the one counter that tells the reader which rows lost their+ /// provenance. @discardableResult static func rewriteCitations(of entry: Entry, _ rewrites: [UUID: Int]) -> Bool { guard !rewrites.isEmpty else { return false }- let columnsChanged = LegacyColumns.rewriteVersions(rewrites, on: entry)- let blobRefreshed = LegacyColumns.refreshCitations(on: entry)- return columnsChanged || blobRefreshed+ return entry.editCitations { citations in+ if let rewritten = citations.rewritingVersions(rewrites) { citations = rewritten }+ } } private static func chunks<Element>(of values: [Element], size: Int) -> [ArraySlice<Element>] {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/SiteRelationshipPopulationPass.swift b/Packages/AsterismCore/Sources/AsterismCore/SiteRelationshipPopulationPass.swiftindex 6cfd328..f9e2409 100644--- a/Packages/AsterismCore/Sources/AsterismCore/SiteRelationshipPopulationPass.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/SiteRelationshipPopulationPass.swift@@ -6,8 +6,14 @@ import SwiftData // the `"4"` marker generation, this pass has no production caller and must not // be linked into a shipping build. #if DEBUG || ASTERISM_PERFORMANCE_TESTING-/// Populates `Entry.site` and `Work.site` from their hostname strings, for a-/// library whose relationships are unset.+/// Populates `Entry.site` and `WorkSiteMembership.site` from their hostname+/// strings, for a library whose relationships are unset.+///+/// It populated `Work.site` too until V9 dropped that column+/// (`drop-superseded-columns` Q27, which is also why this survived the spec's+/// deletion list: two dozen seeders still need the two relationships that are+/// left, and inlining the row selection at each of them is the duplication the+/// paragraph below argues against). /// /// **Fixture and test support, and nothing else** — hence the guard above, /// which is the same one the fixtures that call it carry. It arrived as the@@ -58,15 +64,11 @@ enum SiteRelationshipPopulationPass { let winner = winners[entry.hostname] if entry.site !== winner { entry.site = winner } }- for work in try context.fetch(FetchDescriptor<Work>()) {- let winner = winners[LegacyColumns.membership(from: work).hostname]- if LegacyColumns.site(from: work) !== winner {- LegacyColumns.setSite(winner, on: work)- }- }- // V8: the membership carries its own Site reference, and it is the one- // the live code reads — the Work column above is the superseded mirror.- // A fixture that pinned only the mirror left every membership on+ // A Work's site presence is its memberships, and the membership carries+ // its own Site reference. The `Work.site` walk that used to stand here+ // went with the column at V9; nothing had read it since V8.+ //+ // A fixture that pinned only the Work mirror left every membership on // whichever row it was minted against, which is precisely the state // `SiteReconciler.repin` and `MembershipReconciler` exist to converge // (Q57), so a suite built on this pass could not tell a converged graph
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ToleratedStateFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/ToleratedStateFixture.swiftindex 003c8c6..f9288fd 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ToleratedStateFixture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ToleratedStateFixture.swift@@ -150,20 +150,17 @@ extension LibraryRepository { // cheapest legal way to give both rows one. // // The **membership** is where the identity is authored. The- // retained columns were written here too, by hand, on the- // grounds that the duplicate scan and the validator still- // read them — neither does: the scan buckets by membership- // (Req 5.1) and the validator's identity arm is the- // membership arm (Q54). They are a mirror of the primary- // membership until V9 (T-2272), so the fixture keeps them in- // step the way every production write site does, through- // `refreshMembership`, rather than seeding a second- // authored copy that could disagree with the first.+ // Work's own identity columns were written here too, by+ // hand, on the grounds that the duplicate scan and the+ // validator still read them — neither did: the scan buckets+ // by membership (Req 5.1) and the validator's identity arm+ // is the membership arm (Q54). V9 dropped the columns+ // outright. The identity this shape needs is the+ // membership's. if let membership = work.membership(for: "mergeable.test") { membership.urlIdentity = "twinned-serial" membership.urlIdentityState = .legacyUnverified }- LegacyColumns.refreshMembership(on: work) work.lastParsedTitle = "Twinned Serial" work.titleProvenance = .parsed work.genericNotes = notes@@ -172,7 +169,7 @@ extension LibraryRepository { path: "serial-\(index + 1)", index: 60 + index) context.insert(entry) entry.work = work- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .manual } } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V8PopulationPass.swift b/Packages/AsterismCore/Sources/AsterismCore/V8PopulationPass.swiftdeleted file mode 100644index 8b75fd3..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/V8PopulationPass.swift+++ /dev/null@@ -1,262 +0,0 @@-import Foundation-import SwiftData--/// Fills the rows and blobs the V7 → V8 stage could only *add*, from the columns-/// it left in place (Decision 3).-///-/// **Production code, not fixture support.** The V7 → V8 stage is lightweight-/// and additive — two tables and two columns — because SwiftData runs it inside-/// `ModelContainer.init`, where a stage that dropped the source columns would-/// destroy them before anything could read them. This pass is the other half of-/// that bargain: it runs *after* the open, under the bootstrap's exclusive lease-/// and before the library is presented, and it is what makes the `"7"` marker-/// generation an upgrade rather than a refusal (Req 2.2).-///-/// Three phases in a fixed order — Works, Entries, TitlePatterns. The order buys-/// nothing at runtime; it is fixed so a recorded-store test can assert what a-/// partial run left behind (Req 2.4).-///-/// **Every phase is idempotent by an existence guard**, so a run interrupted by-/// a failing save is completed rather than doubled by the next one: a Work that-/// already holds a membership on its hostname is skipped, and an Entry or-/// pattern that already holds its blob is skipped. That is what lets the-/// marker-lagging arm re-enter on the next open with the same result as a first-/// run (Req 2.4).-///-/// `reconcileAfterSync` runs it too, with the same guards: rows a lagging V7-/// device syncs in after the migration (Req 2.7) get their memberships and blobs-/// on the next reconcile, and until then the nil-blob fallbacks keep them-/// readable (Q36).-enum V8PopulationPass {-- /// Which of the three phases a run performs.- ///- /// The bootstrap arm and every full-tier reconcile run `.all`; an arrival- /// runs only the phases the last tolerance scan saw unconverted rows for- /// (Decision 5). A phase that does not run is a phase whose whole-table- /// fetch is not paid for, which is the point.- struct Phases: OptionSet {- let rawValue: Int- static let memberships = Phases(rawValue: 1 << 0)- static let citations = Phases(rawValue: 1 << 1)- static let definitions = Phases(rawValue: 1 << 2)- static let all: Phases = [.memberships, .citations, .definitions]- }-- /// Runs the selected phases, chunked, with one save per dirty chunk.- ///- /// Chunking is not a nicety: the whole library is rewritten here, and a- /// single save over a 5,000-Entry store is the shape `SiteReconciler`'s Q27- /// measured at seventeen seconds. Every chunk boundary is a legal library.- static func run(- context: ModelContext,- batchSize: Int = LibraryRepository.bulkOperationBatchSize,- saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy(),- phases: Phases = .all- ) throws {- if phases.contains(.memberships) {- try populateMemberships(- context: context, batchSize: batchSize, saveStrategy: saveStrategy)- }- if phases.contains(.citations) {- try populateCitations(- context: context, batchSize: batchSize, saveStrategy: saveStrategy)- }- if phases.contains(.definitions) {- try populateDefinitions(- context: context, batchSize: batchSize, saveStrategy: saveStrategy)- }- }-- // MARK: - Phase 1: Works → memberships-- /// One `WorkSiteMembership` per Work, carrying the six site/identity/URL- /// columns (Req 2.1).- ///- /// The membership's `createdAt` is the Work's, so a migrated single-site- /// Work orders exactly as it did before (Req 1.2). Its `site` is the row the- /// Work was pinned to, and where the Work was never pinned — a tolerated- /// state since M4a — the `SiteResolutionOrder` winner for the hostname, and- /// nil where no Site row has arrived (Req 1.5).- ///- /// A Work with a **blank** hostname gets no membership. That is the Q30- /// tolerated state, not a failure: it arrives from a lagging writer or a- /// partial sync, the validator reports it, and inventing a membership on ""- /// would put a row on a hostname that does not exist.- private static func populateMemberships(- context: ModelContext,- batchSize: Int,- saveStrategy: any RepositorySaveStrategy- ) throws {- let works = try context.fetch(FetchDescriptor<Work>())- guard !works.isEmpty else { return }- let winners = SiteResolutionOrder.winnersByHostname(- try context.fetch(FetchDescriptor<Site>()))- // One fetch rather than a relationship traversal per Work: reading- // `work.siteMemberships` faults the inverse array a row at a time, which- // is the fan-out cost Q17 keeps `Site.works` internal to avoid.- var held = Set(- try context.fetch(FetchDescriptor<WorkSiteMembership>())- .compactMap { membership -> MembershipReconciler.Key? in- guard let workID = membership.resolvedWorkID else { return nil }- return MembershipReconciler.Key(workID: workID, hostname: membership.hostname)- })-- for chunk in LibraryRepository.chunks(of: works, size: batchSize) {- var dirty = false- for work in chunk {- let hostname = LegacyColumns.membership(from: work).hostname- guard !M2Unicode.isBlank(hostname) else { continue }- guard held.insert(- MembershipReconciler.Key(workID: work.id, hostname: hostname)).inserted- else { continue }- guard let membership = mint(for: work, fallbackSite: winners[hostname])- else { continue }- context.insert(membership)- dirty = true- }- if dirty { try saveStrategy.save(context) }- }- }-- /// The single membership a Work's retained columns describe, or nil where the- /// hostname is blank (the Q30 tolerated state).- ///- /// The migration's own, and nothing else's since task 21: the 7/8 archive- /// carries membership records, so the import builds rows from what the file- /// says rather than from a Work's retained columns.- static func mint(for work: Work, fallbackSite: Site?) -> WorkSiteMembership? {- let legacy = LegacyColumns.membership(from: work)- guard !M2Unicode.isBlank(legacy.hostname) else { return nil }- return WorkSiteMembership(- hostname: legacy.hostname,- createdAt: legacy.createdAt,- urlIdentity: legacy.urlIdentity,- urlIdentityState: legacy.urlIdentityState,- urlIdentityRuleID: legacy.urlIdentityRuleID,- workURLString: legacy.workURLString,- workID: work.id,- work: work,- site: LegacyColumns.site(from: work) ?? fallbackSite)- }-- // `refreshMembership` stood here: the 6/7 import's update branch, which- // derived a Work's membership from the columns an archive record had just- // overwritten. Task 21 deleted it with the format — a 7/8 archive carries- // membership records, so the import applies what the file says and mirrors- // the columns from the membership rather than the other way round.--- // MARK: - Phase 2: Entries → the citation blob-- /// **A value guard, not an existence guard** (Decision 4). Through phase 1- /// the legacy columns are still the source of truth for citations: every- /// writer in the repository — the composed teaching commit, the re-parse- /// commit, the articles sweep, the import — writes the columns and never- /// calls `setCitations`. An existence guard would therefore populate a row- /// once and then let its blob drift for ever, silently, because a nil blob- /// falls back to the columns while a *stale* one does not.- ///- /// So the blob is written whenever it differs from what the columns say —- /// which still costs nothing on a converged library, because the second run- /// finds every blob already equal (the idempotence Req 2.4 asks for). Task- /// 12 inverts this: the writers move to `setCitations`, the blob becomes the- /// source of truth, and V9 deletes the columns and this phase with them.- ///- /// This phase converges the two homes **between** commits. It is not the- /// whole of Decision 4: a commit whose own validation reads the blob needs- /// the refresh to have happened *inside* it, which is why every column- /// writer calls `LegacyColumns.refreshCitations` — the same code, at the- /// write site.- private static func populateCitations(- context: ModelContext,- batchSize: Int,- saveStrategy: any RepositorySaveStrategy- ) throws {- let entries = try context.fetch(FetchDescriptor<Entry>())- for chunk in LibraryRepository.chunks(of: entries, size: batchSize) {- var dirty = false- for entry in chunk where LegacyColumns.refreshCitations(on: entry) {- dirty = true- }- if dirty { try saveStrategy.save(context) }- }- }-- // MARK: - Phase 3: TitlePatterns → the definition blob-- /// A row whose ten columns do not form a legal arm is **left alone**.- ///- /// `LegacyColumns.definition` throws on such a row, and there is nothing- /// honest to write for it: the arm the reader taught is not recoverable, and- /// substituting one is the mistake Decision 5 of- /// `library-integrity-tolerance` was written about. The validator already- /// reports it and quarantines its hostname; failing the whole pass over it- /// would make the library unopenable on V8 for a state it opens with today.- /// `assertComplete` therefore does not demand a blob for it either.- ///- /// **An existence guard is right here, unlike phase 2's.** `TitlePattern`'s- /// ten definition columns have no writer left: `TitlePattern.init` writes- /// only `definitionData`, `applyStoredDefinition` goes through- /// `setDefinition`, and no production path assigns a column (the pin in- /// `ModelContractTests` is what keeps that true). A populated pattern blob- /// therefore cannot go stale, so a converted row is skipped outright.- private static func populateDefinitions(- context: ModelContext,- batchSize: Int,- saveStrategy: any RepositorySaveStrategy- ) throws {- let patterns = try context.fetch(FetchDescriptor<TitlePattern>())- for chunk in LibraryRepository.chunks(of: patterns, size: batchSize) {- var dirty = false- for pattern in chunk where pattern.definitionData == nil {- guard let stored = try? LegacyColumns.definition(from: pattern) else { continue }- try pattern.setDefinition(stored)- dirty = true- }- if dirty { try saveStrategy.save(context) }- }- }-- // MARK: - Certification-- /// Throws unless the pass has nothing left to do (Q35).- ///- /// **The pass certifies itself, because the validator cannot.** Every state- /// this pass repairs is one the validator *tolerates* — a Work with no- /// membership, an Entry reading its citations through the columns — so a- /// validator run over an unconverted library comes back clean. The marker is- /// published on this answer, and only on this answer.- static func assertComplete(context: ModelContext) throws {- var membershipHostnames: [UUID: Set<String>] = [:]- for membership in try context.fetch(FetchDescriptor<WorkSiteMembership>()) {- guard let workID = membership.resolvedWorkID else { continue }- membershipHostnames[workID, default: []].insert(membership.hostname)- }- for work in try context.fetch(FetchDescriptor<Work>()) {- let hostname = LegacyColumns.membership(from: work).hostname- guard !M2Unicode.isBlank(hostname) else { continue }- guard membershipHostnames[work.id]?.contains(hostname) == true else {- throw incomplete("Work", work.id, "holds no membership for \(hostname)")- }- }- for entry in try context.fetch(FetchDescriptor<Entry>()) where entry.citationsData == nil {- throw incomplete("Entry", entry.id, "carries no citation blob")- }- for pattern in try context.fetch(FetchDescriptor<TitlePattern>())- where pattern.definitionData == nil {- // A row whose columns will not decode is not the pass's failure —- // see `populateDefinitions`.- guard (try? LegacyColumns.definition(from: pattern)) != nil else { continue }- throw incomplete("TitlePattern", pattern.id, "carries no definition blob")- }- }-- private static func incomplete(- _ type: String, _ id: UUID, _ reason: String- ) -> LibraryRepositoryError {- .libraryUnavailable(- operation: "completing the V8 population pass",- reason: "\(type) \(id.uuidString) \(reason)")- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swiftindex 24c1e7a..465f79c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swift@@ -106,23 +106,6 @@ public struct WorkVariantUnionOutcome: Sendable, Equatable { /// Merge shows one block, so it takes the first (and only) one. public var auditBlock: String? { auditBlocks.first }-- /// The one site's URL, for a fold over sides that are on one site — every- /// duplicate-set fold, by Req 5.3.- ///- /// **Test-only, and scheduled for T-2272.** No production caller is left:- /// `WorkMergeSiteOutcome` carries the per-hostname value (Q74, Q78) and the- /// resolution sheet folds per hostname (Q65). It survives because the- /// suites read it as the V7-shaped answer, and it is deleted with the rest- /// of the single-site compatibility surface at V9.- ///- /// Keyed rather than `values.first`: a `Dictionary`'s iteration order is- /// per-process seeded, so on the day this is handed a two-site fold it would- /// return a different site's address on each run rather than the same wrong- /// one — which is the difference between a bug and an unreproducible bug.- public var workURL: String? {- workURLsByHostname.keys.sorted().first.flatMap { workURLsByHostname[$0] }- } } public enum WorkVariantUnion {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesReteachFlagTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesReteachFlagTests.swiftindex 14e07d4..a938ba6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesReteachFlagTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesReteachFlagTests.swift@@ -127,6 +127,6 @@ struct ArticlesReteachFlagTests { try await fixture.repository.m5UnattachmentFields(of: entryID) == M5UnattachmentFields( hasWork: false, intentionallyUnattached: true, assignmentProvenance: .manual,- hasWorkPatternID: false, hasWorkPatternVersion: false))+ citesWorkPattern: false)) } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swiftindex e5010f9..51ed69d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift@@ -132,9 +132,10 @@ struct BackupExportDegradedRefusalTests { let entry = store.insertEntry( hostname: "present.example", title: "Chapter 1 - Cited", offset: 0) entry.chapterTitle = "Chapter 1"- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = patternID- entry.chapterPatternVersion = 1+ entry.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: patternID, patternVersion: 1)+ } } let repository = try fixture.diagnosedRepository() @@ -237,27 +238,26 @@ struct BackupExportDegradedRefusalTests { /// /// A work's stored type is *not* checked for representability, which is what /// makes `configurable-work-types` Req 7.1 — "export succeeds for works of- /// any type" — true. What changed at V8 is where the type comes from: the- /// derivation reads the work-type identity alone (Req 10.3), so a work- /// carrying only a raw value exports **untyped** rather than carrying the- /// value verbatim. The export still succeeds, which is the requirement.- @Test("A work typed only by a raw value exports untyped rather than refusing")- func unrecognisedWorkTypeExportsVerbatim() async throws {+ /// any type" — true. V8 moved the derivation onto the work-type identity+ /// alone (Req 10.3) and V9 dropped the raw column behind it, so a work with+ /// no identity exports untyped and the export succeeds, which is the+ /// requirement. It was seeded through `typeRaw = "graphicNovel"` until the+ /// column went; that work derived to exactly this.+ @Test("A work with no work-type identity exports untyped rather than refusing")+ func untypedWorkExportsWithoutRefusing() async throws { let fixture = try DegradedExportFixture() let workID = UUID() try fixture.seed { store in store.insertSite(hostname: "present.example")- let work = store.insertWork(+ store.insertWork( id: workID, hostname: "present.example", title: "A Work", offset: 0)- work.typeRaw = "graphicNovel" } let repository = try fixture.diagnosedRepository() let payload = try await repository.backupV7Snapshot() let record = try #require(payload.works.first { $0.id == workID })- // The 7/8 record has nowhere to put a legacy type at all (Req 10.3, Q16),- // and nothing derives one from the retained column.+ // The 7/8 record has nowhere to put a legacy type at all (Req 10.3, Q16). #expect(record.workTypeID == nil) #expect(record.typeName == nil) }@@ -343,8 +343,7 @@ struct BackupExportDegradedRefusalTests { rule.definitionData = Data("{}".utf8) let entry = store.insertEntry(hostname: "tth.example", title: "Chapter 1", offset: 0) entry.urlWorkIdentity = "a-story"- entry.urlWorkRuleID = ruleID- entry.urlWorkRuleVersion = 1+ entry.editCitations { $0.urlWork = CitedRule(id: ruleID, version: 1) } } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")@@ -377,9 +376,10 @@ struct BackupExportDegradedRefusalTests { let entry = store.insertEntry( hostname: "present.example", title: "Chapter 1 - Present", offset: 0) entry.chapterTitle = "Chapter 1"- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = absent- entry.chapterPatternVersion = 3+ entry.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: absent, patternVersion: 3)+ } } let repository = try fixture.diagnosedRepository() @@ -643,7 +643,7 @@ struct BackupExportDegradedRefusalTests { metadata: BackupV7Metadata(appBuild: "1", exportedAt: DegradedExportFixture.epoch)) let decoded = try BackupV7Codec.decode(encoded) - let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) let container = try ModelContainer(for: schema, configurations: [configuration])
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swiftindex 2c56b93..9ee158a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift@@ -118,6 +118,32 @@ struct BackupGoldenExportTests { #expect(plan.counts.works == plan.metadata.workCount) } + /// `drop-superseded-columns`' archive requirement: a 7/8 archive **exported+ /// by a V8 build** restores into a V9 library. The bytes on disk are that+ /// archive — `multi-site-works` recorded them and V9 did not re-record+ /// them, because V9 changed where the citations are read from and not what+ /// the wire says.+ ///+ /// Distinct from `recordedArchiveDecodesAndPlans` above, which stops at the+ /// import gate's in-memory materialization: this one commits to a real+ /// store and validates what is left there, which is what the reader does on+ /// the day the archive is the rollback.+ @Test("A 7/8 archive recorded before V9 restores into a V9 library and validates clean")+ func recordedArchiveRestoresIntoAV9Library() async throws {+ let golden = try Data(contentsOf: Self.goldenURL)++ let target = try await M5Fixture()+ try await target.repository.confirmImport(plan: try BackupImporter.plan(from: golden))++ #expect(try await target.repository.strictDiagnoses().isEmpty)+ // Against an empty library that would validate trivially: what is in the+ // store re-exports as the same archive, so every record arrived.+ let reExported = try BackupV7Codec.encode(+ payload: try await target.repository.backupV7Snapshot(),+ metadata: BackupGoldenLibrary.metadata)+ #expect(reExported == golden)+ }+ /// Req 9.2, end to end: export, restore into an empty library, export again. /// Byte-identical is the strongest form of "reproduces every record" there /// is — a field the import dropped, re-derived or reordered shows up as a@@ -191,6 +217,16 @@ extension LibraryRepository { return (orphans, pairs) } }++ /// The strict validator over the live store, which is the bar an imported+ /// library has to clear (`+BackupImportGates`, Decision 3 of+ /// `library-integrity-tolerance`).+ fileprivate func strictDiagnoses() async throws -> [String: LibraryValidationError] {+ try await withLockedContext(mode: .shared, operation: "validating the restored library") {+ context in+ try LibraryValidator.validateStrict(context: context)+ }+ } } /// The archive the golden library is built from: one record of every kind the
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swiftindex 42b4791..db94e18 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift@@ -191,8 +191,8 @@ struct BackupGroupProjectionTests { id: shared, key: "chapter-1", capturedAt: 40, work: second, site: site) // Manual, so the assignment *is* authored content and the normalisation // is what keeps the two rows from reading as two variants.- rowA.workAssignmentProvenanceRaw = FieldProvenanceKind.manual.rawValue- rowB.workAssignmentProvenanceRaw = FieldProvenanceKind.manual.rawValue+ rowA.editCitations { $0.workAssignment = .manual }+ rowB.editCitations { $0.workAssignment = .manual } try store.commit() let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }@@ -576,9 +576,10 @@ struct BackupGroupProjectionTests { try store.addPattern(id: ruleID, site: second, version: 3, active: false, createdAt: 0) let entry = store.addEntry(key: "chapter-1", capturedAt: 10, site: first) entry.chapterTitle = "Chapter 1"- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = ruleID- entry.chapterPatternVersion = 3+ entry.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: ruleID, patternVersion: 3)+ } try store.commit() let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }@@ -606,9 +607,10 @@ struct BackupGroupProjectionTests { let losing = store.addEntry( id: shared, key: "chapter-1", capturedAt: 20, title: "B", site: site) losing.chapterTitle = "Chapter 1"- losing.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- losing.chapterPatternID = ruleID- losing.chapterPatternVersion = 1+ losing.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: ruleID, patternVersion: 1)+ } try store.commit() let payload = try store.read { try LibraryRepository.projectV7Payload(context: $0) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swiftindex 0281bf9..342aa2a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift@@ -222,14 +222,13 @@ extension LibraryRepository { context.insert(site) for (index, (created, notes)) in [(5.0, "phone notes"), (20.0, "")].enumerated() { let work = Work(- id: id, displayTitle: "A Serial", siteHostname: hostname,+ id: id, displayTitle: "A Serial", timestamp: epoch.addingTimeInterval(created)) work.modifiedAt = epoch.addingTimeInterval(created) work.genericNotes = notes work.lastParsedTitle = "A Serial" work.titleProvenance = .parsed context.insert(work)- work.setLegacySite(site) let entry = Entry( id: UUID(), captureTitle: "Chapter \(index)", captureTitleSource: .host, rawURLString: "https://\(hostname)/chapter-\(index)", hostname: hostname,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex 154b18b..78cd526 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -456,7 +456,7 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr at: configuration.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true )- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let storeConfig = ModelConfiguration( "AsterismV3", schema: schema,@@ -465,12 +465,12 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr ) let container = try ModelContainer( for: schema,- migrationPlan: AsterismV8MigrationPlan.self,+ migrationPlan: AsterismV9MigrationPlan.self, configurations: [storeConfig] ) let context = ModelContext(container) try context.save()- try Data("7\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+ try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic) } private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) throws {@@ -479,7 +479,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) at: configuration.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true )- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let storeConfig = ModelConfiguration( "AsterismV3", schema: schema,@@ -488,13 +488,13 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) ) let container = try ModelContainer( for: schema,- migrationPlan: AsterismV8MigrationPlan.self,+ migrationPlan: AsterismV9MigrationPlan.self, configurations: [storeConfig] ) let context = ModelContext(container) let site = Site(hostname: "existing.com") context.insert(site)- let work = Work(displayTitle: "Existing Work", siteHostname: "existing.com", timestamp: Date())+ let work = Work(displayTitle: "Existing Work", timestamp: Date()) context.insert(work) let entry = Entry( captureTitle: "Chapter 1",@@ -510,9 +510,8 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) // gone with the marker generation that ran it, so a certified library is // seeded already linked. entry.site = site- work.setLegacySite(site) try context.save()- try Data("7\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+ try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic) } /// An archive with `entryCount` Entries on one untaught Site, for the chunking
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swiftindex c2a0399..8429947 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV7ArchiveTests.swift@@ -561,7 +561,7 @@ struct BackupV7ExportTests { let context: ModelContext init() throws {- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) container = try ModelContainer( for: schema, configurations: [@@ -586,7 +586,7 @@ struct BackupV7ExportTests { hostname: BackupV7ExportTests.host, entryIdentityKey: rawURL, timestamp: BackupV7ExportTests.early, note: BackupV7ExportTests.note) entry.conservativeIdentityKey = rawURL- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.manual.rawValue+ entry.editCitations { $0.workAssignment = .manual } context.insert(entry) entry.site = site entry.work = work
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swiftindex 1be1197..bcaf979 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift@@ -78,7 +78,7 @@ struct BootstrapActionTests { /// refusal has to leave the library exactly as it found it, because the /// recovery is a backup archive restored over this store. @Test("A retired marker generation is refused, naming the digit, and nothing is written",- arguments: ["4", "5", "6"])+ arguments: ["4", "5", "6", "7"]) func retiredMarkerGenerationIsRefused(digit: String) async throws { let root = try ActionRoot() try await root.seedReadyLibrary(hostname: "retired-\(digit).example")@@ -119,7 +119,7 @@ struct BootstrapActionTests { // are not what "empty" means here: the guard asks whether anything of the // *reader's* would be certified sight unseen. #expect(result == .ready(.seededEmpty))- #expect(try root.markerText() == "8",+ #expect(try root.markerText() == "9", "a crash between store creation and the marker is repaired, not terminal") withExtendedLifetime(root) {} }@@ -222,7 +222,7 @@ struct BootstrapActionTests { // the owner's library (Decision 5) — and the container construction is // what fails. try Data("this is not a sqlite store".utf8).write(to: root.storeURL, options: .atomic)- try root.writeMarker("8\n")+ try root.writeMarker("9\n") let before = try root.digest() await #expect(throws: (any Error).self) {@@ -260,10 +260,10 @@ private enum RefusedState: String, CaseIterable, Sendable { switch self { case .storeRecordedBelowV5: try root.installStoreRecordedAtFourZeroZero()- try root.writeMarker("8\n")+ try root.writeMarker("9\n") case .readinessMarkerWithoutAStore: try root.createStoreDirectory()- try root.writeMarker("8\n")+ try root.writeMarker("9\n") case .historicalMarkerWithoutAStore: try root.createStoreDirectory() try root.writeHistoricalMarker()@@ -272,7 +272,7 @@ private enum RefusedState: String, CaseIterable, Sendable { try root.writeMigrationArtefact() case .markerRecordingAnUnknownVersion: try await root.seedReadyLibrary(hostname: "unknown.example")- try root.writeMarker("9\n")+ try root.writeMarker("10\n") case .markerThatIsNotText: try await root.seedReadyLibrary(hostname: "bytes.example") try root.writeMarkerBytes(ActionRoot.nonUTF8MarkerBytes)@@ -375,7 +375,7 @@ private final class ActionRoot { let context = ModelContext(container) let timestamp = Date(timeIntervalSince1970: 1_800_000_000) let raw = "https://\(hostname)/read/1"- let work = Work(displayTitle: "A Work", siteHostname: hostname, timestamp: timestamp)+ let work = Work(displayTitle: "A Work", timestamp: timestamp) let entry = Entry( captureTitle: "A Capture", captureTitleSource: .host, rawURLString: raw, hostname: hostname, entryIdentityKey: raw, timestamp: timestamp, work: work)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swiftindex dd3b3c3..083d619 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift@@ -18,14 +18,14 @@ import Testing /// | Axis | Values | /// |---|---| /// | Store family | absent / main file only / companions only / full family |-/// | Readiness marker | absent / `"4"` / `"5"` / `"6"` / `"7"` / `"8"` / unrecognised text / non-UTF-8 bytes |+/// | Readiness marker | absent / `"4"` / `"5"` / `"6"` / `"8"` / `"9"` / unrecognised text / non-UTF-8 bytes | /// | Historical marker | present / absent | /// | Migration artefact | present / absent | /// | Recorded version | at-or-above V5 / below / indeterminate | ///-/// 288 combinations, of which two collapse across the version axis: with no main+/// 384 combinations, of which two collapse across the version axis: with no main /// file there is nothing to read, so the store-absent and companions-only rows-/// have one version value rather than three. That leaves 192 distinct cells,+/// have one version value rather than three. That leaves 256 distinct cells, /// small enough to enumerate rather than sample. /// /// Every cell asserts three things:@@ -85,7 +85,7 @@ struct BootstrapClassifierTests { func readyMarkerGovernsOverAHistoricalMarker() throws { let root = try ClassifierRoot() try root.seedBornAtLiveStore()- try root.writeMarker("8\n")+ try root.writeMarker("9\n") try root.writeHistoricalMarker() #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)@@ -99,7 +99,7 @@ struct BootstrapClassifierTests { func readyMarkerGovernsOverALeftoverArtefact() throws { let root = try ClassifierRoot() try root.seedBornAtLiveStore()- try root.writeMarker("8\n")+ try root.writeMarker("9\n") try root.writeMigrationArtefact() #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)@@ -155,7 +155,7 @@ struct BootstrapClassifierTests { let root = try ClassifierRoot() try root.createStoreDirectory() switch kind {- case .readinessMarker: try root.writeMarker("7\n")+ case .readinessMarker: try root.writeMarker("8\n") case .historicalMarker: try root.writeHistoricalMarker() case .migrationSidecar: try root.writeMigrationArtefact() }@@ -171,7 +171,7 @@ struct BootstrapClassifierTests { func overlappingOrphanedEvidenceNamesTheReadinessMarker() throws { let root = try ClassifierRoot() try root.createStoreDirectory()- try root.writeMarker("7\n")+ try root.writeMarker("8\n") try root.writeHistoricalMarker() try root.writeMigrationArtefact() @@ -180,19 +180,23 @@ struct BootstrapClassifierTests { withExtendedLifetime(root) {} } - /// The three retired generations (`data-model-cleanups` Decision 2). Each was- /// an openable state with an upgrade path beside it — the relationship data- /// pass for `"4"`, a republication for `"5"` and `"6"` — and each is now+ /// The four retired generations. Each was an openable state with an upgrade+ /// path beside it — the relationship data pass for `"4"`, a republication+ /// for `"5"` and `"6"`, V8's population pass for `"7"` — and each is now /// refused, because the population those paths existed for is entirely past- /// them. `"7"` is **not** among them: `multi-site-works` added it back to- /// `appOpenableMarkerVersions` as the lagging generation V8 upgrades from.+ /// them. `"4"`, `"5"` and `"6"` went at `data-model-cleanups` Decision 2;+ /// `"7"` joined them here. `multi-site-works` had added it back to+ /// `appOpenableMarkerVersions` as the lagging generation V8 upgraded from,+ /// and `drop-superseded-columns` **substituted** `"8"` for it (Q2 of that+ /// spec) once every device was confirmed past `"7"`, because the lagging+ /// row holds one digit at a time. /// /// The refusal **names the digit**. Nothing else on the failing side of the /// state table distinguishes one retired generation from another, so a /// message that said only "unsupported" would leave the owner of the one /// library this can happen to with nothing to act on. @Test("A marker recording a retired generation is refused, naming the digit",- arguments: ["4", "5", "6"])+ arguments: ["4", "5", "6", "7"]) func retiredMarkerGenerationIsRefused(digit: String) throws { let root = try ClassifierRoot() try root.seedBornAtLiveStore()@@ -209,7 +213,7 @@ struct BootstrapClassifierTests { } @Test("A marker no build understands classifies unrecognised",- arguments: ["9\n", "3\n", "45\n", "", "four\n"])+ arguments: ["3\n", "45\n", "", "four\n"]) func unrecognisedMarkerText(content: String) throws { let root = try ClassifierRoot() try root.seedBornAtLiveStore()@@ -283,7 +287,7 @@ struct BootstrapClassifierTests { func belowV5StoreIsRefused() throws { let root = try ClassifierRoot() try V4RecordedStoreFixture.install(at: root.storeURL)- try root.writeMarker("7\n")+ try root.writeMarker("8\n") #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .belowV5(version: "4.0.0"),@@ -298,7 +302,7 @@ struct BootstrapClassifierTests { // it, which is `.indeterminate` — and `.indeterminate` proceeds. try root.createStoreDirectory() try Data("not a database".utf8).write(to: root.storeURL, options: .atomic)- try root.writeMarker("8\n")+ try root.writeMarker("9\n") try #require(StoreMetadata.recordedVersion(at: root.storeURL) == .indeterminate) #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready,@@ -364,9 +368,9 @@ private struct Cell: Sendable, CustomStringConvertible { case .four: try root.writeMarker("4\n") case .five: try root.writeMarker("5\n") case .six: try root.writeMarker("6\n")- case .seven: try root.writeMarker("7\n") case .eight: try root.writeMarker("8\n")- case .unrecognisedText: try root.writeMarker("9\n")+ case .nine: try root.writeMarker("9\n")+ case .unrecognisedText: try root.writeMarker("10\n") case .nonUTF8: try root.writeMarkerBytes(ClassifierRoot.nonUTF8MarkerBytes) } if historicalMarker { try root.writeHistoricalMarker() }@@ -378,8 +382,8 @@ private struct Cell: Sendable, CustomStringConvertible { func expectedState(recordedVersion: StoreMetadata.RecordedVersion) -> BootstrapState { if case .below(let version) = recordedVersion { return .belowV5(version: version) } let storePresent = family.isStorePresent- if marker == .eight, storePresent { return .ready }- if marker == .seven, storePresent { return .markerLagging(generation: "7") }+ if marker == .nine, storePresent { return .ready }+ if marker == .eight, storePresent { return .markerLagging(generation: "8") } if !storePresent { if marker != .absent { return .orphanedEvidence(kind: .readinessMarker) } if historicalMarker { return .orphanedEvidence(kind: .historicalMarker) }@@ -406,7 +410,7 @@ private enum StoreFamily: String, CaseIterable, Sendable { } private enum MarkerAxis: String, CaseIterable, Sendable {- case absent, four, five, six, seven, eight, unrecognisedText, nonUTF8+ case absent, four, five, six, eight, nine, unrecognisedText, nonUTF8 } /// What the seeded main file is meant to record. The expectation is derived from
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swiftindex 75612c3..6dfc8a2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift@@ -23,7 +23,7 @@ import Testing /// reaches, as far as bootstrap can observe: a `.sqlite` at the frozen path and /// a marker beside it. What is lost is that the store *arrived* at V5 rather /// than being born there. `MarkerContractTests` and-/// `V5CertificationPathTests` keep that half, on the retained+/// `CertificationPathTests` keep that half, on the retained /// `v4-recorded-4.0.0.sqlite` fixture (task 12). /// * **The migration artefact is written as opaque bytes.** Nothing may construct /// a valid sidecar once the codec is gone, and both the current bootstrap and@@ -79,14 +79,14 @@ struct AppBootstrapStateTests { func unrecognisedMarkerFailsClosed() async throws { let root = try LibraryRoot() try await root.seedReadyLibrary(hostname: "f.example")- // "8" is the version the app publishes, so the unopenable future version+ // "9" is the version the app publishes, so the unopenable future version // this pins is the one after it.- try root.writeMarker("9\n")+ try root.writeMarker("10\n") await #expect(throws: LibraryRepositoryError.self) { try await LibraryRepository.openForApp(root.configuration) }- #expect(try root.markerBytes() == Data("9\n".utf8),+ #expect(try root.markerBytes() == Data("10\n".utf8), "a refused open leaves the marker's bytes alone (Req 2.8)") #expect(root.exists(root.storeURL), "and leaves the store it refused in place") }@@ -231,8 +231,12 @@ private enum PreCertificationState: String, CaseIterable, Sendable { case storeWithRetiredMarkerSix /// `configurable-work-types` Req 8.7's update window, with the **live** /// digit: the app has been updated and not yet launched, so the library- /// still records `"7"`. The app opens it and runs the population pass; the- /// extension must not, because it holds only a shared lock.+ /// still records `"8"`. The app opens it — the V8 → V9 stage drops the+ /// superseded columns on the way in — validates and republishes at `"9"`;+ /// the extension must not, because it holds only a shared lock and the+ /// conversion removes columns. The case name is historical: the lagging+ /// row holds one digit at a time and `drop-superseded-columns` substituted+ /// `"8"` for `"7"` (Q2), which is why the seed below writes `"8"`. case storeWithLaggingMarkerSeven func seed(into root: LibraryRoot) async throws {@@ -250,7 +254,7 @@ private enum PreCertificationState: String, CaseIterable, Sendable { try root.removeMarker() try root.writeMigrationArtefact() case .storeWithFutureMarker:- try root.writeMarker("9\n")+ try root.writeMarker("10\n") case .storeWithRetiredMarkerFour: try root.writeMarker("4\n") case .storeWithRetiredMarkerFive:@@ -258,7 +262,7 @@ private enum PreCertificationState: String, CaseIterable, Sendable { case .storeWithRetiredMarkerSix: try root.writeMarker("6\n") case .storeWithLaggingMarkerSeven:- try root.writeMarker("7\n")+ try root.writeMarker("8\n") } } }@@ -268,7 +272,7 @@ private enum PreCertificationState: String, CaseIterable, Sendable { /// The bytes a certified library's readiness marker holds. Frozen persisted state /// — `FrozenLibraryPathTests` is where that is pinned; here it is the value the /// ready cases compare against.-private let readyMarkerBytes = "8\n"+private let readyMarkerBytes = "9\n" /// The counts of a library seeded with exactly one `Site`. ///@@ -313,7 +317,7 @@ private final class LibraryRoot { // MARK: - Seeding /// The state Req 2.2 is about, reached the way the app reaches it: the- /// app-role opener creates the store, certifies it and marks it `"7"`, and one+ /// app-role opener creates the store, certifies it and marks it `"9"`, and one /// row is written through the repository it returns. No container opener and /// no migration path is involved, so nothing here is removed by a later task. func seedReadyLibrary(hostname: String) async throws {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swiftsimilarity index 68%rename from Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swiftrename to Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swiftindex 5a6db6c..83d7b28 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift@@ -16,11 +16,11 @@ import Testing /// /// What is left is the two ends: a store on a retired generation is **refused /// before a container converts it**, and an empty store is marked at birth. The-/// refusal case still seeds through `V5RecordedStoreFixture` — a store the-/// container *would* open — because a store that could not be opened anyway-/// would prove nothing about when the refusal happens.+/// refusal case seeds through `V8RecordedStoreFixture` — a store the container+/// *would* open — because a store that could not be opened anyway would prove+/// nothing about when the refusal happens. @Suite("Certification paths", .serialized)-struct V5CertificationPathTests {+struct CertificationPathTests { // MARK: - Helpers @@ -28,7 +28,7 @@ struct V5CertificationPathTests { let url: URL init() throws { url = FileManager.default.temporaryDirectory.appending(- path: "V5Certify-\(UUID())", directoryHint: .isDirectory)+ path: "Certify-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) } deinit { try? FileManager.default.removeItem(at: url) }@@ -44,21 +44,20 @@ struct V5CertificationPathTests { .trimmingCharacters(in: .whitespacesAndNewlines) } - /// A store recorded at 5.0.0 — the state a device that stopped launching the- /// app before `configurable-work-types` would be in, and one the declared- /// V5 → V6 stage converts happily. That it *is* convertible is the point: the- /// refusal below has to come from the marker, before any container exists,- /// not from a store nothing could open.+ /// A store recorded at 8.0.0 — the state every installed device is in on+ /// the morning of the V9 update, and one the declared V8 → V9 stage converts+ /// happily. That it *is* convertible is the point: the refusal below has to+ /// come from the marker, before any container exists, not from a store+ /// nothing could open. ///- /// It was the 4.0.0 fixture, installed and then converted in a separate step.- /// The declared V5 → V6 stage refuses a 4.0.0 store outright, and the shipped- /// classifier already refused one before any container existed (Req 2.9,- /// Decision 1) — so the frozen-snapshot seed is both the only constructible- /// input and the faithful one.- private func installStoreArrivedAtV5(_ configuration: LibraryConfiguration) throws {- try V5RecordedStoreFixture.install(at: configuration.storeURL)+ /// It has been the frozen-snapshot seed at each version in turn, because+ /// each newly declared stage refuses the generation below it outright and+ /// the shipped classifier already refused one before any container existed+ /// (Req 2.9, Decision 1).+ private func installStoreArrivedAtV8(_ configuration: LibraryConfiguration) throws {+ try V8RecordedStoreFixture.install(at: configuration.storeURL) #expect(try V4RecordedStoreFixture.recordedModelVersions(at: configuration.storeURL)- == ["5.0.0"], "the seed is written by the frozen snapshot, not the live classes")+ == ["8.0.0"], "the seed is written by the frozen snapshot, not the live classes") } // MARK: - The retired generations@@ -73,19 +72,20 @@ struct V5CertificationPathTests { /// The refusal happens in `classify`, before `ModelContainer.init`. That is /// what the recorded version proves: this store would have been converted to- /// 7.0.0 by any container construction, and it is still recorded at 5.0.0- /// afterwards. The marker is left alone for the same reason — the recovery is- /// a backup archive restored over this library, and a refusal that rewrote- /// the evidence would take that away.+ /// 9.0.0 by any container construction — **destructively**, since the V8 → V9+ /// stage drops columns — and it is still recorded at 8.0.0 afterwards. The+ /// marker is left alone for the same reason: the recovery is a backup+ /// archive restored over this library, and a refusal that rewrote the+ /// evidence would take that away. ///- /// The 5.0.0 pin only means something alongside the control that follows it:- /// the same store, marked `"7"`, opens and is recorded 7.0.0. That is what+ /// The 8.0.0 pin only means something alongside the control that follows it:+ /// the same store, marked `"8"`, opens and is recorded 9.0.0. That is what /// makes the pin an ordering claim rather than a store nothing could convert. @Test("A store on a retired marker generation is refused before anything converts it",- arguments: ["4", "5", "6"])+ arguments: ["4", "5", "6", "7"]) func retiredMarkerGenerationIsRefusedBeforeConversion(digit: String) async throws { let (dir, cfg) = try config()- try installStoreArrivedAtV5(cfg)+ try installStoreArrivedAtV8(cfg) try Data("\(digit)\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic) do {@@ -97,29 +97,29 @@ struct V5CertificationPathTests { } #expect(try markerContent(cfg) == digit, "a refused open may not rewrite the marker")- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["5.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["8.0.0"], "the marker check must decide before ModelContainer.init converts anything") // Control, mirroring the extension-side twin // (`MarkerContractTests.extensionDeclinesBeforeOpeningAContainer`):- // with a `"7"` marker the same store is reached, opened and converted.- // Without it the 5.0.0 assertion above could hold because the store was+ // with an `"8"` marker the same store is reached, opened and converted.+ // Without it the 8.0.0 assertion above could hold because the store was // unopenable rather than because the marker was read first.- try Data("7\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)+ try Data("8\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic) _ = try await LibraryRepository.openForApp(cfg)- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["8.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["9.0.0"], "the same store converts once the marker check passes") withExtendedLifetime(dir) {} } // MARK: - Mark-at-birth - @Test("Mark-at-birth publishes \"8\" directly for an empty store")+ @Test("Mark-at-birth publishes \"9\" directly for an empty store") func markAtBirthStillPublishesTheCurrentVersionDirectly() async throws { let (dir, cfg) = try config() let (result, _) = try await LibraryRepository.openForApp(cfg) #expect(result == .ready(.zero))- #expect(try markerContent(cfg) == "8",+ #expect(try markerContent(cfg) == "9", "an empty store has nothing to bring forward and is certified at birth (Q26)") withExtendedLifetime(dir) {} }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swiftindex bf0ffac..12ac13b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift@@ -4,71 +4,175 @@ import Testing @testable import AsterismCore -/// Decision 4, the in-commit half: **columns written ⇒ blob refreshed**.+/// **The blob is the only home a citation has** (V9, T-2280). ///-/// Through phase 1 every citation writer in the repository writes the legacy-/// columns, while `Entry.citations` — which `LibraryValidator` is now the sole-/// reader through — returns the *blob* wherever one exists and falls back to the-/// columns only while it is nil (Q36). After the migration every Entry has a-/// blob. A commit that wrote the columns and left the blob alone would therefore-/// hand its own validation the pre-write citations against post-write values,-/// and the commit would refuse itself.-///-/// `V8PopulationPass` converges the two homes, but only in `reconcileAfterSync`-/// and at bootstrap — never inside a commit. So the refresh lives at the write-/// site, and these are the tests that would fail without it.-@Suite("Citation blob refresh at the write site", .serialized)+/// The seventeen citation columns still exist in the V8 stored shape, but+/// nothing writes them any more: `Entry.init` seeds the blob, every writer goes+/// through `setCitations` or `editCitations`, and every reader — `Entry`'s own+/// provenance accessors included — reads it back. This suite pins the three+/// things that follow: a capture is born with a blob; an edit is value-guarded,+/// repairs bytes that will not decode (Q24) and leaves a nil blob nil (Q23);+/// and a row that has somehow lost its blob reads as the default and is+/// **counted** rather than throwing under whichever screen asked first, or+/// quarantining its hostname (Q6, Q25).+@Suite("The citation blob is the only home", .serialized) struct CitationBlobRefreshTests { private let host = "blob.example" // MARK: - The value guard itself - @Test("Refreshing a converged row changes nothing; a column write moves the blob")- func refreshIsValueGuarded() throws {+ @Test("A row is born with a blob, and an edit that changes nothing writes nothing")+ func editingIsValueGuarded() throws { let store = try BlobStore() let entry = store.insertEntry(hostname: host, seconds: 10) try store.save() - // First write: there is no blob, so one is minted from the columns.- #expect(LegacyColumns.refreshCitations(on: entry))- let minted = entry.citationsData- #expect(minted != nil)- // Second: the two homes agree, so nothing is written.- #expect(!LegacyColumns.refreshCitations(on: entry))+ // Born with one: `init` seeds the default, so no row this build creates+ // is ever nil-blob.+ let minted = try #require(entry.citationsData)+ #expect(try entry.citations == EntryCitations())++ // An edit that changes nothing writes nothing.+ #expect(!entry.editCitations { $0.chapterTitle = .absent }) #expect(entry.citationsData == minted) - // A column write makes them disagree, and the refresh converges them.+ // One that changes something moves the bytes, and the derived accessor+ // reads it back. entry.chapterTitle = "Chapter 7"- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = UUID()- entry.chapterPatternVersion = 1- #expect(try entry.citations != LegacyColumns.citations(from: entry))- #expect(LegacyColumns.refreshCitations(on: entry))- #expect(try entry.citations == LegacyColumns.citations(from: entry))- #expect(try entry.citations.chapterTitle.kind == .pattern)+ #expect(entry.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: UUID(), patternVersion: 1)+ })+ #expect(entry.citationsData != minted)+ #expect(entry.chapterTitleProvenance == .pattern) } - /// Bytes that will not decode are repaired from the columns rather than- /// standing as a permanent diagnosis.- @Test("An undecodable blob is replaced by what the columns say")- func refreshRepairsAnUndecodableBlob() throws {+ /// Bytes that will not decode are **repaired in place** (Q24): the edit+ /// lands over the default value rather than being dropped. Dropping it left+ /// the caller's surrounding write — a manual assignment, a re-teach — with+ /// its provenance stuck at `.none`, which is a state the validator refuses+ /// on the next save. What the row loses is citations nothing could read.+ @Test("An undecodable blob is repaired by the edit that lands on it")+ func editingRepairsAnUndecodableBlob() throws { let store = try BlobStore() let entry = store.insertEntry(hostname: host, seconds: 10)- entry.citationsData = Data("not json".utf8)+ let corrupt = Data("not json".utf8)+ entry.citationsData = corrupt try store.save() #expect(throws: (any Error).self) { try entry.citations }- #expect(LegacyColumns.refreshCitations(on: entry))- #expect(try entry.citations == LegacyColumns.citations(from: entry))+ #expect(entry.editCitations { $0.workAssignment = .manual })+ #expect(entry.citationsData != corrupt)+ #expect(try entry.citations.workAssignment == .manual)+ #expect(entry.workAssignmentProvenance == .manual)+ // Every arm the edit did not touch is the default, not the unreadable+ // row's former contents — which is the cost the decision accepts.+ #expect(try entry.citations.identity == .rawURL)+ }++ /// The write the repair exists for: `applyManualAssignment` is the shared+ /// body behind `moveEntry` and the detach disposition, and a skipped edit+ /// left it saving an Entry that holds a Work with `.none` provenance — the+ /// exact tuple `LibraryValidator` throws on.+ @Test("A manual assignment over undecodable bytes lands with its provenance")+ func manualAssignmentLandsOverUndecodableBytes() throws {+ let store = try BlobStore()+ let entry = store.insertEntry(hostname: host, seconds: 10)+ entry.citationsData = Data("not json".utf8)+ try store.save()++ LibraryRepository.applyManualAssignment(+ to: entry, at: Date(timeIntervalSince1970: 1_800_000_000))++ #expect(entry.workAssignmentProvenance == .manual)+ #expect(try entry.citations.workAssignment == .manual)+ #expect(try entry.citations.workURL == nil)+ #expect(entry.workURLAssignmentKind == nil)+ }++ /// A nil blob is **not** undecodable: an edit that changes nothing leaves it+ /// nil (Q23). `SiteReconciler.rewriteCitations` walks every Entry on a+ /// consolidation and rewrites nothing in the default value, so a converting+ /// `editCitations` would zero `entriesWithoutCitationBlobCount` — the one+ /// counter that tells the reader which rows lost their provenance — on the+ /// first reconcile after the upgrade.+ @Test("A citation rewrite over a nil-blob Entry leaves the blob nil")+ func rewritingCitationsLeavesANilBlobNil() throws {+ let store = try BlobStore()+ let entry = store.insertEntry(hostname: host, seconds: 10)+ entry.citationsData = nil+ try store.save()++ #expect(!SiteReconciler.rewriteCitations(of: entry, [UUID(): 3]))+ #expect(entry.citationsData == nil)++ // A rewrite over a *real* nil-blob-read edit still writes: it is only+ // the no-op that leaves the row alone.+ #expect(entry.editCitations { $0.workAssignment = .manual })+ #expect(entry.citationsData != nil)+ }++ // MARK: - Capture (Req 4: the blob is written at creation)++ @Test("A capture is saved with a citation blob")+ func captureWritesTheBlob() async throws {+ let fixture = try BlobFixture()+ let repository = try fixture.diagnosedRepository()++ _ = try await repository.capture(+ CaptureDraft(+ captureTitle: "Chapter 7 - Real Work", captureTitleSource: .host,+ rawURLString: "https://\(host)/read?chapter=7"))++ let facts = try fixture.citationFacts()+ #expect(facts.count == 1)+ #expect(facts.first?.hasBlob == true)+ }++ // MARK: - A nil blob (Q6)++ @Test("A nil-blob Entry reads as the default and is counted, not quarantined")+ func aNilBlobReadsAsTheDefaultAndIsCounted() throws {+ let fixture = try BlobFixture()+ try fixture.seed { context in+ let site = Site(hostname: self.host)+ site.mode = .untaught+ context.insert(site)+ let entry = BlobFixture.insertEntry(context, hostname: self.host, seconds: 10)+ entry.site = site+ // What a row a V8 build wrote looks like once V9 has dropped the+ // columns it kept its citations in.+ entry.citationsData = nil+ }++ let context = ModelContext(fixture.container)+ let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)+ // An ordinary read answers rather than throwing.+ #expect(try entry.citations == EntryCitations())+ #expect(entry.citationValues.identity == .rawURL)+ #expect(try LibraryRepository.snapshot(entry).identityKeyVersion == 1)++ // It is **counted**, and that is the whole of the report (Req 19, Q25).+ let scan = try LibraryToleranceScan.scan(context: ModelContext(fixture.container))+ #expect(scan.entriesWithoutCitationBlobCount == 1)++ // It is **not** quarantined. Refusing every write on the hostname+ // recovers nothing — the provenance went at the drop stage and no write+ // on this site brings it back — and it would take the reader's repair+ // (a re-teach, a manual assignment) away with it. Undecodable bytes+ // still throw; `MembershipValidationTests` pins that half.+ let diagnostics = try LibraryValidator.validate(context: ModelContext(fixture.container))+ #expect(diagnostics.diagnoses.isEmpty)+ #expect(diagnostics.quarantineMap()[self.host] == nil) } // MARK: - Composed teaching (Req 3.2) - /// The blocker this suite exists for: on a library that has migrated, every- /// Entry carries a blob, so the first re-teach reads the pre-write blob- /// against the post-write columns and refuses itself with "absent chapter- /// must have none provenance".- @Test("A re-teach on a migrated library commits and leaves the blob equal to the columns")+ /// The blocker this suite exists for: a re-teach's own validation reads the+ /// citations back inside the commit, so a writer that left the blob behind+ /// refused itself with "absent chapter must have none provenance". It cannot+ /// now — the blob *is* what the writer wrote.+ @Test("A re-teach on a migrated library commits and leaves every citation in the blob") func composedTeachingRefreshesTheBlob() async throws { let fixture = try BlobFixture() try fixture.seed { context in@@ -78,7 +182,8 @@ struct CitationBlobRefreshTests { BlobFixture.insertEntry(context, hostname: self.host, seconds: 10) } // Exactly what the migration leaves behind (Req 2.1): a blob per Entry.- try fixture.runPopulationPass()+ // It used to take a conversion pass over the seeded rows; `Entry.init`+ // seeds the default blob since V9 (Q13), so a row is born with one. #expect(try fixture.citationFacts().allSatisfy(\.hasBlob)) let repository = try fixture.diagnosedRepository()@@ -95,8 +200,6 @@ struct CitationBlobRefreshTests { for facts in try fixture.citationFacts() { #expect(facts.hasBlob)- // The blob is what the columns say, not what they said before.- #expect(facts.matchesColumns) #expect(facts.chapterKind == .pattern) #expect(facts.workAssignmentKind == .pattern) }@@ -105,7 +208,7 @@ struct CitationBlobRefreshTests { // MARK: - Recalculation (the re-parse commit's writer) - @Test("Recalculation on a migrated library commits and leaves the blob equal to the columns")+ @Test("Recalculation on a migrated library commits and leaves every citation in the blob") func recalculationRefreshesTheBlob() async throws { let fixture = try BlobFixture() try fixture.seed { context in@@ -114,7 +217,6 @@ struct CitationBlobRefreshTests { // recalculation has something to write. BlobFixture.insertEntry(context, hostname: self.host, seconds: 10) }- try fixture.runPopulationPass() let repository = try fixture.diagnosedRepository() let contract = try await repository.previewRecalculation(hostname: host)@@ -125,7 +227,7 @@ struct CitationBlobRefreshTests { } for facts in try fixture.citationFacts() {- #expect(facts.matchesColumns)+ #expect(facts.hasBlob) #expect(facts.chapterKind == .pattern) } }@@ -143,12 +245,12 @@ private final class BlobStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismCitationBlob-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }@@ -168,7 +270,6 @@ private final class BlobStore { /// crosses out. private struct CitationFacts { var hasBlob: Bool- var matchesColumns: Bool var chapterKind: FieldProvenanceKind var workAssignmentKind: FieldProvenanceKind }@@ -199,20 +300,12 @@ private struct BlobFixture { try context.save() } - func runPopulationPass() throws {- let context = ModelContext(container)- try V8PopulationPass.run(context: context)- try context.save()- }- func citationFacts() throws -> [CitationFacts] { let context = ModelContext(container) return try context.fetch(FetchDescriptor<Entry>()).map { entry in- let columns = LegacyColumns.citations(from: entry) let blob = try? entry.citations return CitationFacts( hasBlob: entry.citationsData != nil,- matchesColumns: blob == columns, chapterKind: blob?.chapterTitle.kind ?? .none, workAssignmentKind: blob.map(Self.kind(of:)) ?? .none) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swiftindex 077c9e1..abc8514 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationResolutionParityTests.swift@@ -47,8 +47,9 @@ struct CitationResolutionParityTests { 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)+ #expect(+ fixture.entry.citationValues.chapterTitle.patternID == fixture.titlePattern.id)+ #expect(fixture.entry.citationValues.chapterTitle.patternVersion == 1) let diagnostics = try LibraryValidator.validate(graph: fixture.graph) #expect(diagnostics.quarantineMap().isEmpty)@@ -65,7 +66,7 @@ struct CitationResolutionParityTests { version: 2, isActive: true, createdAt: Self.epoch, definition: .wholeTitle, site: fixture.site) fixture.site.patterns = fixture.site.patternValues + [v2]- #expect(fixture.entry.identityNameTitleRuleVersion == 1)+ #expect(fixture.entry.citationValues.identity.nameTitleRule?.version == 1) let diagnostics = try LibraryValidator.validate(graph: fixture.graph) #expect(diagnostics.quarantineMap().isEmpty)@@ -87,8 +88,7 @@ struct CitationResolutionParityTests { 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)+ #expect(fixture.entry.citationValues.identity.urlRule?.version == 2) let diagnostics = try LibraryValidator.validate(graph: fixture.graph) #expect(diagnostics.quarantineMap().isEmpty)@@ -107,11 +107,10 @@ struct CitationResolutionParityTests { /// A `WorkSiteMembership` cites its rule by UUID alone and carries no /// version, so there is no `(id, version)` pair to resolve and no version to /// mismatch — the version for display resolves by row lookup at read time.- /// The retained column keeps its stale value until V9 and nothing reads it.+ /// The retained column that kept a stale value beside it went at V9. @Test("The Work rule identity no longer resolves a cited version at all") func workIdentityIsNoLongerACitedRuleSite() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()- fixture.work.urlIdentityRuleVersion = 99 fixture.membership.urlIdentityRuleID = UUID() // cites a rule no Site owns let diagnostics = try LibraryValidator.validate(graph: fixture.graph)@@ -122,7 +121,7 @@ struct CitationResolutionParityTests { @Test("A version mismatch on the assignment reference is diagnosed, not resolved") func ruleVersionMismatchDiagnosesAtRequiredReference() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()- fixture.entry.workURLRuleVersion = 99+ fixture.entry.citeWorkURLRule(version: 99) let diagnostics = try LibraryValidator.validate(graph: fixture.graph) #expect(diagnostics.quarantineMap()[fixture.site.hostname] != nil)@@ -148,9 +147,9 @@ struct CitationResolutionParityTests { 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+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: citedID, version: 1))+ } } let repository = try await library.openForApp() @@ -173,9 +172,9 @@ struct CitationResolutionParityTests { definition: .segmented) let entry = store.insertEntry( hostname: "cited.example", title: "A Cited Work - Chapter 3")- entry.workAssignmentProvenance = .pattern- entry.workPatternID = citedID- entry.workPatternVersion = 99+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: citedID, version: 99))+ } } let repository = try await library.openForApp() @@ -197,9 +196,9 @@ struct CitationResolutionParityTests { 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+ orphan.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: UUID(), version: 1))+ } } let repository = try await library.openForApp() @@ -230,9 +229,9 @@ struct CitationResolutionParityTests { 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+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: citedID, version: 1))+ } } let repository = try await library.openForApp() @@ -252,9 +251,9 @@ struct CitationResolutionParityTests { 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+ orphan.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: citedID, version: 1))+ } } let repository = try await library.openForApp() @@ -317,17 +316,18 @@ struct CitationResolutionParityTests { 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 {+ let citations = entry.citationValues+ if let chapter = citations.chapterTitle.patternID,+ let chapterVersion = citations.chapterTitle.patternVersion { #expect(assigned.patternValues.contains {- $0.id == patternID && $0.version == patternVersion+ $0.id == chapter && $0.version == chapterVersion }, "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)+ let cited = try #require(citations.identity.urlRule) #expect(assigned.urlRuleValues.contains {- $0.id == ruleID && $0.version == ruleVersion+ $0.id == cited.id && $0.version == cited.version }, "the cited URL rule is not owned by the assigned Site row") } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationSurgery.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationSurgery.swiftnew file mode 100644index 0000000..567e61f--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationSurgery.swift@@ -0,0 +1,91 @@+import Foundation++@testable import AsterismCore++/// **Test-only citation surgery.**+///+/// Several suites seed a *broken* citation — a version no rule holds, an id no+/// Site owns — to prove the validator reports it or the reconciler repairs it.+/// Each of those used to be a one-line write to a retired column. Since V9 the+/// blob is a citation's only home and its arms are typed, so breaking one means+/// naming the arm; these helpers say that once instead of at twenty call sites.+///+/// Nothing in production does any of this. A blob whose arms disagree is a state+/// only a partial write produced, and the columns that made one spellable are+/// gone.+extension EntryCitations.Identity {+ /// The same arm with the name contributor cited at a different version.+ func citingNameTitleVersion(_ version: Int) -> Self {+ guard let url = urlRule, let name = nameTitleRule else { return self }+ let replaced = CitedRule(id: name.id, version: version)+ switch self {+ case .rawURL: return self+ case .rule: return .rule(url: url, nameTitle: replaced)+ case .composed: return .composed(url: url, nameTitle: replaced)+ }+ }++ /// The same arm with no name contributor at all.+ var citingNoNameTitle: Self {+ guard let url = urlRule else { return self }+ switch self {+ case .rawURL: return self+ case .rule: return .rule(url: url, nameTitle: nil)+ case .composed: return .composed(url: url, nameTitle: nil)+ }+ }++ /// The same arm citing a different URL rule row.+ func citingURLRuleID(_ id: UUID) -> Self {+ guard let url = urlRule else { return self }+ let replaced = CitedRule(id: id, version: url.version)+ switch self {+ case .rawURL: return self+ case .rule: return .rule(url: replaced, nameTitle: nameTitleRule)+ case .composed: return .composed(url: replaced, nameTitle: nameTitleRule)+ }+ }+}++extension FieldProvenance {+ /// The same provenance citing its pattern at a different version.+ func citingPatternVersion(_ version: Int) -> FieldProvenance {+ FieldProvenance.tolerant(kind: kind, patternID: patternID, patternVersion: version)+ }++ /// The same provenance citing a different pattern row.+ func citingPatternID(_ id: UUID) -> FieldProvenance {+ FieldProvenance.tolerant(kind: kind, patternID: id, patternVersion: patternVersion)+ }+}++extension Entry {+ /// Re-points every URL-rule citation — the identity basis, the Work+ /// extraction and the chapter sequence — at one id, which is what it takes+ /// to reach the extraction replay: the tuple table requires all three to+ /// name the same rule.+ func citeURLRule(id: UUID) {+ editCitations { citations in+ citations.identity = citations.identity.citingURLRuleID(id)+ if let urlWork = citations.urlWork {+ citations.urlWork = CitedRule(id: id, version: urlWork.version)+ }+ if let sequence = citations.chapterSequence {+ citations.chapterSequence = CitedRule(id: id, version: sequence.version)+ }+ }+ }++ /// Re-points the Work-assignment URL rule — both the `.urlRule` arm and the+ /// `workURL` citation beside it, which are one column pair's worth of state.+ func citeWorkURLRule(id: UUID? = nil, version: Int? = nil) {+ editCitations { citations in+ guard let current = citations.workAssignment.urlRuleRule ?? citations.workURL+ else { return }+ let replaced = CitedRule(+ id: id ?? current.id, version: version ?? current.version)+ citations.workAssignment = .urlRule(replaced)+ citations.workURL = replaced+ }+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swiftindex 85c974a..f958c0e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternResolutionTests.swift@@ -266,17 +266,16 @@ private final class CitedResolutionFixture { let rawURL = "https://\(hostname)/read?\(workQuery)=\(workIdentity)&\(sequenceQuery)=\(sequence)" let workTitle = "Work \(workIdentity)"- let work = Work(displayTitle: workTitle, siteHostname: hostname, timestamp: epoch)- work.setLegacyColumns {+ let work = Work(displayTitle: workTitle, timestamp: epoch)+ work.primaryMembershipEdit { $0.urlIdentity = workIdentity $0.urlIdentityState = .rule $0.urlIdentityRuleID = rule.id }- work.urlIdentityRuleVersion = rule.version context.insert(work)- // Both halves in the same save (Req 1.4), pointing at *this* row — the- // one that owns the rule the Work's identity cites.- work.setLegacySite(site)+ // The membership is pinned at *this* row — the one that owns the rule+ // the Work's identity cites (Req 1.4).+ work.primaryMembershipEdit { $0.site = site } let identity = try URLDerivedEntryIdentity( hostname: ExactScalarString(hostname),@@ -287,25 +286,22 @@ private final class CitedResolutionFixture { captureTitleSource: .host, rawURLString: rawURL, hostname: hostname, entryIdentityKey: EntryIdentityKeyV2Codec.encode(identity), timestamp: epoch, work: work)- entry.identityKeyVersion = 2 entry.identityBasis = .urlRule entry.conservativeIdentityKey = rawURL entry.urlWorkIdentity = workIdentity- entry.urlWorkRuleID = rule.id- entry.urlWorkRuleVersion = rule.version entry.chapterSequence = sequence- entry.chapterSequenceRuleID = rule.id- entry.chapterSequenceRuleVersion = rule.version- entry.identityURLRuleID = rule.id- entry.identityURLRuleVersion = rule.version entry.chapterTitle = "Chapter \(sequence)"- entry.chapterTitleProvenance = .pattern- entry.chapterPatternID = pattern.id- entry.chapterPatternVersion = pattern.version- entry.workAssignmentProvenance = .urlRule- entry.workURLAssignmentKind = .identity- entry.workURLRuleID = rule.id- entry.workURLRuleVersion = rule.version+ let citedRule = CitedRule(id: rule.id, version: rule.version)+ try entry.setCitations(+ EntryCitations(+ identity: .rule(url: citedRule, nameTitle: nil),+ urlWork: citedRule,+ chapterSequence: citedRule,+ chapterTitle: FieldProvenance.tolerant(+ kind: .pattern, patternID: pattern.id, patternVersion: pattern.version),+ workAssignment: .urlRule(citedRule),+ workURL: citedRule,+ workURLAssignmentKind: .identity)) context.insert(entry) // Same row again: this Entry cites this row's title pattern and URL // rule, so this row is where its citations resolve.
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swiftindex 495005a..fae5bcb 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift@@ -38,13 +38,13 @@ struct ComposedCaptureTests { let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first { $0.id == snapshot.id }) try LibraryValidator.validateEntryTuple( entry: entry, site: fixture.site(context), works: entry.work.map { [$0] } ?? [])- #expect(entry.identityKeyVersion == 2)+ #expect(entry.citationValues.identity.keyVersion == 2) #expect(entry.conservativeIdentityKey == entry.rawURLString) #expect(entry.chapterTitle == "Chapter 9") #expect(entry.chapterSequence == "9") #expect(entry.urlWorkIdentity == "42") // Reused the Work created for identity 42 by the teach.- #expect(entry.work?.legacyColumns.urlIdentity == "42")+ #expect(entry.work?.primaryMembership?.urlIdentity == "42") } @Test("Capture after whole-title + sequence teach: v3 key with the title-derived name")@@ -58,8 +58,8 @@ struct ComposedCaptureTests { let context = fixture.freshContext() let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first { $0.id == snapshot.id })- #expect(entry.identityKeyVersion == 3)- #expect(entry.identityNameTitleRuleID != nil)+ #expect(entry.citationValues.identity.keyVersion == 3)+ #expect(entry.citationValues.identity.nameTitleRule != nil) #expect(entry.chapterSequence == "2") #expect(entry.urlWorkIdentity == nil) let expected = EntryIdentityKeyV3Codec.encode(try URLSequenceNameIdentity(@@ -84,7 +84,7 @@ struct ComposedCaptureTests { let context = fixture.freshContext() let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first { $0.id == snapshot.id })- #expect(entry.identityKeyVersion == 1) // conservative+ #expect(entry.citationValues.identity.keyVersion == 1) // conservative #expect(entry.entryIdentityKey == entry.rawURLString) #expect(entry.chapterSequence == nil) #expect(entry.chapterTitle == "Chapter 3") // settled by the title@@ -107,7 +107,7 @@ struct ComposedCaptureTests { let works = try context.fetch(FetchDescriptor<Work>()) #expect(works.count == 1) let work = try #require(works.first)- #expect(work.legacyColumns.urlIdentity == "42")+ #expect(work.primaryMembership?.urlIdentity == "42") #expect(work.lastParsedTitle == "Real Work Renamed") #expect(work.displayTitle == "Real Work Renamed") // provenance is parsed }@@ -119,7 +119,7 @@ struct ComposedCaptureTests { let snapshot = try await fixture.capture(title: "Anything", rawURL: "https://ex.com/read?id=1") let context = fixture.freshContext() let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first { $0.id == snapshot.id })- #expect(entry.identityKeyVersion == 1)+ #expect(entry.citationValues.identity.keyVersion == 1) #expect(entry.entryIdentityKey == "https://ex.com/read?id=1") #expect(entry.conservativeIdentityKey == "https://ex.com/read?id=1") #expect(entry.work == nil)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swiftindex fe0e52e..cc2c2ea 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift@@ -57,7 +57,7 @@ struct ComposedRecalculationTests { entryIdentityKey: "https://ex.com/read?id=42&chapter=9", timestamp: Date(timeIntervalSince1970: 99)) entry.conservativeIdentityKey = entry.rawURLString- entry.identityKeyVersion = 1+ entry.editCitations { $0.identity = .rawURL } entry.identityBasis = .conservative context.insert(entry) }@@ -71,7 +71,7 @@ struct ComposedRecalculationTests { let context = fixture.freshContext() #expect(try LibraryValidator.validate(context: context).tupleDiagnoses[host] == nil) let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first { $0.id == drifted })- #expect(entry.identityKeyVersion == 2) // rule applied+ #expect(entry.citationValues.identity.keyVersion == 2) // rule applied #expect(entry.chapterSequence == "9") #expect(entry.chapterTitle == "Chapter 9") #expect(entry.work != nil)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swiftindex 1ad829a..735814f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift@@ -55,7 +55,7 @@ struct ComposedTeachingRepositoryTests { #expect(entry.conservativeIdentityKey == entry.rawURLString) } let e1Entry = try #require(entries.first { $0.id == e1 })- #expect(e1Entry.identityKeyVersion == 2)+ #expect(e1Entry.citationValues.identity.keyVersion == 2) #expect(e1Entry.identityBasis == .urlRule) #expect(e1Entry.chapterTitle == "Chapter 7") #expect(e1Entry.chapterSequence == "7")@@ -88,9 +88,10 @@ struct ComposedTeachingRepositoryTests { let context = fixture.freshContext() #expect(try LibraryValidator.validate(context: context).tupleDiagnoses[host] == nil) let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first { $0.id == e1 })- #expect(entry.identityKeyVersion == 3)- #expect(entry.identityNameTitleRuleID == titleID)- #expect(entry.identityNameTitleRuleVersion == titleVersion)+ #expect(entry.citationValues.identity.keyVersion == 3)+ #expect(+ entry.citationValues.identity.nameTitleRule+ == CitedRule(id: titleID, version: titleVersion)) #expect(entry.urlWorkIdentity == nil) #expect(entry.chapterSequence == "7") }@@ -155,7 +156,7 @@ struct ComposedTeachingRepositoryTests { let midContext = fixture.freshContext() let midEntry = try #require(try midContext.fetch(FetchDescriptor<Entry>()).first { $0.id == e1 }) let firstKey = midEntry.entryIdentityKey- let firstNameVersion = midEntry.identityNameTitleRuleVersion+ let firstNameVersion = midEntry.citationValues.identity.nameTitleRule?.version // Re-teach the title rule with a leading trim → name "Solo Story". let second = try await fixture.repository.projectComposedTeaching(@@ -173,8 +174,9 @@ struct ComposedTeachingRepositoryTests { #expect(try LibraryValidator.validate(context: afterContext).tupleDiagnoses[host] == nil) let afterEntry = try #require(try afterContext.fetch(FetchDescriptor<Entry>()).first { $0.id == e1 }) #expect(afterEntry.entryIdentityKey != firstKey) // key recomputed with the trimmed name- #expect(afterEntry.identityNameTitleRuleVersion == titleV2)- #expect(afterEntry.identityNameTitleRuleVersion != firstNameVersion)+ let afterNameVersion = afterEntry.citationValues.identity.nameTitleRule?.version+ #expect(afterNameVersion == titleV2)+ #expect(afterNameVersion != firstNameVersion) let expected = EntryIdentityKeyV3Codec.encode(try URLSequenceNameIdentity( hostname: ExactScalarString(host), workName: ExactScalarString("Solo Story"), chapterSequence: ExactScalarString("7")))@@ -246,7 +248,7 @@ struct ComposedTeachingRepositoryTests { timestamp: Date(timeIntervalSince1970: 5)) entry.conservativeIdentityKey = entry.rawURLString entry.chapterTitle = ""- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.manual.rawValue+ entry.editCitations { $0.chapterTitle = .manual } context.insert(entry) } @@ -262,7 +264,7 @@ struct ComposedTeachingRepositoryTests { let context = fixture.freshContext() #expect(try context.fetch(FetchDescriptor<TitlePattern>()).isEmpty) let e1Entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first { $0.id == e1 })- #expect(e1Entry.identityKeyVersion == 1)+ #expect(e1Entry.citationValues.identity.keyVersion == 1) } // MARK: - Identity-matched Work renaming (Req 3.21)@@ -311,7 +313,7 @@ struct ComposedTeachingRepositoryTests { } let seeded = fixture.freshContext() let seededWork = try #require(try seeded.fetch(FetchDescriptor<Work>()).first)- #expect(seededWork.legacyColumns.urlIdentity == "regressor") // identity-matched from here on+ #expect(seededWork.primaryMembership?.urlIdentity == "regressor") // identity-matched from here on #expect(seededWork.displayTitle == "Read The Regressor Creates Everything") // Re-teach the same rules with a leading trim.@@ -371,8 +373,8 @@ struct ComposedTeachingRepositoryTests { #expect(membership.urlIdentityRuleID != nil) // The retained columns hold the same answer until V9 (Decision 3), so // neither home can be read as the Work having no identity.- #expect(work.legacyColumns.urlIdentity == "regressor")- #expect(work.legacyColumns.urlIdentityState == .rule)+ #expect(work.primaryMembership?.urlIdentity == "regressor")+ #expect(work.primaryMembership?.urlIdentityState == .rule) } @Test("A manual display title survives the rename an identity match applies (Req 3.21)")@@ -535,7 +537,7 @@ struct ComposedRepoFixture { rawURLString: spec.url, hostname: host, entryIdentityKey: spec.url, timestamp: Date(timeIntervalSince1970: seconds)) entry.conservativeIdentityKey = spec.url- entry.identityKeyVersion = 1+ entry.editCitations { $0.identity = .rawURL } entry.identityBasis = .conservative context.insert(entry) seconds += 1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swiftindex 92da693..fb1ca07 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift@@ -348,12 +348,12 @@ private final class RuleGroupStore { let directory = FileManager.default.temporaryDirectory .appending(path: "AsterismConvergedRules-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) let container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) self.init(context: ModelContext(container)) retained = container
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swiftindex 6aadc55..f1a9c00 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift@@ -244,12 +244,12 @@ private final class ScanStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismCrossSiteScan-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }@@ -285,7 +285,6 @@ private final class ScanStore { membership.urlIdentity = identity membership.urlIdentityState = identity == nil ? .none : .rule }- LegacyColumns.refreshMembership(on: work) return work }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteMergeTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteMergeTests.swiftindex c6d72ad..5310934 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteMergeTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteMergeTests.swift@@ -60,7 +60,6 @@ struct CrossSiteMergeTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } // MARK: - Union (Req 4.2, 4.3)@@ -96,7 +95,6 @@ struct CrossSiteMergeTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } /// Req 4.2's shared half: where both Works are on one site, that site's@@ -134,7 +132,6 @@ struct CrossSiteMergeTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } /// Q78: the address a site's merge dropped is a field on the outcome, and@@ -190,7 +187,6 @@ struct CrossSiteMergeTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } // MARK: - The pre-mutation refusal (Req 4.5)@@ -219,7 +215,6 @@ struct CrossSiteMergeTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } // MARK: - Distinct pairs (Req 5.7, Q32)@@ -246,7 +241,6 @@ struct CrossSiteMergeTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DrainedReShareTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DrainedReShareTests.swiftindex 4b9d96f..cdadc57 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DrainedReShareTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DrainedReShareTests.swift@@ -288,7 +288,7 @@ private struct DrainedReShareFixture { entry.modifiedAt = Date(timeIntervalSince1970: 1_660_000_000) if let workTitle { let work = Work(- displayTitle: workTitle, siteHostname: host,+ displayTitle: workTitle, timestamp: Date(timeIntervalSince1970: 1_600_000_000)) context.insert(work) entry.work = work
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swiftindex 38f06e6..935d29f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePropertyTests.swift@@ -455,9 +455,9 @@ enum DuplicateShapes { id: id, key: key, capturedAt: captured, sharedAt: shared, note: note, work: work.flatMap { seededWorks.rows[$0] }, site: site) guard entry.work != nil else { return }- entry.workAssignmentProvenance = .pattern- entry.workPatternID = patternID- entry.workPatternVersion = 1+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: patternID, version: 1))+ } } } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePublicationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePublicationTests.swiftindex 11f4b42..e127e1e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePublicationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicatePublicationTests.swift@@ -559,11 +559,11 @@ private final class PublicationSeedStore { context.insert(entry) if let work { entry.work = work- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .manual } } if let chapterTitle { entry.chapterTitle = chapterTitle- entry.chapterTitleProvenance = .manual+ entry.editCitations { $0.chapterTitle = .manual } } return entry }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcileAfterSyncTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcileAfterSyncTests.swiftindex 574b9c5..5e1155f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcileAfterSyncTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcileAfterSyncTests.swift@@ -290,15 +290,15 @@ extension LibraryRepository { context.insert(site) var works: [Work] = [] for (index, notes) in ["notes from A", "notes from B"].enumerated() {- let work = Work(- displayTitle: "The Serial", siteHostname: hostname,+ // Through `create`, because the scan buckets a Work by its+ // **membership** and the identity has to land on that row.+ let work = Work.create(+ in: context, title: "The Serial", hostname: hostname, site: site, timestamp: Self.probeEpoch.addingTimeInterval(Double(index)))- work.setLegacyColumns { $0.urlIdentity = "series-a" }+ work.primaryMembershipEdit { $0.urlIdentity = "series-a" } work.lastParsedTitle = "The Serial" work.titleProvenance = .parsed work.genericNotes = notes- context.insert(work)- work.setLegacySite(site) works.append(work) } for (index, work) in works.enumerated() {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swiftindex cc2fc1b..ff048e1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift@@ -44,12 +44,12 @@ final class DuplicateStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismDuplicateReconciler-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) seed = ModelContext(container) if let saveStrategy {@@ -139,7 +139,7 @@ final class DuplicateStore { in: seed, id: id, title: title, hostname: hostname, site: site, timestamp: Self.epoch.addingTimeInterval(createdAt)) work.modifiedAt = Self.epoch.addingTimeInterval(modifiedAt ?? createdAt)- work.setLegacyColumns { $0.urlIdentity = urlIdentity }+ work.primaryMembershipEdit { $0.urlIdentity = urlIdentity } work.genericNotes = notes if parsed { work.lastParsedTitle = title@@ -359,8 +359,8 @@ struct EntryFacts: Equatable, Sendable { firstCapturedAt = entry.firstCapturedAt lastSharedAt = entry.lastSharedAt modifiedAt = entry.modifiedAt- chapterPatternID = entry.chapterPatternID- chapterPatternVersion = entry.chapterPatternVersion+ chapterPatternID = entry.citationValues.chapterTitle.patternID+ chapterPatternVersion = entry.citationValues.chapterTitle.patternVersion } } @@ -380,7 +380,7 @@ struct WorkFacts: Equatable, Sendable { id = work.id displayTitle = work.displayTitle genericNotes = work.genericNotes- workURLString = work.legacyColumns.workURLString+ workURLString = work.primaryMembership?.workURLString genreTags = work.genreTags workTypeID = work.workTypeID titleProvenance = work.titleProvenance
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swiftindex 482972a..fca963b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift@@ -77,9 +77,10 @@ struct DuplicateReconcilerTests { try store.addPattern(id: ruleID, site: twinRow, version: 7, active: true, createdAt: 10) let entry = store.addEntry(key: "chapter-1", capturedAt: 0, site: site) entry.chapterTitle = "Chapter 1"- entry.chapterTitleProvenance = .pattern- entry.chapterPatternID = ruleID- entry.chapterPatternVersion = 7+ entry.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: ruleID, patternVersion: 7)+ } try store.commit() let outcome = try store.reconcile()@@ -225,13 +226,9 @@ struct DuplicateReconcilerTests { /// which cites a rule **by UUID only** and carries no version — so the /// membership has nothing to renumber, and nothing here touches it. ///- /// **The retained column is left alone too, since task 21.** It moved with- /// the renumbering while `LibraryValidator`'s `.rule` arm resolved- /// `(urlIdentityRuleID, urlIdentityRuleVersion)` and the 6/7 archive- /// exported the pair; task 8 moved the arm onto the membership and task 21- /// dropped the version from the wire, so there is no reader left for the- /// column and no reason to write it. V9 deletes it.- @Test("Rule renumbering moves neither the Work's retained version nor its membership")+ /// The `Work.urlIdentityRuleVersion` column this also pinned as left alone+ /// went at V9, and so did the half of the assertion that read it.+ @Test("Rule renumbering leaves the Work's membership citation alone") func workCitationColumnFollowsTheRenumbering() throws { let store = try DuplicateStore() let site = store.addSite(displayName: "first", mode: .taught)@@ -242,8 +239,7 @@ struct DuplicateReconcilerTests { let work = store.addWork( id: DuplicateStore.rankedID(2), title: "The Serial", urlIdentity: "series-a", createdAt: 0, site: site)- work.setLegacyColumns { $0.urlIdentityRuleID = ruleID }- work.urlIdentityRuleVersion = 7+ work.primaryMembershipEdit { $0.urlIdentityRuleID = ruleID } try store.commit() let outcome = try store.reconcile()@@ -255,14 +251,11 @@ struct DuplicateReconcilerTests { let works = try context.fetch(FetchDescriptor<Work>()) let versions = Set(try context.fetch(FetchDescriptor<URLRulePattern>()).map(\.version)) return (- columns: works.map(\.urlIdentityRuleVersion), converged: versions, membershipRules: works.flatMap { $0.membershipValues.map(\.urlIdentityRuleID) } ) } #expect(cited.converged.count == 1, "the premise: the group converged on one version")- #expect(cited.columns == [7],- "the retained column has no reader left and is not written") // The membership cites by UUID and carries no version, so renumbering // leaves it exactly as it was. #expect(cited.membershipRules.allSatisfy { $0 == nil || $0 == ruleID })@@ -384,7 +377,7 @@ struct DuplicateReconcilerTests { store.addEntry(id: shared, key: "chapter-1", capturedAt: 0, site: site) let carrier = store.addEntry(id: shared, key: "chapter-1", capturedAt: 0, site: site) carrier.chapterTitle = "The Reader's Title"- carrier.chapterTitleProvenance = .manual+ carrier.editCitations { $0.chapterTitle = .manual } try store.commit() try store.reconcileToFixedPoint()@@ -406,12 +399,14 @@ struct DuplicateReconcilerTests { let otherWork = store.addWork( title: "Other Work", urlIdentity: "series-b", createdAt: 0, site: site) let shared = DuplicateStore.rankedID(1)- let bare = store.addEntry(+ // Bare: no reader-authored assignment. It used to say so as a `.pattern`+ // kind with nothing cited, which the blob normalises to no assignment at+ // all (Q39) — the default a fresh row already carries.+ _ = store.addEntry( id: shared, key: "chapter-1", capturedAt: 0, work: survivorWork, site: site)- bare.workAssignmentProvenance = .pattern let authored = store.addEntry( id: shared, key: "chapter-1", capturedAt: 0, note: "note", work: otherWork, site: site)- authored.workAssignmentProvenance = .manual+ authored.editCitations { $0.workAssignment = .manual } try store.commit() try store.reconcileToFixedPoint()@@ -441,7 +436,7 @@ struct DuplicateReconcilerTests { let moved = store.addEntry( key: "chapter-2", capturedAt: 5, sharedAt: 7, note: "chapter note", rating: .down, work: loser, site: site)- moved.workAssignmentProvenance = FieldProvenanceKind.manual+ moved.editCitations { $0.workAssignment = .manual } store.addEntry(key: "chapter-1", capturedAt: 1, work: survivor, site: site) try store.commit()
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swiftindex 6635f71..bcddaa6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift@@ -424,18 +424,21 @@ struct DuplicateResolutionTests { displayTitle: "T", titleProvenance: .parsed, hostname: "one.example", workURLString: nil, genericNotes: "", genreTags: [], typeDisplay: .untyped) + // Every side here is on one site, so the fold's answer is that+ // hostname's entry — the single-site `workURL` facade went with the+ // columns at V9. let adopted = WorkVariantUnion.fold(into: bare, others: [withURL])- #expect(adopted.workURL == "https://a.example")+ #expect(adopted.workURLsByHostname == ["one.example": "https://a.example"]) #expect(adopted.retainedFields.contains(.sourceWorkURL)) #expect(adopted.discardedFields.isEmpty) let kept = WorkVariantUnion.fold(into: withURL, others: [withOther])- #expect(kept.workURL == "https://a.example")+ #expect(kept.workURLsByHostname == ["one.example": "https://a.example"]) #expect(kept.discardedFields.contains(.sourceWorkURL)) #expect(kept.auditBlock?.contains("https://b.example") == true) let nothing = WorkVariantUnion.fold(into: bare, others: [bare])- #expect(nothing.workURL == nil)+ #expect(nothing.workURLsByHostname.isEmpty) #expect(nothing.auditBlocks.isEmpty) } @@ -655,7 +658,6 @@ private final class ResolutionFixture { // Q59's standing guard, on the seam every read-back in this suite goes // through: a resolution that collapsed a set must leave every surviving // Work's superseded columns mirroring its primary membership.- try assertWorkColumnsMirrorMemberships(in: context, sourceLocation: sourceLocation) return context } @@ -746,7 +748,7 @@ private final class ResolutionSeedStore { context.insert(entry) if let work { entry.work = work- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .manual } } return entry }@@ -782,7 +784,7 @@ private final class ResolutionSeedStore { work.titleProvenance = .parsed work.genericNotes = notes work.genreTags = tags- work.setLegacyColumns { $0.workURLString = workURL }+ work.primaryMembershipEdit { $0.workURLString = workURL } work.membershipValues.first?.workURLString = workURL work.workTypeID = ResolutionSeedStore.typeID(for: type) work.modifiedAt = ResolutionFixture.epoch.addingTimeInterval(offset)@@ -872,7 +874,6 @@ private final class AssignmentFixture { // Q59's standing guard, on the seam every read-back in this suite goes // through: a resolution that collapsed a set must leave every surviving // Work's superseded columns mirroring its primary membership.- try assertWorkColumnsMirrorMemberships(in: context, sourceLocation: sourceLocation) return context } @@ -926,7 +927,7 @@ private final class AssignmentSeedStore { entry.lastSharedAt = AssignmentFixture.epoch.addingTimeInterval(offset) context.insert(entry) entry.work = work- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .manual } return entry } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swiftindex 9984aa4..4dcee89 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift@@ -350,10 +350,10 @@ struct DuplicateScanTests { let shared = UUID() let first = store.addEntry(id: shared, key: "chapter-1", offset: 0) first.work = leftWork- first.workAssignmentProvenance = .manual+ first.editCitations { $0.workAssignment = .manual } let second = store.addEntry(id: shared, key: "chapter-1", offset: 60) second.work = rightWork- second.workAssignmentProvenance = .manual+ second.editCitations { $0.workAssignment = .manual } try store.commit() let result = try store.scan()@@ -374,10 +374,10 @@ struct DuplicateScanTests { let shared = UUID() let first = store.addEntry(id: shared, key: "chapter-1", offset: 0) first.work = leftWork- first.workAssignmentProvenance = .manual+ first.editCitations { $0.workAssignment = .manual } let second = store.addEntry(id: shared, key: "chapter-1", offset: 60) second.work = rightWork- second.workAssignmentProvenance = .manual+ second.editCitations { $0.workAssignment = .manual } try store.commit() let result = try store.scan()@@ -516,12 +516,12 @@ private final class ScanStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismDuplicateScan-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }@@ -562,8 +562,8 @@ private final class ScanStore { site: try? LibraryRepository.fetchSites(hostname: hostname, context: context).first, timestamp: Self.epoch.addingTimeInterval(offset)) work.lastParsedTitle = parsedTitle- work.setLegacyColumns { $0.urlIdentity = urlIdentity }- if urlIdentity != nil { work.setLegacyColumns { $0.urlIdentityState = .rule } }+ work.primaryMembershipEdit { $0.urlIdentity = urlIdentity }+ if urlIdentity != nil { work.primaryMembershipEdit { $0.urlIdentityState = .rule } } if let membership = work.membershipValues.first { membership.urlIdentity = urlIdentity membership.urlIdentityState = urlIdentity == nil ? .none : .rule
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryCitationsTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryCitationsTests.swiftindex 45cba83..4fa3fa5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryCitationsTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryCitationsTests.swift@@ -4,15 +4,15 @@ import Testing @testable import AsterismCore -/// The two V8 blobs and the legacy-column readers that feed them (Q25, Q36).+/// The two blobs a citation and a rule definition live in (Q25, Q36). ///-/// **The seven-citation projection is pinned against the columns, not against-/// itself.** `EntryRuleCitations`'s key-path table is gone; the expectation-/// below is the same seven `(id, version, target, label)` rows read straight off-/// the columns, written out by hand so a projection that quietly reorders,-/// relabels or drops a slot fails here rather than in an archive refusal three-/// subsystems away.-@Suite("V8 citation and definition blobs", .serialized)+/// **The seven-citation projection is pinned against a written-out expectation,+/// not against itself.** `EntryRuleCitations`'s key-path table is gone, and so+/// are the seventeen columns this suite used to read the same seven+/// `(id, version, target, label)` rows off; the expectation is spelt by hand so+/// a projection that quietly reorders, relabels or drops a slot fails here+/// rather than in an archive refusal three subsystems away.+@Suite("Citation and definition blobs", .serialized) struct EntryCitationsTests { // MARK: - Fixtures@@ -32,36 +32,10 @@ struct EntryCitationsTests { timestamp: Date(timeIntervalSince1970: 1_800_000_000)) } - /// The seven rows, read directly off the columns — the retired key-path- /// table's answer, restated once so the projection has something to be- /// measured against.- ///- /// Two of the seven are **arm-scoped**, and that is the whole of V8's- /// normalisation (Q39): the stored key version picks the identity arm and- /// the stored provenance kind picks the assignment arm, and a citation- /// belonging to an arm the row did not choose is dropped. Only a partial- /// write produces one — the validator rejects every such shape — and the- /// columns still hold it until V9, so the blob normalises rather than- /// inventing a case per mismatch.- private static func expectedRows(of entry: Entry) -> [(UUID?, Int?, String)] {- // `.rule`/`.composed` carry a **non-optional** cited URL rule, so a row- // claiming v2 or v3 without one is the raw-URL basis and cites nothing —- // its name contributor goes with it.- let identityArm = entry.identityKeyVersion >= 2 && entry.identityURLRuleID != nil- let patternArm = entry.workAssignmentProvenanceRaw == FieldProvenanceKind.pattern.rawValue- return [- (identityArm ? entry.identityURLRuleID : nil,- identityArm ? entry.identityURLRuleVersion : nil, "its identity rule"),- (identityArm ? entry.identityNameTitleRuleID : nil,- identityArm ? entry.identityNameTitleRuleVersion : nil, "its naming title rule"),- (entry.urlWorkRuleID, entry.urlWorkRuleVersion, "its work-extraction rule"),- (entry.chapterSequenceRuleID, entry.chapterSequenceRuleVersion, "its sequence rule"),- (entry.chapterPatternID, entry.chapterPatternVersion, "its chapter rule"),- (patternArm ? entry.workPatternID : nil,- patternArm ? entry.workPatternVersion : nil, "its work rule"),- (entry.workURLRuleID, entry.workURLRuleVersion, "its work URL rule"),- ]- }+ private static let expectedLabels = [+ "its identity rule", "its naming title rule", "its work-extraction rule",+ "its sequence rule", "its chapter rule", "its work rule", "its work URL rule",+ ] private static let expectedTargets: [Entry.RuleCitation.Target] = [ .urlRule, .titlePattern, .urlRule, .urlRule, .titlePattern, .titlePattern, .urlRule,@@ -69,196 +43,99 @@ struct EntryCitationsTests { // MARK: - Req 10.2: the citation blob - /// Every identity generation × every assignment provenance × both- /// nil/non-nil states of the optional slots × **both the well-formed and the- /// mismatched spelling of each arm**. The columns are read through- /// `LegacyColumns.citations`, projected to the seven rows, and compared to- /// what `expectedRows` says the normalisation owes.+ /// The seven `(id, version, target, label)` rows, written out by hand so a+ /// projection that quietly reorders, relabels or drops a slot fails here+ /// rather than in an archive refusal three subsystems away. ///- /// The `mismatched` axis is the one the first draft of this matrix never- /// reached: it sets *every* work citation column whatever the provenance- /// kind says, and drops the cited URL rule out from under a v2/v3 key- /// version. Those are the shapes a partial write produces, and the point of- /// running them is that all of them normalise by one rule rather than four- /// arms behaving four ways.- @Test(- "The seven citation rows project from the columns under one normalisation",- arguments: [1, 2, 3],- [FieldProvenanceKind.none, .pattern, .urlRule, .manual])- func sevenRowsMatchTheColumns(keyVersion: Int, assignment: FieldProvenanceKind) throws {- for populated in [false, true] {- for mismatched in [false, true] {- let entry = Self.makeEntry()- entry.identityKeyVersion = keyVersion- // Mismatched: a rule-basis key version with no cited URL rule,- // which the identity cases cannot represent.- if keyVersion > 1, !mismatched {- entry.identityURLRuleID = Self.ruleA- entry.identityURLRuleVersion = 4- }- if keyVersion > 1 {- entry.identityNameTitleRuleID = Self.patternA- entry.identityNameTitleRuleVersion = 2- }- if populated {- entry.urlWorkRuleID = Self.ruleB- entry.urlWorkRuleVersion = 5- entry.chapterSequenceRuleID = Self.ruleB- entry.chapterSequenceRuleVersion = 5- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = Self.patternB- entry.chapterPatternVersion = 3- entry.workURLAssignmentKindRaw = URLWorkAssignmentKind.identity.rawValue- }- entry.workAssignmentProvenanceRaw = assignment.rawValue- // Mismatched: both work citations present whatever the kind is.- if mismatched || assignment == .pattern {- entry.workPatternID = Self.patternA- entry.workPatternVersion = 7- }- if mismatched || assignment == .urlRule {- entry.workURLRuleID = Self.ruleA- entry.workURLRuleVersion = 9- }-- let label = "populated=\(populated) mismatched=\(mismatched)"- let citations = LegacyColumns.citations(from: entry)- let rows = citations.ruleCitations- let expected = Self.expectedRows(of: entry)- #expect(rows.count == expected.count)- for (index, row) in rows.enumerated() {- #expect(row.id == expected[index].0, "row \(index) id, \(label)")- #expect(row.version == expected[index].1, "row \(index) version, \(label)")- #expect(row.label == expected[index].2, "row \(index) label")- #expect(row.target == Self.expectedTargets[index], "row \(index) target")- }- // The case *is* the provenance now, so the value writes back the- // kind it chose rather than the one the columns disagreed on.- #expect(citations.workAssignment.provenanceKind- == (citations.workAssignment == .none ? .none : assignment),- "assignment kind, \(label)")- // The blob is what the Entry answers with once it holds one, and- // it answers the same rows.- try entry.setCitations(citations)- #expect(try entry.ruleCitations.map(\.id) == rows.map(\.id))- // Normalisation is a fixed point: re-reading the blob and- // re-projecting produces the same value, so the population pass- // converges on the second run rather than oscillating.- #expect(try entry.citations == citations, "round trip, \(label)")- }- }- }-- /// The identity **case** replaces the `identityKeyVersion` switch (Q26), and- /// carries the same integer back.- @Test("Identity cases and key versions correspond", arguments: [1, 2, 3, 0, 9])- func identityCases(keyVersion: Int) throws {+ /// It used to be pinned against the seventeen Entry columns the blob+ /// superseded, over a matrix of every identity generation × every assignment+ /// provenance × the well-formed and mismatched spelling of each arm. V9+ /// dropped the columns, and with them every mismatched spelling: `Identity`+ /// carries a **non-optional** cited URL rule and `WorkAssignment` carries its+ /// own citation inside the case (Q39), so an arm without its rule is a value+ /// that cannot be constructed rather than one that has to be normalised.+ @Test("The seven citation rows project in a fixed order, with fixed labels and targets")+ func sevenRowsProjectInOrder() throws {+ let citations = EntryCitations(+ identity: .composed(+ url: CitedRule(id: Self.ruleA, version: 4),+ nameTitle: CitedRule(id: Self.patternA, version: 2)),+ urlWork: CitedRule(id: Self.ruleB, version: 5),+ chapterSequence: CitedRule(id: Self.ruleB, version: 5),+ chapterTitle: FieldProvenance.tolerant(+ kind: .pattern, patternID: Self.patternB, patternVersion: 3),+ workAssignment: .pattern(CitedRule(id: Self.patternA, version: 7)),+ workURL: CitedRule(id: Self.ruleA, version: 9),+ workURLAssignmentKind: .identity)++ let rows = citations.ruleCitations+ #expect(rows.map(\.label) == Self.expectedLabels)+ #expect(rows.map(\.target) == Self.expectedTargets)+ #expect(+ rows.map(\.id) == [+ Self.ruleA, Self.patternA, Self.ruleB, Self.ruleB, Self.patternB,+ Self.patternA, Self.ruleA,+ ])+ #expect(rows.compactMap(\.version) == [4, 2, 5, 5, 3, 7, 9])++ // The blob is what the Entry answers with, and it answers the same rows. let entry = Self.makeEntry()- entry.identityKeyVersion = keyVersion- entry.identityURLRuleID = Self.ruleA- entry.identityURLRuleVersion = 4- let identity = LegacyColumns.citations(from: entry).identity- switch keyVersion {- case 2:- #expect(identity == .rule(url: CitedRule(id: Self.ruleA, version: 4), nameTitle: nil))- case 3:- #expect(- identity == .composed(url: CitedRule(id: Self.ruleA, version: 4), nameTitle: nil))- default:- // A version this build has no case for reads as the conservative- // basis rather than throwing, the tolerance every enum column has.- #expect(identity == .rawURL)- }- #expect(identity.keyVersion == [2: 2, 3: 3][keyVersion, default: 1])+ try entry.setCitations(citations)+ #expect(try entry.citations == citations)+ #expect(try entry.ruleCitations.map(\.id) == rows.map(\.id)) } - /// An identity generation whose rule citation is absent is not a- /// `.rule`/`.composed`: the case carries a non-optional citation, so a row- /// that has lost it reads as the conservative basis.- @Test("A rule-basis version with no cited rule reads as the raw-URL basis")- func identityWithoutARule() throws {- let entry = Self.makeEntry()- entry.identityKeyVersion = 3- #expect(LegacyColumns.citations(from: entry).identity == .rawURL)+ /// An empty value cites nothing in all seven slots, which is what a row born+ /// today holds until a writer gives it something.+ @Test("The default value cites nothing in every slot")+ func defaultCitesNothing() {+ let rows = EntryCitations().ruleCitations+ #expect(rows.count == Self.expectedLabels.count)+ #expect(rows.allSatisfy { $0.id == nil && $0.version == nil }) } - /// Q39, stated once as a table: the stored kind picks the arm, and a- /// citation belonging to any other arm is dropped rather than smuggled into- /// the case. Four rows, one rule.- ///- /// The alternative was a payload that let *one* of these round-trip — a work- /// pattern under a disagreeing provenance — while the other three still- /// normalised silently. Every one of them is a shape `LibraryValidator`- /// rejects, the columns still hold the dropped citation until V9, and one- /// rule that always holds beats one exception that mostly does not.- @Test(- "The stored kind picks the arm and foreign citations are dropped",- arguments: [- (FieldProvenanceKind.none, EntryCitations.WorkAssignment.none),- (.manual, .manual),- (.pattern, .pattern(CitedRule(id: patternA, version: 7))),- (.urlRule, .urlRule(CitedRule(id: ruleA, version: 9))),- ])- func assignmentNormalisation(- kind: FieldProvenanceKind, expected: EntryCitations.WorkAssignment- ) throws {- let entry = Self.makeEntry()- entry.workAssignmentProvenanceRaw = kind.rawValue- // Both citations present, whatever the kind says.- entry.workPatternID = Self.patternA- entry.workPatternVersion = 7- entry.workURLRuleID = Self.ruleA- entry.workURLRuleVersion = 9-- let citations = LegacyColumns.citations(from: entry) - #expect(citations.workAssignment == expected)- // `workURL` is its own slot and is read from its own column either way,- // so dropping the *assignment's* copy loses no citation the row holds.- #expect(citations.workURL == CitedRule(id: Self.ruleA, version: 9))+ /// The identity **case** replaces the `identityKeyVersion` switch (Q26), and+ /// carries the same integer back.+ ///+ /// It was driven from the column, over the out-of-range spellings a newer+ /// build could have written (0, 9) as well as the three this build has. V9+ /// dropped the column: the case is the only spelling of the basis now, so+ /// what is left to pin is the integer each one writes back for the+ /// archive's reverse projection.+ @Test("Identity cases carry their key version back")+ func identityCases() {+ let url = CitedRule(id: Self.ruleA, version: 4)+ #expect(EntryCitations.Identity.rawURL.keyVersion == 1)+ #expect(EntryCitations.Identity.rule(url: url, nameTitle: nil).keyVersion == 2)+ #expect(EntryCitations.Identity.composed(url: url, nameTitle: nil).keyVersion == 3) } - /// The identity half of the same rule: the stored key version picks the arm,- /// and an arm with no cited URL rule degrades to `.rawURL` — taking its name- /// contributor with it, because a name contributor without the rule that- /// produced the key cites nothing replayable.- @Test("A rule-basis version with a name contributor but no URL rule degrades whole")- func identityNormalisation() throws {- let entry = Self.makeEntry()- entry.identityKeyVersion = 3- entry.identityNameTitleRuleID = Self.patternA- entry.identityNameTitleRuleVersion = 2-- let citations = LegacyColumns.citations(from: entry)-- #expect(citations.identity == .rawURL)- #expect(citations.ruleCitations[1].id == nil)- }+ // `identityWithoutARule`, `assignmentNormalisation` and+ // `identityNormalisation` stood here. All three drove+ // `LegacyColumns.citations`, which normalised the column shapes a partial+ // write could produce: a v2/v3 key version with no cited URL rule, a work+ // pattern cited under `.manual`, a name contributor with no rule beside it.+ // V9 dropped every column that could spell one, and `EntryCitations` cannot:+ // `Identity` carries a non-optional cited rule and `WorkAssignment` carries+ // its citation inside the case (Q39), so there is no mismatched value left+ // to normalise. /// Rule renumbering rewrites versions and nothing else, and reports "no /// change" when the map names no version the row cites. @Test("Version rewriting touches every cited slot and only the versions") func versionRewrite() throws {- let entry = Self.makeEntry()- entry.identityKeyVersion = 3- entry.identityURLRuleID = Self.ruleA- entry.identityURLRuleVersion = 1- entry.identityNameTitleRuleID = Self.patternA- entry.identityNameTitleRuleVersion = 1- entry.urlWorkRuleID = Self.ruleA- entry.urlWorkRuleVersion = 1- entry.chapterSequenceRuleID = Self.ruleA- entry.chapterSequenceRuleVersion = 1- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = Self.patternA- entry.chapterPatternVersion = 1- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.workPatternID = Self.patternA- entry.workPatternVersion = 1- entry.workURLRuleID = Self.ruleA- entry.workURLRuleVersion = 1+ let citations = EntryCitations(+ identity: .composed(+ url: CitedRule(id: Self.ruleA, version: 1),+ nameTitle: CitedRule(id: Self.patternA, version: 1)),+ urlWork: CitedRule(id: Self.ruleA, version: 1),+ chapterSequence: CitedRule(id: Self.ruleA, version: 1),+ chapterTitle: FieldProvenance.tolerant(+ kind: .pattern, patternID: Self.patternA, patternVersion: 1),+ workAssignment: .pattern(CitedRule(id: Self.patternA, version: 1)),+ workURL: CitedRule(id: Self.ruleA, version: 1)) - let citations = LegacyColumns.citations(from: entry) #expect(citations.rewritingVersions([:]) == nil) #expect(citations.rewritingVersions([Self.ruleB: 9]) == nil) let rewritten = try #require(citations.rewritingVersions([Self.ruleA: 6, Self.patternA: 8]))@@ -266,6 +143,7 @@ struct EntryCitationsTests { #expect(rewritten.ruleCitations.map(\.id) == citations.ruleCitations.map(\.id)) } + // MARK: - Req 10.1: the definition blob private static let definitions: [PatternDefinition] = [@@ -279,10 +157,14 @@ struct EntryCitationsTests { .wholeTitle, ] - /// Every arm, with and without trims: the ten columns read back as the same- /// `StoredPatternDefinition` a freshly written blob holds, and `chapterless`- /// and `form` are derived rather than stored.- @Test("Every pattern arm round-trips through the columns", arguments: definitions)+ /// Every arm, with and without trims, round-trips through the blob, and+ /// `chapterless` and `form` are derived rather than stored.+ ///+ /// It used to check the same value against the ten decomposed columns too,+ /// through the one door that read them. V9 dropped the columns, so the blob+ /// is the whole of a definition and a row with none throws — which is what+ /// `nilDefinitionThrows` below pins.+ @Test("Every pattern arm round-trips through the blob", arguments: definitions) func definitionRoundTrip(definition: PatternDefinition) throws { for trims in [(nil, nil), ("Read: ", " | Site")] as [(String?, String?)] { let written = try TitlePattern(@@ -294,58 +176,25 @@ struct EntryCitationsTests { #expect(try written.definition == definition) #expect(try written.storedDefinition.chapterless == !definition.producesChapter) #expect(try written.storedDefinition.form == definition.form)-- // The same value spelled in the ten legacy columns, read back by the- // fallback: this is what `V8PopulationPass` copies.- let legacy = Self.legacyPattern(definition: definition, trims: trims)- #expect(legacy.definitionData == nil)- #expect(try legacy.storedDefinition == expected)- #expect(try legacy.definition == definition) } } - /// A pattern row in the V7 shape: the ten columns set, the blob nil.- private static func legacyPattern(- definition: PatternDefinition, trims: (String?, String?)- ) -> TitlePattern {- // Built through the writing initializer, then reduced to the V7 shape by- // clearing the blob and spelling the arm out in the columns.- let pattern = try! TitlePattern(- version: 1, createdAt: Date(timeIntervalSince1970: 0), definition: .wholeTitle)- pattern.definitionData = nil- pattern.formRaw = definition.form.rawValue- pattern.chapterless = !definition.producesChapter- pattern.trimPrefix = trims.0- pattern.trimSuffix = trims.1- switch definition {- case .segment(let work, let ignored), .chapterlessSegment(let work, let ignored):- pattern.segmentWorkAnchor = work- pattern.segmentIgnoredAnchors = ignored- case .phrase(let prefix, let separator, let suffix, let order),- .chapterlessPhrase(let prefix, let separator, let suffix, let order):- pattern.phrasePrefix = prefix- pattern.phraseSeparator = separator- pattern.phraseSuffix = suffix- pattern.fieldOrderRaw = order.rawValue- case .wholeTitle:- break- }- return pattern- }-- /// A column combination that is not a legal arm throws rather than- /// substituting a rule the reader never taught (Decision 5 of- /// `library-integrity-tolerance`).- @Test("An illegal column combination throws instead of substituting")- func illegalColumns() throws {+ /// A pattern with no blob throws rather than substituting a rule the reader+ /// never taught (Decision 5 of `library-integrity-tolerance`).+ ///+ /// It used to fall back to the ten decomposed columns, and the case pinned+ /// here was a *column combination* that formed no legal arm. V9 dropped+ /// them: there is nothing behind the blob, so a nil one is the whole of the+ /// unreadable state and `GroupOrdering` gives it its own constant key (Q11).+ @Test("A pattern with no definition blob throws instead of substituting")+ func nilDefinitionThrows() throws { let pattern = try TitlePattern( version: 1, createdAt: Date(timeIntervalSince1970: 0), definition: .wholeTitle) pattern.definitionData = nil- pattern.formRaw = PatternForm.segment.rawValue- pattern.segmentWorkAnchor = nil #expect(throws: ModelInvariantError.self) { _ = try pattern.storedDefinition } } + // MARK: - Blob failure modes @Test("Undecodable citation bytes throw rather than reading as no citations")@@ -395,17 +244,32 @@ struct EntryCitationsTests { #expect(pattern.definitionData == bytes) } - /// Nil bytes fall back to the columns rather than reading as empty (Q36):- /// this is what keeps a row a lagging V7 device syncs in after the migration- /// readable (Req 2.7).- @Test("A nil blob reads through the legacy columns")- func nilBlobFallsBack() throws {+ /// The constant `Entry.init` seeds every new row with decodes back to the+ /// value it was encoded from.+ ///+ /// This is what licenses the `try!` behind `EntryCitations.defaultBlob`: the+ /// encode has no runtime input, so if it can ever fail it fails here.+ @Test("The default citation blob decodes back to the default value")+ func defaultBlobDecodes() throws {+ #expect(+ try JSONBlob.decode(EntryCitations.self, from: EntryCitations.defaultBlob)+ == EntryCitations())+ }++ /// Nil bytes read as the default — identity `.rawURL`, nothing cited (Q6).+ ///+ /// They used to fall back to the columns, which is what kept a row a lagging+ /// V7 device synced in readable (Req 2.7). Since V9 the blob is a citation's+ /// only home: a nil one means a row this build has not rewritten, the+ /// diagnosis counts it (a report, not a quarantine — Q25), and an ordinary+ /// read answers rather than failing whichever screen asked first.+ @Test("A nil blob reads as the default")+ func nilBlobReadsAsTheDefault() throws { let entry = Self.makeEntry()- entry.identityKeyVersion = 2- entry.identityURLRuleID = Self.ruleA- entry.identityURLRuleVersion = 4- #expect(entry.citationsData == nil)- #expect(try entry.citations == LegacyColumns.citations(from: entry))- #expect(try entry.ruleCitations.first?.id == Self.ruleA)+ entry.citationsData = nil++ #expect(try entry.citations == EntryCitations())+ #expect(try entry.citations.identity == .rawURL)+ #expect(try entry.ruleCitations.allSatisfy { $0.id == nil }) } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swiftindex e184846..7db3669 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift@@ -153,9 +153,9 @@ struct EntryDetailAndMergeToleranceTests { let entry = store.insertEntry( id: entryID, hostname: "dup.example", title: "A Cited Work - Chapter 3")- entry.workAssignmentProvenance = .pattern- entry.workPatternID = losingPatternID- entry.workPatternVersion = 1+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: losingPatternID, version: 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@@ -192,10 +192,10 @@ struct EntryDetailAndMergeToleranceTests { 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+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: patternID, version: 2))+ } } let repository = try await library.openForApp() @@ -243,9 +243,9 @@ struct EntryDetailAndMergeToleranceTests { let entry = store.insertEntry( id: entryID, hostname: "dup.example", title: "A Cited Work - Chapter 3")- entry.workAssignmentProvenance = .pattern- entry.workPatternID = losingPatternID- entry.workPatternVersion = 1+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: losingPatternID, version: 1))+ } } try library.mutate { context in let sites = try context.fetch(FetchDescriptor<Site>())@@ -426,8 +426,7 @@ struct EntryDetailAndMergeToleranceTests { let entry = store.insertEntry( hostname: "dup.example", title: "chapter", url: "https://dup.example/read/1") entry.work = work- entry.workAssignmentProvenance = .manual-+ entry.editCitations { $0.workAssignment = .manual } store.insertWork(id: duplicatedWorkID, hostname: "dup.example", title: "Twin A") store.insertWork( id: duplicatedWorkID, hostname: "dup.example", title: "Twin B", offset: 90)@@ -450,7 +449,7 @@ struct EntryDetailAndMergeToleranceTests { let twins = try library.readContext().fetch(FetchDescriptor<Work>()) .filter { $0.id == duplicatedWorkID } #expect(twins.count == 2)- #expect(twins.allSatisfy { $0.legacyColumns.workURLString == nil })+ #expect(twins.allSatisfy { $0.primaryMembership?.workURLString == nil }) } /// The Work URL basis derives its current rule from the Work's Site. A Work@@ -468,7 +467,7 @@ struct EntryDetailAndMergeToleranceTests { hostname: "orphan.example", title: "chapter", url: "https://orphan.example/read/1") entry.work = work- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .manual } } let repository = try await library.openForApp() @@ -509,7 +508,7 @@ struct EntryDetailAndMergeToleranceTests { let entry = store.insertEntry( hostname: "dup.example", title: "chapter", url: "https://dup.example/read/1") entry.work = work- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .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).@@ -518,9 +517,8 @@ struct EntryDetailAndMergeToleranceTests { let loser = try #require(sites.first { $0.displayName == "lose-row" }) let work = try #require(try context.fetch(FetchDescriptor<Work>()) .first { $0.id == workID })- work.setLegacySite(loser)- // The membership is what the Work URL basis reads its site from now- // (Req 3.6); the column beside it is the V9 mirror.+ // The membership is the only place a Work's site lives (Req 3.6);+ // the mirrored column beside it went at V9. work.membershipValues.first?.site = loser } let repository = try await library.openForApp()@@ -715,7 +713,7 @@ private final class ToleranceSeedStore { for (work, title) in [(source, "Source Chapter"), (target, "Target Chapter")] { let entry = insertEntry(hostname: hostname, title: title) entry.work = work- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .manual } } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swiftindex 6c8a646..ee9367e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift@@ -43,8 +43,6 @@ struct EnumTolerancePolicyTests { url: "https://\(Self.host)/read/1") entry.captureTitleSourceRaw = "telepathy" entry.ratingRaw = "sideways"- entry.chapterTitleProvenanceRaw = "divination"- entry.workAssignmentProvenanceRaw = "divination" entry.work = work } let repository = try await library.openForApp()@@ -63,45 +61,13 @@ struct EnumTolerancePolicyTests { #expect(entry.rating == nil) } - /// The combination the coercion itself could manufacture. A newer build's- /// provenance kind cites a pattern id and version; read here the kind- /// coerces to `.none`, which carries neither, and `FieldProvenance`'s- /// combination check would have refused the pair — failing the whole- /// snapshot on precisely the row Q2 exists to keep readable. The citation- /// travels with the kind it belonged to, so the row presents as uncited.- @Test("An unknown provenance raw drops the citation rather than failing the snapshot")- func unknownProvenanceRawDropsTheCitation() async throws {- let library = try WriteFixture()- let workID = UUID()- let entryID = UUID()- try library.seed { store in- store.insertSite(hostname: Self.host)- let work = store.insertWork(- id: workID, hostname: Self.host, title: "A Serial", offset: 0)- let entry = store.insertEntry(- id: entryID, hostname: Self.host, title: "Chapter 1", offset: 10,- url: "https://\(Self.host)/read/1")- entry.chapterTitleProvenanceRaw = "divination"- entry.chapterPatternID = UUID()- entry.chapterPatternVersion = 3- entry.workAssignmentProvenanceRaw = "divination"- entry.workPatternID = UUID()- entry.workPatternVersion = 3- entry.work = work- }- let repository = try await library.openForApp()-- let snapshot = try await repository.works()-- let work = try #require(snapshot.works.first { $0.id == workID })- let entry = try #require(work.entries.first { $0.id == entryID })- #expect(entry.chapterTitleProvenance.kind == .none)- #expect(entry.chapterTitleProvenance.patternID == nil)- #expect(entry.chapterTitleProvenance.patternVersion == nil)- #expect(entry.workAssignmentProvenance.kind == .none)- #expect(entry.workAssignmentProvenance.patternID == nil)- #expect(entry.workAssignmentProvenance.patternVersion == nil)- }+ // `unknownProvenanceRawDropsTheCitation` stood here: an Entry whose+ // provenance raw a newer build spelt, with a pattern citation beside it, had+ // to read as uncited rather than fail the whole snapshot. Both halves of+ // that shape were Entry columns V9 dropped — `EntryCitations` types each arm+ // and stores the citation *inside* it (Q39 of `multi-site-works`), so a kind+ // without its rule is no longer spellable and the coercion has nothing left+ // to manufacture. // MARK: - Merge-basis building @@ -144,7 +110,7 @@ struct EnumTolerancePolicyTests { store.insertSite(hostname: Self.host) let source = store.insertWork( id: sourceID, hostname: Self.host, title: "Source", offset: 0)- source.setLegacyIdentityStateRaw("quantum")+ source.primaryMembershipEdit { $0.urlIdentityStateRaw = "quantum" } store.insertWork(id: targetID, hostname: Self.host, title: "Target", offset: 20) let entry = store.insertEntry( hostname: Self.host, title: "Chapter 1", offset: 10,@@ -168,7 +134,7 @@ struct EnumTolerancePolicyTests { store.insertSite(hostname: Self.host) let work = store.insertWork( id: workID, hostname: Self.host, title: "A Serial", offset: 0)- work.setLegacyIdentityStateRaw("quantum")+ work.primaryMembershipEdit { $0.urlIdentityStateRaw = "quantum" } } let repository = try await library.openForApp() @@ -186,9 +152,9 @@ struct EnumTolerancePolicyTests { /// otherwise carry `.manual` where the store holds "hologram", which is the /// data loss the tolerance is *not* allowed to cause. ///- /// `Work.typeRaw` used to be the one exempt column (Q16). V8 stops reading- /// it altogether (Req 10.3 of `multi-site-works`), so there is no exemption- /// left: the column is retained, unread and never archived.+ /// `Work.typeRaw` used to be the one exempt column (Q16). V8 stopped+ /// reading it (Req 10.3 of `multi-site-works`) and V9 dropped it, so there+ /// is no exemption left and nothing to archive. @Test("A tolerated value still refuses the export rather than being archived as the default") func toleratedValueStillRefusesTheExport() async throws { let library = try WriteFixture()@@ -219,56 +185,40 @@ struct EnumTolerancePolicyTests { } } - /// The Entry's three retained provenance columns, and the reason Q53 does- /// not reach the export.+ /// The Entry's citations, and the reason Q53 does not reach the export. /// /// Q53 retired the *validator's* guards, where `EntryCitations` types the- /// kind and the refused arm is unreachable. The export still reads the- /// columns: an Entry whose `citationsData` is nil falls back to- /// `LegacyColumns.citations` (Q36), which reads all three through- /// `ToleratedEnum.read(default: .none)` — so without a guard here an- /// unrecognised spelling a newer build wrote is archived as `.none`, and the- /// reader's provenance is lost inside a backup.- @Test("An unknown entry provenance raw refuses the export rather than archiving as none")- func toleratedEntryProvenanceStillRefusesTheExport() async throws {- for field in ["chapter provenance", "work assignment provenance", "work URL assignment"] {- let library = try WriteFixture()- try library.seed { store in- store.insertSite(hostname: Self.host)- let work = store.insertWork(- id: UUID(), hostname: Self.host, title: "A Serial", offset: 0)- let entry = store.insertEntry(- hostname: Self.host, title: "Chapter 1", offset: 10,- url: "https://\(Self.host)/read/1")- entry.work = work- // The blob is what the wire carries; the columns are what a- // nil-blob row is read through, and what these guards answer for.- entry.citationsData = nil- switch field {- case "chapter provenance":- entry.chapterTitleProvenanceRaw = "divination"- case "work assignment provenance":- entry.workAssignmentProvenanceRaw = "divination"- default:- entry.workURLAssignmentKindRaw = "telekinesis"- }- }- let repository = try await library.openForApp()-- // The premise: this row still reads.- #expect(try await !repository.works().works.isEmpty)+ /// kind and the refused arm is unreachable. The export needs one anyway, for+ /// the same reason Q8 gives: it must never archive a coerced value. Until V9+ /// the guard was three raw provenance columns, because a nil-blob row was+ /// read through them and `ToleratedEnum.read(default: .none)` would have+ /// turned a newer build's spelling into `.none` inside a backup. The blob is+ /// the only home now, so the guard is the blob's — and it is stricter, since+ /// a kind this build has no case for does not decode at all.+ @Test("An unreadable citation blob refuses the export rather than archiving as nothing")+ func unreadableCitationsStillRefuseTheExport() async throws {+ let library = try WriteFixture()+ try library.seed { store in+ store.insertSite(hostname: Self.host)+ let work = store.insertWork(+ id: UUID(), hostname: Self.host, title: "A Serial", offset: 0)+ let entry = store.insertEntry(+ hostname: Self.host, title: "Chapter 1", offset: 10,+ url: "https://\(Self.host)/read/1")+ entry.work = work+ entry.citationsData = Data("not json".utf8)+ }+ let repository = try await library.openForApp() - do {- _ = try await repository.backupV7Snapshot()- Issue.record("the export archived an unrepresentable \(field)")- } catch let error as BackupV7ExportError {- guard case .unrepresentableValue(_, let refused, let value) = error else {- Issue.record("expected .unrepresentableValue, got \(error)")- return- }- #expect(refused == field)- #expect(value == (field == "work URL assignment" ? "telekinesis" : "divination"))+ do {+ _ = try await repository.backupV7Snapshot()+ Issue.record("the export archived an unreadable citation blob")+ } catch let error as BackupV7ExportError {+ guard case .unrepresentableValue(_, let refused, _) = error else {+ Issue.record("expected .unrepresentableValue, got \(error)")+ return }+ #expect(refused == "citations") } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swiftindex e14af89..d97285b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift@@ -168,7 +168,7 @@ struct FanOutWriteTests { let assigned = store.insertEntry( id: shared, hostname: "dup.example", title: "Chapter", offset: 0) assigned.work = work- assigned.workAssignmentProvenance = .manual+ assigned.editCitations { $0.workAssignment = .manual } // The twin points nowhere: the row a sync arrival left behind before // the assignment reached it. store.insertEntry(id: shared, hostname: "dup.example", title: "Chapter", offset: 30)@@ -200,7 +200,7 @@ struct FanOutWriteTests { let row = store.insertEntry( id: shared, hostname: "dup.example", title: "Chapter", offset: offset) row.work = work- row.workAssignmentProvenance = .manual+ row.editCitations { $0.workAssignment = .manual } // A sentinel the write would overwrite with the fixed clock's // value, so "nothing was written" is observable. row.modifiedAt = WriteFixture.epoch.addingTimeInterval(12_345)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swiftindex 7a5cf2b..dfc6685 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift@@ -76,7 +76,7 @@ struct FrozenLibraryPathTests { /// moved it from `"5"` to `"6"` (Q26), `character-extraction` from `"6"` to /// `"7"` (Q80) — and the app opens every generation it has published. What is /// frozen is the shape and the filename beside it.- private static let markerContents = "8\n"+ private static let markerContents = "9\n" /// Everything a fresh app-role open is allowed to leave in the root, SQLite's /// own `-wal`/`-shm` companions excluded. An extra entry here is a path@@ -279,10 +279,15 @@ struct FrozenLibraryPathTests { /// name here — see the rule in the suite's doc comment. @Test("No declared identifier carries a version number it does not describe") func noIdentifierNamesAVersionItDoesNotDescribe() throws {- /// The store schemas this package declares — V8 live, V5, V6 and V7- /// frozen as the `from` versions of the three lightweight stages — the- /// plan that stages them, and the floor the recorded-version reading- /// refuses below.+ /// The store schemas this package declares — V9 live and V8 frozen as+ /// the `from` version of the one lightweight stage — the plan that+ /// stages them, and the floor the recorded-version reading refuses+ /// below.+ ///+ /// V5, V6 and V7 went with the stages that named them: every device is+ /// confirmed at marker `"8"`, which is `retire-migration-chain`+ /// Decision 6's population precondition for each of them (Q2 of+ /// `drop-superseded-columns`). /// /// **No marker generation is named here any more.** `markerLaggingV4`, /// `markerLaggingV5` and `markerLaggingV6` were the bootstrap states for@@ -292,8 +297,7 @@ struct FrozenLibraryPathTests { /// both deliberately unversioned by name because they always mean the /// current generation. let declaresAStoreSchemaOrMarkerGeneration: Set<String> = [- "AsterismSchemaV5", "AsterismSchemaV6", "AsterismSchemaV7",- "AsterismSchemaV8", "AsterismV8MigrationPlan", "V8PopulationPass",+ "AsterismSchemaV8", "AsterismSchemaV9", "AsterismV9MigrationPlan", "atOrAboveV5", "belowV5", "firstV5Major", ] /// The archive format — 7/8, the one shape the app reads and writes, plus@@ -375,38 +379,27 @@ struct FrozenLibraryPathTests { .map { String($0.1) } } #expect(- declared.sorted() == [- "AsterismSchemaV5", "AsterismSchemaV6", "AsterismSchemaV7", "AsterismSchemaV8",- ],+ declared.sorted() == ["AsterismSchemaV8", "AsterismSchemaV9"], "the package declares versioned schemas \(declared); Req 3.3 allows only ones a plan references") - let referenced = AsterismV8MigrationPlan.schemas.map { String(describing: $0) }+ let referenced = AsterismV9MigrationPlan.schemas.map { String(describing: $0) } #expect(- referenced == [- "AsterismSchemaV5", "AsterismSchemaV6", "AsterismSchemaV7", "AsterismSchemaV8",- ],+ referenced == ["AsterismSchemaV8", "AsterismSchemaV9"], "the plan references \(referenced), which is not the set of declared schemas")- // Three stages, all lightweight: V5 → V6 adds a column and a table,- // V6 → V7 adds two columns and two tables, and V7 → V8 adds two columns- // and two tables. None changes anything that exists, so- // `ModelContainer.init` runs all three conversions; the data pass that- // fills V8's new rows runs after the open (Decision 3). The V5 stage- // stays deliberately (Q80): retiring it would carry- // `retire-migration-chain` Decision 6's population precondition.- #expect(- AsterismV8MigrationPlan.stages.count == 3,- "the plan stages \(AsterismV8MigrationPlan.stages.count) migrations; V5 → V6 → V7 → V8 is three")- #expect(- AsterismSchemaV5.versionIdentifier == Schema.Version(5, 0, 0),- "the frozen snapshot's version stamp is the `from` side every V5 store is matched on")+ // One lightweight stage. It is the first that **removes** — 35+ // attributes and the `Work.site` ↔ `Site.works` inverse pair — which is+ // why every reader had to be off them before this schema existed:+ // `ModelContainer.init` runs the conversion and nothing gets to read+ // what it destroys. The V5, V6 and V7 stages retired with the snapshots+ // they named (Q2), on the population precondition every device now meets. #expect(- AsterismSchemaV6.versionIdentifier == Schema.Version(6, 0, 0),- "the frozen snapshot's version stamp is the `from` side every V6 store is matched on")- #expect(- AsterismSchemaV7.versionIdentifier == Schema.Version(7, 0, 0),- "the frozen snapshot's version stamp is the `from` side every V7 store is matched on")+ AsterismV9MigrationPlan.stages.count == 1,+ "the plan stages \(AsterismV9MigrationPlan.stages.count) migrations; V8 → V9 is one") #expect( AsterismSchemaV8.versionIdentifier == Schema.Version(8, 0, 0),+ "the frozen snapshot's version stamp is the `from` side every V8 store is matched on")+ #expect(+ AsterismSchemaV9.versionIdentifier == Schema.Version(9, 0, 0), "the live schema's version stamp is what every recorded store is compared against") } @@ -568,6 +561,14 @@ struct FrozenLibraryPathTests { "V2LibraryValidator", "BackupV2Snapshot", "LibraryBackupSnapshot", "WorkTitleTrimRule", "MigrationSidecar", "V4Migration", "openCurrent", "V5RelationshipPass",+ // Retired by `drop-superseded-columns` (T-2280) with the stages and+ // columns that named them. `V8Shape` was the test-target bridge+ // phase 1 stood up for the V5/V6/V7 fixtures (Q21) and went with+ // them; it is listed here so a source file cannot quietly become+ // its new home.+ "AsterismSchemaV5", "AsterismSchemaV6", "AsterismSchemaV7",+ "AsterismV8MigrationPlan", "V8PopulationPass", "LegacyColumns",+ "V8Shape", ] for file in try coreSourceFiles() { let text = try String(contentsOf: file, encoding: .utf8)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swiftindex 8ce90d6..b4bad1f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift@@ -150,10 +150,10 @@ struct GroupFetchTests { let right = store.addWork(title: "A Serial") let first = store.addEntry(id: id, captureTitle: "Chapter") first.work = left- first.workAssignmentProvenance = .manual+ first.editCitations { $0.workAssignment = .manual } let second = store.addEntry(id: id, captureTitle: "Chapter") second.work = right- second.workAssignmentProvenance = .manual+ second.editCitations { $0.workAssignment = .manual } try store.commit() let unnormalized = try LibraryRepository.fetchEntryGroup(id: id, context: store.context, canonicalWorkIDs: [:])@@ -222,12 +222,12 @@ private final class GroupStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismGroupFetch-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }@@ -252,7 +252,7 @@ private final class GroupStore { @discardableResult func addWork(id: UUID = UUID(), title: String, offset: TimeInterval = 0) -> Work { let work = Work(- id: id, displayTitle: title, siteHostname: Self.hostname,+ id: id, displayTitle: title, timestamp: Self.epoch.addingTimeInterval(offset)) work.lastParsedTitle = title context.insert(work)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swiftindex 5c49305..dd05411 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift@@ -194,6 +194,44 @@ struct GroupOrderingTests { #expect(GroupOrdering.representativePattern([untrimmed, suffixTrimmed]) === suffixTrimmed) } + /// Q11: with the ten definition columns unwritten, a nil `definitionData`+ /// orders under a constant key of its own — `"no-definition:"` — rather than+ /// under something derived from columns that hold nothing. The prefix has to+ /// stay outside the `raw:` family, or a nil row and an undecodable row would+ /// compare converged and Req 6.1's selector would pick one to represent the+ /// other.+ @Test("A nil definition orders under its own key, apart from raw bytes and real definitions")+ func nilDefinitionOrdersUnderItsOwnKey() throws {+ let store = try OrderingStore()+ let site = store.addSite(hostname: "broken.example")+ let real = try store.addPattern(site: site, version: 1, createdAtOffset: 0)+ let undecodable = try store.addPattern(site: site, version: 1, createdAtOffset: 0)+ undecodable.definitionData = Data("not json".utf8)+ let missing = try store.addPattern(site: site, version: 1, createdAtOffset: 0)+ missing.definitionData = nil+ try store.commit()++ #expect(GroupOrdering.canonicalDefinition(missing) == "no-definition:")+ #expect(GroupOrdering.canonicalDefinition(missing)+ != GroupOrdering.canonicalDefinition(undecodable))+ #expect(GroupOrdering.canonicalDefinition(missing)+ != GroupOrdering.canonicalDefinition(real))++ // The rows tie on hostname, createdAt, version and the active flag, so+ // the definition key is the only thing left to tell them apart. None of+ // the three pairs may read as converged.+ #expect(!GroupOrdering.patternRowsAreInterchangeable(missing, real))+ #expect(!GroupOrdering.patternRowsAreInterchangeable(missing, undecodable))+ #expect(!GroupOrdering.patternRowsAreInterchangeable(undecodable, real))++ // Two nil-blob rows *do* converge: they hold the same nothing, which is+ // the truthful order Q11 chose over one derived from dead columns.+ let alsoMissing = try store.addPattern(site: site, version: 1, createdAtOffset: 0)+ alsoMissing.definitionData = nil+ try store.commit()+ #expect(GroupOrdering.patternRowsAreInterchangeable(missing, alsoMissing))+ }+ /// Two rows carrying the same rule with different JSON byte layouts are the /// same definition; the canonical form re-encodes rather than comparing bytes. @Test("URL rule canonical form is byte-layout independent")@@ -309,11 +347,14 @@ struct GroupOrderingTests { #expect(GroupOrdering.authoredContent(of: entry).isBare) entry.chapterTitle = "Chapter One"- entry.chapterTitleProvenance = .pattern+ entry.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: UUID(), patternVersion: 1)+ } // A derived chapter title is not reader-authored. #expect(GroupOrdering.authoredContent(of: entry).isBare) - entry.chapterTitleProvenance = .manual+ entry.editCitations { $0.chapterTitle = .manual } #expect(GroupOrdering.authoredContent(of: entry).isBare == false) } @@ -532,12 +573,12 @@ private final class OrderingStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismGroupOrdering-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }@@ -571,11 +612,12 @@ private final class OrderingStore { id: UUID = UUID(), title: String, offset: TimeInterval, hostname: String = OrderingStore.hostname ) -> Work {- let work = Work(- id: id, displayTitle: title, siteHostname: hostname,+ // Through `create`, because a Work's hostname is its **membership's**+ // since V9 and the row ordering reads it from there.+ let work = Work.create(+ in: context, id: id, title: title, hostname: hostname, timestamp: Self.epoch.addingTimeInterval(offset)) work.modifiedAt = Self.epoch- context.insert(work) return work } @@ -595,7 +637,7 @@ private final class OrderingStore { noted.note = "prose" let titled = addEntry(captureTitle: "same") titled.chapterTitle = "Chapter One"- titled.chapterTitleProvenance = .manual+ titled.editCitations { $0.chapterTitle = .manual } let unattached = addEntry(captureTitle: "same") unattached.intentionallyUnattached = true let otherHost = addEntry(captureTitle: "same")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupProjectionBasisTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupProjectionBasisTests.swiftindex 9461919..a302910 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupProjectionBasisTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupProjectionBasisTests.swift@@ -126,10 +126,10 @@ struct GroupProjectionBasisTests { store.insertSite(hostname: "dup.example") let first = store.insertWork( id: workID, hostname: "dup.example", title: "A Serial", offset: 0)- first.setLegacyColumns { $0.workURLString = "https://dup.example/one" }+ first.primaryMembershipEdit { $0.workURLString = "https://dup.example/one" } let second = store.insertWork( id: workID, hostname: "dup.example", title: "A Serial", offset: 30)- second.setLegacyColumns { $0.workURLString = "https://dup.example/two" }+ second.primaryMembershipEdit { $0.workURLString = "https://dup.example/two" } } let repository = try await library.openForApp() @@ -195,7 +195,7 @@ struct GroupProjectionBasisTests { store.insertWork(id: workID, hostname: "dup.example", title: "A Serial", offset: 0) let authored = store.insertWork( id: workID, hostname: "dup.example", title: "A Serial", offset: 30)- authored.setLegacyColumns { $0.workURLString = "https://dup.example/serial" }+ authored.primaryMembershipEdit { $0.workURLString = "https://dup.example/serial" } // V8: the membership is where a confirmed Work URL lives (Req 3.6); // the column beside it is the V9 mirror. authored.membershipValues.first?.workURLString = "https://dup.example/serial"
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swiftindex c9f740d..e2b8aee 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift@@ -410,7 +410,7 @@ private final class SeedStore { id: UUID = UUID(), hostname: String, title: String, offset: TimeInterval ) -> Work { let work = Work(- id: id, displayTitle: title, siteHostname: hostname,+ id: id, displayTitle: title, timestamp: LibraryFixture.epoch.addingTimeInterval(offset)) context.insert(work) return work
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swiftindex d99fabb..f9f679f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift@@ -284,12 +284,12 @@ private final class ResolutionStore { } private static func makeContainer(at directory: URL) throws -> ModelContainer {- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) return try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swiftindex 8626637..3e23f51 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift@@ -264,9 +264,9 @@ extension LibraryRepository { id: BaselineSeed.betaManualEntryID, title: "The Beta Serial :: Chapter One", hostname: BaselineSeed.betaHost, path: "read/1", site: beta) manual.work = work- manual.workAssignmentProvenance = .manual+ manual.editCitations { $0.workAssignment = .manual } manual.chapterTitle = "Chapter One"- manual.chapterTitleProvenance = .manual+ manual.editCitations { $0.chapterTitle = .manual } manual.note = "a reader's note" manual.rating = .up @@ -274,11 +274,12 @@ extension LibraryRepository { id: BaselineSeed.betaPatternEntryID, title: "The Beta Serial :: Chapter Two", hostname: BaselineSeed.betaHost, path: "read/2", site: beta) cited.work = work- cited.workAssignmentProvenance = .manual+ cited.editCitations { $0.workAssignment = .manual } cited.chapterTitle = "Chapter Two"- cited.chapterTitleProvenance = .pattern- cited.chapterPatternID = pattern.id- cited.chapterPatternVersion = pattern.version+ cited.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: pattern.id, patternVersion: pattern.version)+ } // An articles Site's Entries are unattached on purpose (Req 2.1's // tuple table pairs `.none` assignment with `intentionallyUnattached`@@ -338,10 +339,14 @@ enum LibraryGraphSerializer { "# Produced by LibraryGraphBaselineTests. Regenerating this file from post-change", "# code defeats the comparison it exists for: it is re-recorded only when the", "# schema itself changes shape, and the format line says which shape it holds.",- "# format 3 is schema V8 (multi-site-works, T-2230): the two new tables and the",- "# two blob columns joined the dump, and every superseded column stayed in it —",- "# they are retained, unread, until V9 drops them (Decision 3).",- "format 3",+ "# format 5 is schema V9 (drop-superseded-columns, T-2280): the columns",+ "# format 4 recorded as present-but-unwritten are gone from the store, so the",+ "# dump no longer has a field for them. A Work is its title, its notes and its",+ "# work-type identity; its site presence is its WorkSiteMembership, an Entry's",+ "# citations are its citationsData and a title rule is its definitionData. The",+ "# Site.works inverse went with Work.site, which is why inverse-site lists one",+ "# array fewer.",+ "format 5", "counts entries=\(entries.count) works=\(works.count) sites=\(sites.count) " + "titlePatterns=\(patterns.count) urlRulePatterns=\(rules.count) " + "workTypes=\(workTypes.count) memberships=\(memberships.count) "@@ -354,7 +359,6 @@ enum LibraryGraphSerializer { ("hostname", quoted(site.hostname)), ("displayName", quoted(site.displayName)), ("modeRaw", quoted(site.modeRaw)),- ("urlIdentityRule", json(site.urlIdentityRule)), ("junkSuffixRule", json(site.junkSuffixRule)), ])) }@@ -367,16 +371,6 @@ enum LibraryGraphSerializer { ("version", "\(pattern.version)"), ("isActive", "\(pattern.isActive)"), ("createdAt", timestamp(pattern.createdAt)),- ("formRaw", quoted(pattern.formRaw)),- ("segmentWorkAnchor", json(pattern.segmentWorkAnchor)),- ("segmentIgnoredAnchors", json(pattern.segmentIgnoredAnchors)),- ("phrasePrefix", optionalQuoted(pattern.phrasePrefix)),- ("phraseSeparator", optionalQuoted(pattern.phraseSeparator)),- ("phraseSuffix", optionalQuoted(pattern.phraseSuffix)),- ("fieldOrderRaw", optionalQuoted(pattern.fieldOrderRaw)),- ("trimPrefix", optionalQuoted(pattern.trimPrefix)),- ("trimSuffix", optionalQuoted(pattern.trimSuffix)),- ("chapterless", "\(pattern.chapterless)"), ("definitionData", optionalQuoted(pattern.definitionData.map(canonicalJSON))), ])) }@@ -415,17 +409,9 @@ enum LibraryGraphSerializer { lines.append( "work " + fields([ ("id", work.id.uuidString),- ("site", optionalQuoted(work.legacySite?.hostname)),- ("siteHostname", quoted(work.legacyColumns.hostname)), ("displayTitle", quoted(work.displayTitle)), ("lastParsedTitle", optionalQuoted(work.lastParsedTitle)),- ("urlIdentity", optionalQuoted(work.legacyColumns.urlIdentity)),- ("urlIdentityStateRaw", quoted(work.legacyIdentityStateRaw)),- ("urlIdentityRuleID", optional(work.legacyColumns.urlIdentityRuleID?.uuidString)),- ("urlIdentityRuleVersion", optional(work.urlIdentityRuleVersion.map(String.init))),- ("workURLString", optionalQuoted(work.legacyColumns.workURLString)), ("genericNotes", quoted(work.genericNotes)),- ("typeRaw", quoted(work.typeRaw)), ("workTypeID", optional(work.workTypeID?.uuidString)), ("genreTags", "[" + work.genreTags.map(quoted).joined(separator: ",") + "]"), ("titleProvenanceRaw", quoted(work.titleProvenanceRaw)),@@ -446,36 +432,16 @@ enum LibraryGraphSerializer { ("rawURLString", quoted(entry.rawURLString)), ("canonicalURLString", optionalQuoted(entry.canonicalURLString)), ("entryIdentityKey", quoted(entry.entryIdentityKey)),- ("identityKeyVersion", "\(entry.identityKeyVersion)"), ("conservativeIdentityKey", quoted(entry.conservativeIdentityKey)), ("identityBasisRaw", quoted(entry.identityBasisRaw)),- ("identityURLRuleID", optional(entry.identityURLRuleID?.uuidString)),- ("identityURLRuleVersion", optional(entry.identityURLRuleVersion.map(String.init))),- ("identityNameTitleRuleID", optional(entry.identityNameTitleRuleID?.uuidString)),- ("identityNameTitleRuleVersion",- optional(entry.identityNameTitleRuleVersion.map(String.init))), ("urlWorkIdentity", optionalQuoted(entry.urlWorkIdentity)),- ("urlWorkRuleID", optional(entry.urlWorkRuleID?.uuidString)),- ("urlWorkRuleVersion", optional(entry.urlWorkRuleVersion.map(String.init))), ("chapterSequence", optionalQuoted(entry.chapterSequence)),- ("chapterSequenceRuleID", optional(entry.chapterSequenceRuleID?.uuidString)),- ("chapterSequenceRuleVersion",- optional(entry.chapterSequenceRuleVersion.map(String.init))), ("chapterTitle", optionalQuoted(entry.chapterTitle)),- ("chapterTitleProvenanceRaw", quoted(entry.chapterTitleProvenanceRaw)),- ("chapterPatternID", optional(entry.chapterPatternID?.uuidString)),- ("chapterPatternVersion", optional(entry.chapterPatternVersion.map(String.init))), ("note", quoted(entry.note)), ("ratingRaw", optionalQuoted(entry.ratingRaw)), ("firstCapturedAt", timestamp(entry.firstCapturedAt)), ("lastSharedAt", timestamp(entry.lastSharedAt)), ("modifiedAt", timestamp(entry.modifiedAt)),- ("workAssignmentProvenanceRaw", quoted(entry.workAssignmentProvenanceRaw)),- ("workPatternID", optional(entry.workPatternID?.uuidString)),- ("workPatternVersion", optional(entry.workPatternVersion.map(String.init))),- ("workURLRuleID", optional(entry.workURLRuleID?.uuidString)),- ("workURLRuleVersion", optional(entry.workURLRuleVersion.map(String.init))),- ("workURLAssignmentKindRaw", optionalQuoted(entry.workURLAssignmentKindRaw)), ("intentionallyUnattached", "\(entry.intentionallyUnattached)"), ("citationsData", optionalQuoted(entry.citationsData.map(canonicalJSON))), ]))@@ -509,10 +475,12 @@ enum LibraryGraphSerializer { ])) } - // The inverse side of every relationship. `Site.entries` and `Site.works`- // are internal by design (Q17 — traversing them faults every record for a- // hostname), which is exactly why a test is the only place they may be- // read: nothing in the app is allowed to notice they changed.+ // The inverse side of every relationship. `Site.entries` and+ // `Site.workMemberships` are internal by design (Q17 — traversing them+ // faults every record for a hostname), which is exactly why a test is+ // the only place they may be read: nothing in the app is allowed to+ // notice they changed. `Site.works` stood beside them until V9 dropped+ // it with `Work.site`. for site in sites.sorted(by: { $0.hostname < $1.hostname }) { lines.append( "inverse-site " + fields([@@ -520,7 +488,6 @@ enum LibraryGraphSerializer { ("patterns", identifiers(site.patternValues.map { $0.id.uuidString })), ("urlRules", identifiers(site.urlRuleValues.map { $0.id.uuidString })), ("entries", identifiers((site.entries ?? []).map { $0.id.uuidString })),- ("works", identifiers((site.works ?? []).map { $0.id.uuidString })), ("workMemberships", identifiers((site.workMemberships ?? []).map { $0.id.uuidString })), ]))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swiftindex 70c83e5..45a4702 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift@@ -338,12 +338,12 @@ private final class ToleranceScanStore { } private static func makeContainer(at directory: URL) throws -> ModelContainer {- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) return try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) } @@ -357,7 +357,7 @@ private final class ToleranceScanStore { let entries = try context.fetch(FetchDescriptor<Entry>()) .map { "Entry \($0.id.uuidString) \($0.hostname) \($0.captureTitle)" } let works = try context.fetch(FetchDescriptor<Work>())- .map { "Work \($0.id.uuidString) \($0.legacyColumns.hostname) \($0.displayTitle)" }+ .map { "Work \($0.id.uuidString) \($0.primaryMembership?.hostname ?? "-") \($0.displayTitle)" } let patterns = try context.fetch(FetchDescriptor<TitlePattern>()) .map { "TitlePattern \($0.id.uuidString) \($0.version)" } let rules = try context.fetch(FetchDescriptor<URLRulePattern>())
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorNilSiteToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorNilSiteToleranceTests.swiftindex 9ed0e28..e88f224 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorNilSiteToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorNilSiteToleranceTests.swift@@ -32,7 +32,7 @@ struct LibraryValidatorNilSiteToleranceTests { /// store, same citation, relationship present or absent. private func unlink(_ fixture: ValidatorFixture) { fixture.entry.site = nil- fixture.work.setLegacySite(nil)+ fixture.membership.site = nil } private func diagnostics(_ fixture: ValidatorFixture) throws -> LibraryDiagnostics {@@ -85,7 +85,9 @@ struct LibraryValidatorNilSiteToleranceTests { func v3NameContributorToleratesNilSite() throws { let fixture = try ValidatorFixtures.wholeTitleSequence() unlink(fixture)- fixture.entry.identityNameTitleRuleVersion = 99 // version mismatch+ fixture.entry.editCitations { // version mismatch+ $0.identity = $0.identity.citingNameTitleVersion(99)+ } let tolerated = try diagnostics(fixture) #expect(tolerated.quarantineMap().isEmpty)@@ -95,7 +97,7 @@ struct LibraryValidatorNilSiteToleranceTests { @Test("v3 name contributor: a populated entry.site still diagnoses an unresolvable pattern") func v3NameContributorStillFailsWithAPopulatedSite() throws { let fixture = try ValidatorFixtures.wholeTitleSequence()- fixture.entry.identityNameTitleRuleVersion = 99+ fixture.entry.editCitations { $0.identity = $0.identity.citingNameTitleVersion(99) } #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil) }@@ -129,7 +131,7 @@ struct LibraryValidatorNilSiteToleranceTests { func chapterProvenanceToleratesNilSite() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- fixture.entry.chapterPatternVersion = 99+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) } let tolerated = try diagnostics(fixture) #expect(tolerated.quarantineMap().isEmpty)@@ -139,7 +141,7 @@ struct LibraryValidatorNilSiteToleranceTests { @Test("Chapter provenance: a populated entry.site still diagnoses an unresolvable pattern") func chapterProvenanceStillFailsWithAPopulatedSite() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()- fixture.entry.chapterPatternVersion = 99+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) } #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil) }@@ -159,7 +161,7 @@ struct LibraryValidatorNilSiteToleranceTests { func requiredReferenceToleratesNilSite() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- fixture.entry.workURLRuleID = UUID() // assignment cites a rule no Site owns+ fixture.entry.citeWorkURLRule(id: UUID()) // cites a rule no Site owns let tolerated = try diagnostics(fixture) #expect(tolerated.quarantineMap().isEmpty)@@ -169,21 +171,16 @@ struct LibraryValidatorNilSiteToleranceTests { @Test("Required reference: a populated entry.site still diagnoses an unresolvable assignment rule") func requiredReferenceStillFailsWithAPopulatedSite() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()- fixture.entry.workURLRuleID = UUID()+ fixture.entry.citeWorkURLRule(id: 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 ValidatorFixtures.wcSegmentIdentitySequence()- unlink(fixture)- fixture.entry.workURLRuleVersion = nil-- #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil)- }+ // "Required reference: a half-written reference still fails closed" stood+ // here. It seeded an id with no version — malformed rather than+ // unresolvable, and so a diagnosis whatever the relationship held. `CitedRule`+ // carries both halves or neither, so since V9 the blob cannot spell that+ // state and no writer can produce it. // MARK: - validateExtractionReplay (Q40) @@ -202,10 +199,7 @@ struct LibraryValidatorNilSiteToleranceTests { func extractionReplayToleratesNilSite() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- let bogus = UUID()- fixture.entry.urlWorkRuleID = bogus- fixture.entry.chapterSequenceRuleID = bogus- fixture.entry.identityURLRuleID = bogus+ fixture.entry.citeURLRule(id: UUID()) let tolerated = try diagnostics(fixture) #expect(tolerated.quarantineMap().isEmpty,@@ -216,10 +210,7 @@ struct LibraryValidatorNilSiteToleranceTests { @Test("Work extraction replay: a populated entry.site still diagnoses it") func extractionReplayStillFailsWithAPopulatedSite() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence()- let bogus = UUID()- fixture.entry.urlWorkRuleID = bogus- fixture.entry.chapterSequenceRuleID = bogus- fixture.entry.identityURLRuleID = bogus+ fixture.entry.citeURLRule(id: UUID()) #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil) }@@ -231,9 +222,7 @@ struct LibraryValidatorNilSiteToleranceTests { func sequenceReplayToleratesNilSite() throws { let fixture = try ValidatorFixtures.wholeTitleSequence() unlink(fixture)- let bogus = UUID()- fixture.entry.chapterSequenceRuleID = bogus- fixture.entry.identityURLRuleID = bogus+ fixture.entry.citeURLRule(id: UUID()) let tolerated = try diagnostics(fixture) #expect(tolerated.quarantineMap().isEmpty)@@ -243,9 +232,7 @@ struct LibraryValidatorNilSiteToleranceTests { @Test("Sequence extraction replay: a populated entry.site still diagnoses it") func sequenceReplayStillFailsWithAPopulatedSite() throws { let fixture = try ValidatorFixtures.wholeTitleSequence()- let bogus = UUID()- fixture.entry.chapterSequenceRuleID = bogus- fixture.entry.identityURLRuleID = bogus+ fixture.entry.citeURLRule(id: UUID()) #expect(try diagnostics(fixture).quarantineMap()[fixture.site.hostname] != nil) }@@ -294,7 +281,7 @@ struct LibraryValidatorNilSiteToleranceTests { func toleratedRecordStillRenders() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- fixture.entry.chapterPatternVersion = 99+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) } let tolerated = try diagnostics(fixture) #expect(tolerated.quarantineMap().isEmpty)@@ -310,7 +297,7 @@ struct LibraryValidatorNilSiteToleranceTests { func strictValidationIsUnchanged() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- fixture.entry.chapterPatternVersion = 99+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) } let diagnoses = try LibraryValidator.validateStrict(graph: fixture.graph) #expect(diagnoses[fixture.site.hostname] != nil,@@ -326,7 +313,7 @@ struct LibraryValidatorNilSiteToleranceTests { func entryTupleGateNeverTolerates() throws { let fixture = try ValidatorFixtures.wcSegmentIdentitySequence() unlink(fixture)- fixture.entry.chapterPatternVersion = 99+ fixture.entry.editCitations { $0.chapterTitle = $0.chapterTitle.citingPatternVersion(99) } #expect(fixture.entry.site == nil) #expect(throws: LibraryValidationError.self) {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swiftindex 4ec0ddc..f82b16b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorTests.swift@@ -68,8 +68,7 @@ struct LibraryValidatorTests { @Test("A v3 Entry with no name contributor is diagnosed") func v3RequiresNameContributor() throws { let fixture = try ValidatorFixtures.wholeTitleSequence()- fixture.entry.identityNameTitleRuleID = nil- fixture.entry.identityNameTitleRuleVersion = nil+ fixture.entry.editCitations { $0.identity = $0.identity.citingNoNameTitle } let diagnoses = try LibraryValidator.validateStrict(graph: fixture.graph) #expect(diagnoses[fixture.site.hostname] != nil) }@@ -111,6 +110,33 @@ struct LibraryValidatorTests { #expect(diagnoses[fixture.site.hostname] != nil) } + /// Q32: a **nil** citation blob beside a live Work link is counted, not+ /// diagnosed.+ ///+ /// It is what the V8 → V9 drop stage leaves on a device that captured+ /// something after its last V8 launch: the blob was never written, so the+ /// assignment provenance went with the columns while the Work relationship+ /// stayed. Quarantining the hostname over it would withdraw the manual+ /// re-assignment that is the only repair (Q25).+ ///+ /// Bytes that *decode* to the same `.none` are a different fact — a partial+ /// write — and still fail, which is the second half of this case.+ @Test("A nil citation blob beside an assigned Work is reported, not diagnosed")+ func nilCitationBlobKeepsItsWorkLink() throws {+ let graph = try ValidatorFixtures.wholeTitleUnsettled()+ let entry = try #require(graph.entries.first)+ let hostname = try #require(graph.sites.first).hostname+ #expect(entry.work != nil, "the row under test has to keep its Work link")++ entry.citationsData = nil+ #expect(entry.citationsAreAbsent)+ #expect(try LibraryValidator.validateStrict(graph: graph).isEmpty)++ try entry.setCitations(EntryCitations())+ #expect(try LibraryValidator.validateStrict(graph: graph)[hostname] != nil,+ "bytes decoding to `.none` beside a Work are a partial write, not upgrade residue")+ }+ // MARK: - Per-Site scoping @Test("An illegal Site is diagnosed while unrelated Sites stay clean")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swiftindex 4675bef..ef7d729 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift@@ -296,18 +296,17 @@ struct LibraryValidatorToleranceTests { #expect(snapshot.rating == nil) } - /// The half of the mapper that did **not** relax. `FieldProvenance`'s init- /// validates the *combination* of kind, pattern id and version — an- /// invariant about this row's own columns, not a spelling from a newer- /// build — so a `.pattern` provenance citing no pattern is still a broken- /// row and still refuses.- @Test("An incomplete provenance citation still throws out of the snapshot mapper")- func incompleteProvenanceFailsClosedInSnapshot() throws {+ /// The half of the mapper that did **not** relax. It used to be+ /// `FieldProvenance`'s combination check over three columns; since V9 the+ /// provenance comes out of the typed blob, where the case carries its cited+ /// rule or is not that case, so the malformed row is one whose *bytes* will+ /// not decode. Either way the rule is the same: an unrecognised spelling+ /// from a newer build is tolerated, a row that is broken in itself is not.+ @Test("Unreadable citation bytes still throw out of the snapshot mapper")+ func unreadableCitationsFailClosedInSnapshot() throws { let store = try ValidatorStore() let fixture = try store.seedTaughtSite()- fixture.entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- fixture.entry.chapterPatternID = nil- fixture.entry.chapterPatternVersion = nil+ fixture.entry.citationsData = Data("not json".utf8) try store.save() #expect(throws: (any Error).self) {@@ -417,12 +416,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: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LogicalRecordCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LogicalRecordCaptureTests.swiftindex 2700a9e..7a982d0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LogicalRecordCaptureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LogicalRecordCaptureTests.swift@@ -203,8 +203,8 @@ struct LogicalRecordCaptureTests { id: shared, hostname: "dup.example", title: "The Serial", offset: 0) let second = store.insertWork( id: shared, hostname: "dup.example", title: "The Serial", offset: 30)- first.setLegacySite(site)- second.setLegacySite(site)+ first.primaryMembershipEdit { $0.site = site }+ second.primaryMembershipEdit { $0.site = site } } let repository = try await library.openForApp()
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swiftindex c06d2d3..24e914c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift@@ -357,7 +357,7 @@ private struct LookupCaptureFixture { _ seed: (ModelContext, String) -> Void ) throws -> LookupCaptureFixture { let url = "https://example.com/chapter-1"- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let container = try ModelContainer( for: schema, configurations: [
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swiftindex b0f5ac4..5fe95b7 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift@@ -47,57 +47,29 @@ struct M4DuplicateScalePerformanceTests { /// and well under a doubling. Moving it up to make a run pass would give the /// test back the property it exists to remove. ///- /// `multi-site-works` moved it to **9.215–9.433 s** (one run, 2026-08-26)- /// for the reason `observationPassKnownIssue` gives — two whole-library- /// passes now run in front of every reconcile, and this loop runs two- /// reconciles per sample. Still inside the ceiling, and the ceiling stays- /// where it is: it is the number a *new* problem has to clear, and the- /// headroom it has lost is itself the signal.+ /// `multi-site-works` moved it to **9.215–9.433 s** (one run, 2026-08-26),+ /// because two whole-library passes ran in front of every reconcile and this+ /// loop runs two reconciles per sample. `drop-superseded-columns` deleted+ /// both and it is back at **7.347–7.411 s**, at the band this ceiling was+ /// drawn over. The ceiling stays where it is: it is the number a *new*+ /// problem has to clear, and it was never moved to accommodate the rise. private let settlingPassCeiling = Duration.seconds(11)- /// The observation pass **was** inside its budget and asserted plainly; see- /// `observationPassKnownIssue`.- private let observationPassBudget = Duration.seconds(2)- /// The floor under that breach, in the shape the settling pass beside it- /// uses: above the measured 2.65–2.79 s with room for noise, well under a- /// doubling.- private let observationPassCeiling = Duration.seconds(4)-- /// **The observation pass left its budget with `multi-site-works`, and this- /// suite records that rather than raising the budget.**+ /// **The observation pass is back inside its budget and asserted plainly.** ///- /// It measured 0.93–0.95 s against 2 s before the feature and **2.65–2.79 s- /// after it** (medians over ten samples, spread ≤ 1.04×). Two whole-library- /// passes joined `reconcileAfterSync` in front of everything this measures- /// — `V8PopulationPass.run` and `MembershipReconciler.run`- /// (`LibraryRepository.swift:378-386`) — and the same cost is measured on- /// its own next door: `M4ScalePerformanceTests`' `reconcile-noop-coherent`- /// went from 1.82–2.00 **ms** to 1.06–1.10 s over the same change. This test- /// times one reconcile, so ~1.07 s of the ~1.7 s added here is that — about- /// 60% — and the rest is the same two passes doing more than nothing over- /// the 1,350 duplicate rows this fixture seeds.+ /// It measured 0.93–0.95 s against 2 s before `multi-site-works`, 2.65–2.79 s+ /// after it — two whole-library passes joined `reconcileAfterSync` in front+ /// of everything this measures — and **1.008–1.025 s** over two runs once+ /// `drop-superseded-columns` deleted `V8PopulationPass` and gated+ /// `MembershipReconciler.heal` on the diagnosis (Q10). The breach was always+ /// derivative of `M4ScalePerformanceTests`' `reconcile-noop-coherent`, which+ /// this test pays once per timed pass; that number fell 36× and this one+ /// followed it, which is the confirmation the attribution was right. ///- /// **Derivative, and confirmed so by Decision 5.** The review that gated the- /// two passes off the arrival tier re-measured this at 2.687 s, essentially- /// unmoved — because `reconcileAfterSync()` here is the **full** tier, which- /// Decision 5 leaves unconditional on purpose. The gate is visible in the- /// same run in `duplicate-arrival-pass-gated` below, which went from 1.080 s- /// to 0.030 s. So this breach is not a second problem to solve: it is the- /// full-tier figure, measured through a fixture, and it closes when that one- /// does (V9, T-2272).- ///- /// Same routing as Decision 27's breach above it: recorded, loud, and left- /// for the design owner, with the analysis in- /// `specs/multi-site-works/verification-run.md`.- private static let observationPassKnownIssue: Comment = """- Req 10.1's observation pass (2 s) is exceeded at 2.65-2.79 s, up from \- 0.93-0.95 s before multi-site-works put V8PopulationPass and \- MembershipReconciler on reconcileAfterSync. ~60% of the rise is the \- full-tier no-op cost measured as reconcile-noop-coherent, which \- Decision 5 leaves unconditional on that tier; this test times a \- full-tier pass, so it moves when that one does. Host-only measurement. \- See the comment above this constant and \- specs/multi-site-works/verification-run.md.- """+ /// So the `withKnownIssue` that stood beside the assertion below is gone+ /// with the breach, and so is the 4 s regression floor that stood under it:+ /// the 2 s budget is a tighter bound than that floor and is asserted+ /// plainly again (Q33 of `drop-superseded-columns`).+ private let observationPassBudget = Duration.seconds(2) /// The publication budget the Recent path already carries /// (`library-integrity-tolerance` Req 5.2/5.3). private let recentPublishBudget = Duration.seconds(2)@@ -123,6 +95,11 @@ struct M4DuplicateScalePerformanceTests { /// *observation* pass beside it, which does all the writing, is 0.93–0.95 s /// and inside budget. ///+ /// **`multi-site-works` and `drop-superseded-columns` moved it and moved it+ /// back**, to 9.215–9.433 s and then to 7.347–7.411 s, without ever touching the+ /// budget or the ceiling. The breach itself is unchanged and still+ /// unattributed (Q7 of `drop-superseded-columns`, T-2093).+ /// /// **The first cost model was wrong, and the measurement that replaced it /// says so.** Task 22 attributed the breach to `commitDeletions` running one /// `saveStrategy.save(context)` per set — 300 collapses, 300 saves, "roughly@@ -225,13 +202,10 @@ struct M4DuplicateScalePerformanceTests { // Recorded beside it: a pass that met 2 s only by having deferred half // its work to the pass before it would satisfy the letter of Req 10.1- // and nothing else. It was inside its budget until `multi-site-works`;- // see `observationPassKnownIssue`.+ // and nothing else. Out of budget between `multi-site-works` and V9,+ // inside it again at 1.008-1.025 s — see `observationPassBudget`. let observed = PerformanceDistribution(observationSamples)- withKnownIssue(Self.observationPassKnownIssue) {- expectWithinBudget("duplicate-observation-pass", observed, observationPassBudget)- }- expectWithinCeiling("duplicate-observation-pass", observed, observationPassCeiling)+ expectWithinBudget("duplicate-observation-pass", observed, observationPassBudget) // Q98, pinned rather than argued: the arrival gate stays open on this // library for the rest of the session, because the 10 converged rule
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4MembershipScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4MembershipScalePerformanceTests.swiftindex b94c10d..53b9a12 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4MembershipScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4MembershipScalePerformanceTests.swift@@ -33,94 +33,13 @@ import Testing .enabled(if: ProcessInfo.processInfo.environment["ASTERISM_RUN_PHYSICAL_PERFORMANCE"] == "1")) struct M4MembershipScalePerformanceTests { - // MARK: - Req 2.5 / Q18 — the V8 population pass over a recorded V7 store-- /// Five samples, each paying its own copy-and-reopen of a 1,000-Work /- /// 5,000-Entry V7 store before anything is timed. The same arithmetic as the- /// worst-case consolidation next door: the setup costs as much as the- /// measurement, and there is no shortcut that does not turn the sample into- /// a measurement of an already-converted store.- private let migrationIterations = 5-- /// The pass measured **3.211–3.254 s** on the host across four release runs- /// (2026-08-26; see `verification-run.md`), with medians of 3.221, 3.242,- /// 3.223 and 3.225 s — a 0.7% spread between them. The 8 s ceiling this- /// carried was ~2.5× a *single* run's band and said so; four agreeing runs- /// are a band, so it is now ~1.5× the top of it. Tightening on evidence is- /// the move the original comment asked for; raising it to make a run pass- /// would be the mistake.- private let populationPassCeiling = Duration.seconds(5)- /// The container open that carries the V7 → V8 lightweight stage measured- /// **0.033–0.034 s** — the stage only adds two tables and two columns- /// (Decision 3), so there is nothing in it proportional to the 6,000 rows- /// behind it. The ceiling is deliberately loose in ratio and tight in- /// absolute terms: at this size the measurement is mostly fixed cost, and- /// what it must catch is a stage that started rewriting rows.- private let migrationOpenCeiling = Duration.milliseconds(500)-- /// Req 2.5's number: what the membership pass costs over the store size the- /// requirement names.- ///- /// **The pass, not the open.** SwiftData runs the V7 → V8 stage inside- /// `ModelContainer.init`, and that stage only *adds* two tables and two- /// columns (Decision 3); the work Req 2.5 is about — 1,000 memberships- /// minted, 5,000 citation blobs written, the pattern's definition converted- /// — is all in `V8PopulationPass`. Both are recorded, timed separately, so a- /// later run can tell which half moved.- ///- /// Ways this could silently measure nothing, all guarded rather than- /// trusted: a copy that did not take would hand the pass a store that was- /// already converted (asserted at zero memberships before the timer starts),- /// and a pass that declined to convert would return in milliseconds- /// (asserted at 1,000 memberships and `assertComplete` after it).- @Test("V8 population pass over a recorded 1,000-Work / 5,000-Entry V7 store (Req 2.5, Q18)")- func populationPassOverRecordedV7Store() throws {- let template = try V7ScaleStoreTemplate()-- var openSamples: [Duration] = []- var passSamples: [Duration] = []- let clock = ContinuousClock()-- for iteration in 0..<(migrationIterations + 1) {- let copy = try template.freshCopy()- defer { copy.remove() }-- let openStart = clock.now- let container = try LibraryRepository.openContainer(at: copy.storeURL)- let openElapsed = clock.now - openStart- let context = ModelContext(container)-- // A store that arrived already converted would make the number- // below a measurement of three empty fetches.- #expect(- try context.fetchCount(FetchDescriptor<WorkSiteMembership>()) == 0,- "iteration \(iteration): the timed pass must start from an unconverted store")-- let start = clock.now- try V8PopulationPass.run(context: context)- let elapsed = clock.now - start-- #expect(- try context.fetchCount(FetchDescriptor<WorkSiteMembership>())- == V7ScaleStoreTemplate.workCount,- """- iteration \(iteration): the pass must have minted one membership per Work \- (\(V7ScaleStoreTemplate.workCount))- """)- try V8PopulationPass.assertComplete(context: context)- withExtendedLifetime(container) {}-- if iteration > 0 {- openSamples.append(openElapsed)- passSamples.append(elapsed)- }- }-- expectWithinCeiling(- "v8-population-pass", PerformanceDistribution(passSamples), populationPassCeiling)- expectWithinCeiling(- "v7-open-and-migrate", PerformanceDistribution(openSamples), migrationOpenCeiling)- }+ // Req 2.5 / Q18's budget stood here: `V8PopulationPass` over a recorded+ // 1,000-Work / 5,000-Entry V7 store, timed apart from the container open+ // that carried the V7 → V8 stage. It measured 3.211–3.254 s against a 5 s+ // ceiling, and the open 0.033–0.034 s against 500 ms. Both go with the pass+ // at V9: nothing converts a library any more, so there is no production cost+ // for the number to describe. `V7ScaleStoreTemplate`, the 1,000-Work store+ // they were seeded from, went with the frozen V5/V6/V7 snapshots. // MARK: - Req 8.6 — what membership reconciliation costs per arrival @@ -140,23 +59,21 @@ struct M4MembershipScalePerformanceTests { /// one run; it is now ~1.6× the top of a four-run band. What it must catch /// is the walk becoming a fetch. ///- /// This is the phase Decision 5 gates off the arrival tier, so the figure is- /// what a *full*-tier pass pays — launch and the duplicate follow-up — not- /// what a remote-change debounce pays.+ /// Since V9 this is the phase the diagnosis counter gates on **both** tiers+ /// (Q10), so the figure is what a pass over a library that is *missing* a+ /// membership pays. A coherent library pays none of it. private let reconcileNoOpCeiling = Duration.milliseconds(800)- /// The healing pass, which mints 1,000 memberships and mirrors 1,000 Works'- /// superseded columns behind them. Measured **1.992–2.030 s** over three- /// runs; the ceiling stays at ~2.5× rather than tightening with the two- /// above, because this is not a cost production pays.+ /// The healing pass, which mints 1,000 memberships. Measured **1.992–2.030 s**+ /// over three runs; the ceiling stays at ~2.5× rather than tightening,+ /// because this is not a cost production pays. /// /// **An isolated upper bound, not a production figure** (relabelled at task /// 22's review). It strips *every* Work's membership and heals all 1,000 at- /// once, which is Req 2.7's shape taken to its limit — and in production- /// `V8PopulationPass.populateMemberships` runs first and mints from each- /// Work's retained hostname column, so `heal` only ever reaches what that- /// could not mint for: a Work with a blank hostname (Q30), or one whose- /// Entries are on a hostname its memberships are not. What this number- /// bounds is the pass's scaling, not an arrival's cost.+ /// once, which is Req 2.7's shape taken to its limit. In production the heal+ /// only runs at all where the diagnosis counted a Work without a membership+ /// (V9, Q10), and what it then reaches is a Work whose membership never+ /// arrived — never the whole table. What this number bounds is the phase's+ /// scaling, not an arrival's cost. private let healCeiling = Duration.seconds(5) @Test("Membership reconciliation over the coherent 5,000-Entry fixture (Req 8.6)")@@ -305,172 +222,6 @@ struct M4MembershipScalePerformanceTests { } } -// MARK: - Fixture: a 1,000-Work / 5,000-Entry store recorded at 7.0.0--/// The M4 composed fixture's shape, written through the **frozen V7 snapshot**-/// so what the population pass converts is a store the V7 classes wrote.-///-/// `V7RecordedStoreFixture` is the correctness fixture — one row per V7 model,-/// every superseded column populated, so a conversion that dropped a field is-/// caught. This is the same idea at the size Req 2.5 names, and it deliberately-/// reuses the M4 fixture's own title rule, URL rule, capture titles and raw URLs-/// (`LibraryRepository.m4Fixture*`): the numbers it produces are then about the-/// same graph every other M4 measurement is taken over.-///-/// Seeded **once** into a template directory. Each sample copies the directory,-/// because the pass is idempotent by construction (Req 2.4) — a second run over-/// the same store would measure three fetches finding nothing to do.-private final class V7ScaleStoreTemplate {- static let hostname = LibraryRepository.m4FixtureHostname- static let entryCount = LibraryRepository.m4FixtureEntryCount- static let entriesPerWork = LibraryRepository.m4FixtureEntriesPerWork- static let workCount = entryCount / entriesPerWork-- static let patternID = UUID(uuidString: "22222222-2222-2222-2222-000000000107")!- static let patternVersion = 1- static let urlRuleID = UUID(uuidString: "33333333-3333-3333-3333-000000000107")!- static let urlRuleVersion = 1- static let timestamp = Date(timeIntervalSince1970: 1_830_000_000)-- let root: URL- var storeURL: URL { root.appending(path: "store.sqlite") }-- init() throws {- root = FileManager.default.temporaryDirectory- .appending(- path: "asterism-v7-scale-perf-\(UUID().uuidString)", directoryHint: .isDirectory)- try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)- try Self.seed(at: storeURL)- }-- /// A copy of the whole store directory, so the sample opens a file no- /// container has ever migrated.- func freshCopy() throws -> Copy {- let destination = FileManager.default.temporaryDirectory- .appending(- path: "asterism-v7-scale-copy-\(UUID().uuidString)", directoryHint: .isDirectory)- try FileManager.default.copyItem(at: root, to: destination)- return Copy(root: destination)- }-- struct Copy {- let root: URL- var storeURL: URL { root.appending(path: "store.sqlite") }- func remove() { try? FileManager.default.removeItem(at: root) }- }-- deinit { try? FileManager.default.removeItem(at: root) }-- /// One taught Site, its two rules, 1,000 Works and 5,000 Entries, every one- /// of them carrying the superseded columns the pass reads.- private static func seed(at storeURL: URL) throws {- guard case .phrase(let prefix, let separator, let suffix, let order) =- LibraryRepository.m4FixtureTitleDefinition()- else {- throw ModelInvariantError.invalidCombination(field: "V7 scale fixture title rule")- }-- try V7RecordedStoreFixture.write(at: storeURL) { context in- let site = AsterismSchemaV7.Site()- site.hostname = hostname- site.displayName = hostname- site.modeRaw = SiteMode.taught.rawValue- context.insert(site)-- let pattern = AsterismSchemaV7.TitlePattern()- pattern.id = patternID- pattern.version = patternVersion- pattern.isActive = true- pattern.createdAt = timestamp- pattern.formRaw = PatternForm.phrase.rawValue- pattern.phrasePrefix = prefix- pattern.phraseSeparator = separator- pattern.phraseSuffix = suffix- pattern.fieldOrderRaw = order.rawValue- pattern.trimPrefix = LibraryRepository.m4FixtureTrimPrefix- pattern.trimSuffix = LibraryRepository.m4FixtureTrimSuffix- pattern.chapterless = false- context.insert(pattern)- pattern.site = site-- let rule = AsterismSchemaV7.URLRulePattern()- rule.id = urlRuleID- rule.version = urlRuleVersion- rule.isCurrent = true- rule.createdAt = timestamp- rule.originRaw = URLRuleOrigin.readerTaught.rawValue- rule.definitionData = try JSONEncoder().encode(- LibraryRepository.m4FixtureURLDefinition())- context.insert(rule)- rule.site = site-- for workIndex in 0..<workCount {- let title = LibraryRepository.m4FixtureWorkTitle(workIndex: workIndex)- let work = AsterismSchemaV7.Work()- work.id = LibraryRepository.m4FixtureUUID(namespace: 31, index: workIndex)- work.displayTitle = title- work.lastParsedTitle = title- // The six columns the pass moves into a membership. The M4- // fixture's URL rule carries a sequence locator and no work- // locator, so its Works settle no URL identity — which is the- // `.none` state the mint copies through.- work.siteHostname = hostname- work.urlIdentityStateRaw = WorkURLIdentityState.none.rawValue- work.titleProvenanceRaw = TitleProvenance.parsed.rawValue- work.createdAt = timestamp.addingTimeInterval(TimeInterval(workIndex))- work.modifiedAt = work.createdAt- context.insert(work)- work.site = site-- for chapter in 1...entriesPerWork {- let entryIndex = workIndex * entriesPerWork + (chapter - 1)- let rawURL = LibraryRepository.m4FixtureRawURL(- hostname: hostname, chapter: chapter, uniqueBy: entryIndex)- let entry = AsterismSchemaV7.Entry()- entry.id = LibraryRepository.m4FixtureUUID(namespace: 32, index: entryIndex)- entry.captureTitle = LibraryRepository.m4FixtureCaptureTitle(- workIndex: workIndex, chapter: chapter)- entry.captureTitleSourceRaw = CaptureTitleSource.host.rawValue- entry.rawURLString = rawURL- entry.conservativeIdentityKey = rawURL- entry.hostname = hostname- // The v3 composed arm: the identity key embeds the- // title-derived Work name, so both cited rules are live and- // the citation blob the pass writes carries five citations- // rather than one.- entry.entryIdentityKey = EntryIdentityKeyV3Codec.encode(- try URLSequenceNameIdentity(- hostname: ExactScalarString(hostname),- workName: ExactScalarString(title),- chapterSequence: ExactScalarString(String(chapter))))- entry.identityBasisRaw = EntryIdentityBasis.urlRule.rawValue- entry.identityKeyVersion = 3- entry.identityURLRuleID = urlRuleID- entry.identityURLRuleVersion = urlRuleVersion- entry.identityNameTitleRuleID = patternID- entry.identityNameTitleRuleVersion = patternVersion- entry.chapterSequence = String(chapter)- entry.chapterSequenceRuleID = urlRuleID- entry.chapterSequenceRuleVersion = urlRuleVersion- entry.chapterTitle = String(chapter)- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = patternID- entry.chapterPatternVersion = patternVersion- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.workPatternID = patternID- entry.workPatternVersion = patternVersion- entry.firstCapturedAt = timestamp.addingTimeInterval(TimeInterval(entryIndex))- entry.lastSharedAt = entry.firstCapturedAt- entry.modifiedAt = entry.firstCapturedAt- context.insert(entry)- entry.work = work- entry.site = site- }- }- }- }-}- // MARK: - Fixture: the live 5,000-Entry fixture, with memberships in the graph /// The coherent M4 composed fixture on disk, certified ready, with the seeding
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swiftindex 00c999b..a7af7db 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift@@ -100,7 +100,7 @@ struct M4ScaleFixtureTests { // 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.legacySite == nil }+ let unlinkedWorks = try seeded.fetch(FetchDescriptor<Work>()).count { $0.primaryMembership?.site == nil } #expect(unlinkedEntries == 0) #expect(unlinkedWorks == 0)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swiftindex ce337ae..28b1a5c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift@@ -224,66 +224,74 @@ struct M4ScalePerformanceTests { /// tier consults, and the number came back under the ceiling; the /// `withKnownIssue` that stood here is gone with the breach. ///- /// **And it has caught it again, this time by design.** See- /// `membershipPassArrivalKnownIssue`: `multi-site-works` put two- /// whole-library passes on `reconcileAfterSync`, and the **full**-tier- /// number is 1.06–1.12 s over four runs. The 10 ms ceiling is kept and- /// asserted inside a `withKnownIssue`, because it is still the number this- /// path *should* have;- /// `reconcileNoOpRegressionCeiling` is what a run has to clear to pass. The- /// arrival tier, which Decision 5 gates, is measured beside it and is inside- /// the old band's order of magnitude at 0.030 s.+ /// **And it has caught it again, this time by design — and V9 has recovered+ /// most of it.** `multi-site-works` put two whole-library passes on+ /// `reconcileAfterSync` and the **full**-tier number went to 1.06–1.12 s+ /// over four runs. `drop-superseded-columns` deleted `V8PopulationPass` with+ /// the columns it converted from and gated the heal on the diagnosis (Q10),+ /// and the number is **0.0296–0.0302 s** over two runs — 36× faster, and 3.0× what is still a+ /// 10 ms ceiling. The ceiling is kept and asserted inside a+ /// `withKnownIssue`, because it is still the number this path *should*+ /// have; `reconcileNoOpRegressionCeiling` is what a run has to clear to+ /// pass, and `membershipPassArrivalKnownIssue` below says what the 30 ms is. private let reconcileNoOpCeiling = Duration.milliseconds(10) /// The floor under the accepted breach, in the shape Req 5.5 and Req 10.1- /// use: generous over the measured 1.06–1.12 s, and far enough under a- /// doubling to refuse a *new* problem rather than the recorded one. Shared- /// with the arrival measurement, whose own guard is the ratio beside it —- /// one absolute floor means a host-wide slowdown fails both rather than- /// neither.- private let reconcileNoOpRegressionCeiling = Duration.seconds(2)-- /// **A full-tier no-op pass costs 1.07–1.09 s over a converged 5,000-Entry+ /// use. **Brought down from 2 s to 100 ms by `drop-superseded-columns`**+ /// (Q33 of that spec): it was drawn over a 1.06–1.12 s measurement that has+ /// since fallen to 0.0296–0.0302 s, and a floor 66× above the number it guards+ /// refuses nothing. 100 ms is ~3.3× the measured median-of-20, which is+ /// **looser** than the 1.4–2× its neighbours carry, deliberately: those+ /// bounds sit over second-scale measurements, and at the 30 ms scale a+ /// scheduling hiccup is a proportionally larger share of the sample, so the+ /// same ratio would make the floor a flake. Tightening a floor onto a+ /// number that improved is the opposite of the move the house discipline+ /// forbids, which is raising one to make a run pass. Shared with the+ /// arrival measurement: one absolute floor means a host-wide slowdown+ /// fails both rather than neither.+ private let reconcileNoOpRegressionCeiling = Duration.milliseconds(100)++ /// **A full-tier no-op pass costs 0.0296–0.0302 s over a converged 5,000-Entry /// library, against a 10 ms ceiling, and this suite records that rather than /// raising the ceiling.** ///- /// `reconcileAfterSync` runs two whole-library passes before the ones it- /// already ran (`LibraryRepository.swift:378-386`):+ /// The 1.07–1.09 s this comment used to record was two whole-library passes+ /// `multi-site-works` put in front of everything `reconcileAfterSync`+ /// already ran. **Both are gone.** `V8PopulationPass` is deleted with the+ /// columns it converted from, and `MembershipReconciler.heal` — whose+ /// whole-Entry-table enumeration `M4MembershipScalePerformanceTests`+ /// measures on its own at 0.38–0.50 s — now runs only where the diagnosis+ /// counts a Work with no membership (Q10 of `drop-superseded-columns`),+ /// which a converged library never does. ///- /// * `V8PopulationPass.run` fetches the whole `Work`, `Entry` and- /// `TitlePattern` tables. Its citation phase is a **value** guard by- /// design (Decision 4) — it re-derives each of the 5,000 Entries'- /// citations from the columns and compares — so a converged library pays- /// for the comparison on every full-tier pass.- /// * `MembershipReconciler.heal` enumerates the whole `Entry` table, which- /// `M4MembershipScalePerformanceTests` measures on its own at- /// 0.494–0.513 s. It is `context.enumerate` rather than a fetch, so it- /// materialises little — but it is still the largest table in the library.+ /// What is left is ~30 ms, and it is **not** the conversion passes coming+ /// back: `reconcile-noop-arrival` beside this measurement is 0.0301–0.0303 s+ /// and `duplicate-arrival-pass-gated` next door 0.0301–0.0314 s, so all three+ /// tiers and gates now cost the same thing. That thing is the floor of a pass:+ /// the lock, a fresh `ModelContext`, `reconcileWorkLists`, the Site phase+ /// over a library with no duplicate hostnames, and+ /// `MembershipReconciler`'s **dedupe** phases, which Q10 deliberately left+ /// unconditional — they fetch the 1,000 membership rows on every pass,+ /// whichever tier asked. ///- /// **Task 22's review took this off the arrival debounce and left it on the- /// full tier, deliberately (Decision 5).** An arrival now runs those phases- /// only where the last tolerance scan counted rows for them, and- /// `reconcile-noop-arrival` beside this measurement is what that costs:- /// 0.030 s in both of the review's runs, a thirty-fifth of the figure here.- /// The full tier stays unconditional because only its value guard can see a- /// blob gone stale under its columns — a state no counter can count, because- /// the row *has* a blob — and the full tier is launch and the duplicate- /// follow-up, not every remote change.- ///- /// So the breach that remains is **by construction rather than unrouted**:- /// what it costs is one pass per launch, on a path no reader waits on- /// (Q45 of `cloudkit-mirroring`), and it goes when V9 (T-2272) drops the- /// columns and the phase that mirrors them. The 10 ms ceiling stays where it- /// is because it is still the number this path *should* have; it is asserted- /// inside the known-issue block, and `reconcileNoOpRegressionCeiling` is- /// what a run has to clear. See `specs/multi-site-works/verification-run.md`.+ /// So the remaining breach is 30 ms of membership dedupe against a ceiling+ /// drawn in 2024 over a library that had no membership table, on a path no+ /// reader waits on (Q45 of `cloudkit-mirroring`). Closing it means gating+ /// the dedupe phases the way the heal is gated, which is a design decision+ /// `drop-superseded-columns` does not take: the cost is recorded as a+ /// follow-up comment on **T-2280** and is a candidate research item for+ /// `specs/post-v8-convergence`, with no ticket of its own.+ /// See `specs/drop-superseded-columns/verification-run.md`. private static let membershipPassArrivalKnownIssue: Comment = """- The full-tier no-op pass is 1.06-1.12 s against its 10 ms regression \- ceiling: multi-site-works put V8PopulationPass and MembershipReconciler \- on reconcileAfterSync, both of which read the whole library. Decision 5 \- gated them off the arrival tier (see reconcile-noop-arrival, 0.030 s in \- the same run) and left them unconditional on the full tier, which is \- launch and the duplicate follow-up. Host-only measurement. See the \- comment above this test and specs/multi-site-works/verification-run.md.+ The full-tier no-op pass is 0.0296-0.0302 s against its 10 ms regression \+ ceiling — down 36x from the 1.06-1.12 s multi-site-works measured, \+ because drop-superseded-columns deleted V8PopulationPass and gated \+ MembershipReconciler.heal on the diagnosis (Q10). What is left is the \+ floor of a pass plus the membership dedupe phases, which stay \+ unconditional and fetch the 1,000 membership rows every time; \+ reconcile-noop-arrival and duplicate-arrival-pass-gated measure the \+ same floor over the same two runs. Host-only measurement. See the \+ comment above this test and \+ specs/drop-superseded-columns/verification-run.md. """ /// The consolidation measures 39.2–41.3 s (medians over three runs) at the /// current constant. ~1.33× the top of that band, the same margin the@@ -300,12 +308,14 @@ struct M4ScalePerformanceTests { /// reconcile — the overwhelmingly common case, since `reconcileAfterSync()` /// runs on every remote-change debounce and once per launch (Q45). ///- /// The pass has no hostnames to consolidate here, so what is timed is the- /// floor: the lock, a fresh `ModelContext`, the two `site == nil` fetches- /// Req 1.8's heal makes over a 5,000-Entry graph, and — since- /// `multi-site-works` — the two whole-library membership passes described in- /// `membershipPassArrivalKnownIssue`. The guards below are what keep it from- /// being a measurement of an empty store.+ /// The pass has no hostnames to consolidate here, and since+ /// `drop-superseded-columns` it has no conversion pass to run and no heal to+ /// arm (Q10), so what is timed is the floor: the lock, a fresh+ /// `ModelContext`, `reconcileWorkLists`, the Site phase with nothing to+ /// consolidate, and `MembershipReconciler`'s unconditional dedupe phases+ /// over the 1,000 membership rows. See `membershipPassArrivalKnownIssue`.+ /// The guards below are what keep it from being a measurement of an empty+ /// store. @Test("No-op reconcile over the coherent 5,000-Entry fixture (Req 1.7)") func reconcileNoOpOverCoherentFixture() async throws { let (configuration, root) = try await seedReadyStore()@@ -352,10 +362,8 @@ struct M4ScalePerformanceTests { (p95 \(measured.p95), spread \(measured.spread)x) """) - // **The tier the debounce actually runs** (Decision 5). Above is the- // full tier — launch and the duplicate follow-up — which keeps the- // conversion passes unconditional because only its value guard can see a- // blob gone stale under its columns. Every remote-change arrival takes+ // **The tier the debounce actually runs**. Above is the full tier —+ // launch and the duplicate follow-up. Every remote-change arrival takes // this path instead, and its gate reads the counters the refresh below // produces, exactly as `handleSyncArrivals` does. try await repository.refreshDiagnostics()@@ -364,18 +372,17 @@ struct M4ScalePerformanceTests { } expectWithinCeiling( "reconcile-noop-arrival", arrival, reconcileNoOpRegressionCeiling)- // A **ratio** beside it, not a second band: this label has one run- // behind it, and what it has to catch is someone making the arrival tier- // unconditional again — which would put it back beside the full tier- // rather than at a fraction of it. A ratio says that without pinning a- // number this host measured once.- #expect(- arrival.median * 4 <= measured.median,- """- reconcile-noop-arrival median \(arrival.median) is not comfortably under \- the full tier's \(measured.median) — the arrival gate (Decision 5) is \- not narrowing the pass (p95 \(arrival.p95), spread \(arrival.spread)x)- """)+ // **The `arrival.median * 4 <= measured.median` ratio that stood here is+ // gone with what it measured** (Q34 of `drop-superseded-columns`).+ // Decision 5 of `multi-site-works` gated the two whole-library+ // conversion passes off the arrival tier and left them on the full one,+ // and the ratio pinned that gap: 0.030 s against 1.07 s. V9 deleted both+ // passes, so the gap it asserted no longer exists — 0.0301 s against+ // 0.0302 s in the run that found this — and an assertion that the two tiers+ // differ by 4× would now fail on correct code. What the tiers still+ // choose between is the duplicate phase (`duplicatePhaseRuns`), and+ // `duplicate-arrival-pass-gated` next door is the label that measures+ // that. The shared absolute ceiling above is what guards this one. } /// The Q27 shape, run against the chunked design (Q45).
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swiftindex ce1886e..ca2fa4a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift@@ -52,9 +52,8 @@ struct M5SeedWork: Sendable { var membershipID: UUID = UUID() var displayTitle: String var hostname: String- var type: WorkType = .other- /// The V6 configured-type citation. Set it beside `type: .other` for a work- /// the updated build typed; set `type` instead for a legacy-typed one.+ /// The V6 configured-type citation, which is the **only** type a Work has+ /// since V9 dropped the pre-feature `typeRaw` column. var workTypeID: UUID? var titleProvenance: TitleProvenance = .manual var lastParsedTitle: String?@@ -65,7 +64,6 @@ struct M5SeedWork: Sendable { init( id: UUID, membershipID: UUID = UUID(), displayTitle: String, hostname: String,- type: WorkType = .other, workTypeID: UUID? = nil, titleProvenance: TitleProvenance = .manual, lastParsedTitle: String? = nil, genericNotes: String = "", workURLString: String? = nil, urlIdentity: String? = nil,@@ -75,7 +73,6 @@ struct M5SeedWork: Sendable { self.membershipID = membershipID self.displayTitle = displayTitle self.hostname = hostname- self.type = type self.workTypeID = workTypeID self.titleProvenance = titleProvenance self.lastParsedTitle = lastParsedTitle@@ -336,14 +333,11 @@ extension LibraryRepository { title: seed.displayTitle, hostname: seed.hostname, site: siteRows[seed.hostname], timestamp: seed.createdAt)- // Retained but unread from V8 (Req 10.3): seeded so a test can- // assert the column is left alone, never to give the Work a type.- work.typeRaw = seed.type.rawValue work.workTypeID = seed.workTypeID work.titleProvenance = seed.titleProvenance work.lastParsedTitle = seed.lastParsedTitle work.genericNotes = seed.genericNotes- work.setLegacyColumns {+ work.primaryMembershipEdit { $0.workURLString = seed.workURLString $0.urlIdentity = seed.urlIdentity if seed.urlIdentity != nil { $0.urlIdentityState = .legacyUnverified }@@ -373,9 +367,16 @@ extension LibraryRepository { entry.chapterTitle = seed.chapterTitle entry.chapterSequence = seed.chapterSequence if let provenance = seed.chapterTitleProvenance {- entry.chapterTitleProvenance = provenance+ // The blob types the arm, so a `.pattern` kind with no rule+ // to cite is no provenance at all — `tolerant` is where that+ // normalisation (Q39) already lives, and it is what these+ // seeds mean: a derived title, not a reader's.+ entry.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: provenance, patternID: nil, patternVersion: nil)+ } } else if seed.chapterTitle != nil {- entry.chapterTitleProvenance = .manual+ entry.editCitations { $0.chapterTitle = .manual } } entry.intentionallyUnattached = seed.intentionallyUnattached context.insert(entry)@@ -394,7 +395,11 @@ extension LibraryRepository { context.fetch( FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID }))) entry.work = rows[seed.workRowIndex]- entry.workAssignmentProvenance = seed.workAssignmentProvenance+ entry.editCitations {+ $0.workAssignment = seed.workAssignmentProvenance == .manual+ ? .manual+ : .none+ } } } @@ -538,13 +543,16 @@ struct M5UnattachmentFields: Sendable, Equatable { var hasWork: Bool var intentionallyUnattached: Bool var assignmentProvenance: FieldProvenanceKind- var hasWorkPatternID: Bool- var hasWorkPatternVersion: Bool+ /// Whether the citation blob names a work title pattern. It was two fields,+ /// `hasWorkPatternID` and `hasWorkPatternVersion`, back when the citation+ /// was a column pair that could half-arrive; `EntryCitations.WorkAssignment`+ /// types the arm, so the id and the version are one answer.+ var citesWorkPattern: Bool } extension LibraryRepository {- /// Read raw, because the snapshot deliberately does not carry the pattern- /// citation the unattachment clears.+ /// Read from the blob, because the snapshot deliberately does not carry the+ /// pattern citation the unattachment clears. func m5UnattachmentFields(of id: UUID) async throws -> M5UnattachmentFields { try await withLockedContext(mode: .shared, operation: "reading unattachment fields") { context in@@ -557,8 +565,7 @@ extension LibraryRepository { hasWork: row.work != nil, intentionallyUnattached: row.intentionallyUnattached, assignmentProvenance: row.workAssignmentProvenance,- hasWorkPatternID: row.workPatternID != nil,- hasWorkPatternVersion: row.workPatternVersion != nil)+ citesWorkPattern: row.citationValues.workAssignment.patternRule != nil) } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swiftindex 356adce..72d3644 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift@@ -11,12 +11,16 @@ import Testing /// the app. The defence is the readiness marker, read *before* either process /// constructs a container. ///-/// **The app opens two digits and the extension one.** `multi-site-works`-/// publishes `"8"` and adds `"7"` back to `appOpenableMarkerVersions` as the-/// generation V8 upgrades from (Req 2.2): the app runs the population pass and-/// republishes, while the extension refuses `"7"` outright, because it holds-/// only a shared lock and must never convert or write. `"4"`, `"5"` and `"6"`-/// stay retired (`data-model-cleanups` Decision 2) and are refused by both.+/// **The app opens two digits and the extension one.** `drop-superseded-columns`+/// publishes `"9"` and holds `"8"` in `appOpenableMarkerVersions` as the+/// generation V9 upgrades from (Req 2.2): the app opens it — the lightweight+/// stage drops the superseded columns inside `ModelContainer.init` — validates+/// the store and republishes at `"9"`, with no data pass and no reconciler+/// (Q9). The extension refuses `"8"` outright, because it holds only a shared+/// lock and must never convert or write, and for V9 that matters more than it+/// did for V8: the conversion it would otherwise run **removes** columns.+/// `"4"`–`"7"` stay retired (`data-model-cleanups` Decision 2, and Q2 of+/// `drop-superseded-columns` for `"7"`) and are refused by both. /// /// The extension's refusal therefore **forks** (Req 2.3), as it did before /// Decision 2 collapsed it: a generation the app opens is resolvable by opening@@ -47,7 +51,7 @@ struct MarkerContractTests { /// 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- /// `"8"` directly (Q26).+ /// `"9"` directly (Q26). private func makeReadyLibrary(_ configuration: LibraryConfiguration) async throws { _ = try await LibraryRepository.openForApp(configuration) }@@ -78,47 +82,48 @@ struct MarkerContractTests { reason: "the containing app has not initialized the current library") /// The extension's refusal for a generation **the app still opens** — the- /// update window, where opening the app runs the population pass and moves- /// the marker on (Req 2.3).+ /// update window, where opening the app converts the store and moves the+ /// marker on (Req 2.3). private static let openTheApp = LibraryRepositoryError.libraryUnavailable( operation: "opening library from extension", reason: "Open Asterism to finish updating the library") // MARK: - App side accepts one generation - @Test("The app opens a library marked \"8\"")+ @Test("The app opens a library marked \"9\"") func appAcceptsTheCurrentMarkerVersion() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- #expect(try markerContent(cfg) == "8",+ #expect(try markerContent(cfg) == "9", "an empty store has nothing to bring forward, so it is certified at birth (Q26)") let (current, _) = try await LibraryRepository.openForApp(cfg) #expect(current == .ready(.seededEmpty), "a certified library opens in the app")- #expect(try markerContent(cfg) == "8", "and the open leaves the marker as it found it")+ #expect(try markerContent(cfg) == "9", "and the open leaves the marker as it found it") } - /// Req 2.2: the previous generation is *opened*, not refused — the app runs- /// the population pass and republishes at the current one.- @Test("The app opens a library marked \"7\" and republishes it at \"8\"")+ /// Req 2.2: the previous generation is *opened*, not refused — the stage+ /// drops the superseded columns on the way in, and the app republishes at+ /// the current generation once the store has validated (Q9).+ @Test("The app opens a library marked \"8\" and republishes it at \"9\"") func appUpgradesTheLaggingGeneration() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- try writeMarker(cfg, "7\n")+ try writeMarker(cfg, "8\n") let (result, repository) = try await LibraryRepository.openForApp(cfg) await repository.shutdown() #expect(result == .ready(.seededEmpty))- #expect(try markerContent(cfg) == "8",- "the marker moves only after the pass has completed and certified itself")+ #expect(try markerContent(cfg) == "9",+ "the marker moves only after the converted store has validated") } /// The retired generations sit in this list beside the digits no build ever- /// published, which is the point of Decision 2: `"4"`, `"5"` and `"6"` are+ /// published, which is the point of Decision 2: `"4"` through `"7"` are /// now exactly as openable as `"45"`. @Test("The app fails closed on every marker version it does not open",- arguments: ["4\n", "5\n", "6\n", "3\n", "9\n", "45\n", "", "four\n"])+ arguments: ["4\n", "5\n", "6\n", "7\n", "3\n", "45\n", "", "four\n"]) func appRejectsEveryOtherMarkerVersion(content: String) async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)@@ -133,7 +138,7 @@ struct MarkerContractTests { /// The refusal names the digit, so the one library this can happen to says /// which generation it is on rather than only that it is wrong.- @Test("The refusal names the marker generation it found", arguments: ["4", "5", "6"])+ @Test("The refusal names the marker generation it found", arguments: ["4", "5", "6", "7"]) func appRefusalNamesTheRetiredGeneration(digit: String) async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)@@ -150,11 +155,11 @@ struct MarkerContractTests { // MARK: - Extension side requires the current version - @Test("The extension opens a library marked \"8\"")+ @Test("The extension opens a library marked \"9\"") func extensionAcceptsTheCurrentVersion() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- #expect(try markerContent(cfg) == "8")+ #expect(try markerContent(cfg) == "9") let (result, _) = try await LibraryRepository.openForExtension(cfg) #expect(result == .ready(.seededEmpty))@@ -171,27 +176,28 @@ struct MarkerContractTests { } /// Req 8.7 of `configurable-work-types`, now with the live digit: between- /// the app being updated and first launched the library still records `"7"`,+ /// the app being updated and first launched the library still records `"8"`, /// and a capture in that window must fail safely rather than convert the- /// store under a shared lock. The message is the actionable one, because- /// opening the app is what resolves it (Req 2.3).+ /// store under a shared lock — a V9 conversion **drops columns**, so the+ /// hazard is destructive rather than merely additive. The message is the+ /// actionable one, because opening the app is what resolves it (Req 2.3). @Test("The extension declines the update window and says to open the app") func extensionDeclinesTheUpdateWindow() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- try writeMarker(cfg, "7\n")+ try writeMarker(cfg, "8\n") await #expect(throws: Self.openTheApp) { try await LibraryRepository.openForExtension(cfg) }- #expect(try markerContent(cfg) == "7", "the extension may not republish readiness")+ #expect(try markerContent(cfg) == "8", "the extension may not republish readiness") } /// The other half of the fork: a generation the app does not open either /// keeps the "has not initialized" wording, because opening the app would /// not resolve it. @Test("The extension declines a retired generation with the unknown-digit message",- arguments: ["5", "6"])+ arguments: ["5", "6", "7"]) func extensionDeclinesARetiredGeneration(retired: String) async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)@@ -206,27 +212,27 @@ struct MarkerContractTests { @Test("The extension declines a \"4\" marker before it constructs a ModelContainer") func extensionDeclinesBeforeOpeningAContainer() async throws { let (_, cfg) = try config()- // A genuinely 5.0.0-recorded store, not a corrupt one: the container- // *would* open it, converting it to 7.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, which is why this uses- // the frozen-snapshot seed rather than the 4.0.0 fixture the declared- // V5 → V6 stage now refuses outright.- try V5RecordedStoreFixture.install(at: cfg.storeURL)+ // A genuinely 8.0.0-recorded store, not a corrupt one: the container+ // *would* open it, converting it to 9.0.0 in a process holding only a+ // shared lock — and that conversion **drops columns**, so the hazard+ // (Q14) is destructive now rather than merely additive. A store that+ // cannot be opened at all would prove nothing about the ordering, which+ // is why this uses the frozen-snapshot seed.+ try V8RecordedStoreFixture.install(at: cfg.storeURL) try writeMarker(cfg, "4\n") await #expect(throws: Self.declined) { try await LibraryRepository.openForExtension(cfg) }- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["5.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["8.0.0"], "the marker check must decide before ModelContainer.init converts anything") - // Control: with an "8" marker the same store is reached, opened, and+ // Control: with a "9" 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, "8\n")+ try writeMarker(cfg, "9\n") _ = try await LibraryRepository.openForExtension(cfg)- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["8.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["9.0.0"], "the same store converts once the marker check passes") } @@ -238,7 +244,7 @@ struct MarkerContractTests { func extensionRejectsUnknownMarkerVersions() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- try writeMarker(cfg, "9\n")+ try writeMarker(cfg, "10\n") await #expect(throws: Self.declined) { try await LibraryRepository.openForExtension(cfg)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationEightTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationNineTests.swiftsimilarity index 50%rename from Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationEightTests.swiftrename to Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationNineTests.swiftindex 1b68966..7b9a4e1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationEightTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationNineTests.swift@@ -4,19 +4,19 @@ import Testing @testable import AsterismCore -/// The `"7"` → `"8"` generation, end to end (Req 2.2, 2.3, 2.4).+/// The `"8"` → `"9"` generation, end to end (Req 2.2, 2.3, 2.4). ///-/// `data-model-cleanups` Decision 2 left the project with one marker digit and-/// no lagging arm at all, and its own note said the next bump would have to-/// reconstruct one. This suite is what says the reconstruction is right: the-/// classifier's new row, the acting arm's sequence, the failure that must leave-/// the marker where it found it, and both halves of the extension's restored-/// fork.+/// V9's arm is shorter than V8's: the lightweight stage drops the superseded+/// columns inside `ModelContainer.init`, so there is no data pass and no+/// reconciler to run after it (Q9). What is left is the sequence — open,+/// validate, publish — the failure that must leave the marker where it found+/// it, and both halves of the extension's fork, which is what keeps a+/// share-sheet invocation out of a conversion that destroys the source (Q3). ///-/// Every case runs over a store the **V7 classes** wrote, because what the arm-/// converts is a library the previous build left behind.-@Suite("Marker generation 8", .serialized)-struct MarkerGenerationEightTests {+/// Every case runs over a library a **V8 build** left behind: a store recorded+/// at 8.0.0 with its memberships minted and its blobs filled, marked `"8"`.+@Suite("Marker generation 9", .serialized)+struct MarkerGenerationNineTests { private final class Root { let url: URL@@ -29,11 +29,12 @@ struct MarkerGenerationEightTests { } deinit { try? FileManager.default.removeItem(at: url) } - /// A genuinely 7.0.0-recorded library carrying the `"7"` marker: what an- /// installed device holds on the morning of the update.- func seedV7Library() throws {- try V7RecordedStoreFixture.install(at: configuration.storeURL)- try writeMarker("7\n")+ /// What a device that has run the V8 build holds: the store recorded at+ /// 8.0.0 with its memberships minted and its blobs filled, and the+ /// marker at `"8"`.+ func seedV8Library() throws {+ try V8RecordedStoreFixture.install(at: configuration.storeURL)+ try writeMarker("8\n") } func writeMarker(_ content: String) throws {@@ -48,31 +49,31 @@ struct MarkerGenerationEightTests { // MARK: - Req 2.2: the classification - @Test("A \"7\" marker over a store classifies as the lagging generation")- func sevenIsLagging() throws {+ @Test("An \"8\" marker over a store classifies as the lagging generation")+ func eightIsLagging() throws { let root = try Root()- try root.seedV7Library()+ try root.seedV8Library() #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)- == .markerLagging(generation: "7"))+ == .markerLagging(generation: "8")) withExtendedLifetime(root) {} } - @Test("An \"8\" marker over a store classifies ready")- func eightIsReady() throws {+ @Test("A \"9\" marker over a store classifies ready")+ func nineIsReady() throws { let root = try Root()- try root.seedV7Library()- try root.writeMarker("8\n")+ try root.seedV8Library()+ try root.writeMarker("9\n") #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready) withExtendedLifetime(root) {} } @Test("Any other digit is unrecognised, and the refusal names it",- arguments: ["4", "5", "6", "9"])+ arguments: ["4", "5", "6", "7"]) func otherDigitsAreUnrecognised(digit: String) throws { let root = try Root()- try root.seedV7Library()+ try root.seedV8Library() try root.writeMarker("\(digit)\n") guard case .unrecognised(let reason) = try LibraryRepository.classify(@@ -86,14 +87,14 @@ struct MarkerGenerationEightTests { // MARK: - Req 2.2: the arm - /// The whole sequence: the store converts on the way in, the pass fills the- /// memberships and blobs, `assertComplete` certifies it, the marker moves,- /// and validation runs *after* the marker with diagnoses rather than a- /// refusal (Q35).- @Test("The app arm converts, populates, certifies and republishes")+ /// The whole sequence: the store converts on the way in, it validates, and+ /// only then does the marker move (Q9). Nothing else runs — the memberships+ /// and blobs the V8 arm had to fill are already filled, and the launch+ /// reconcile handles anything that arrived since.+ @Test("The app arm converts, validates and republishes") func armRunsTheWholeSequence() async throws { let root = try Root()- try root.seedV7Library()+ try root.seedV8Library() let (result, repository) = try await LibraryRepository.openForApp(root.configuration) defer { withExtendedLifetime(root) {} }@@ -104,10 +105,10 @@ struct MarkerGenerationEightTests { return } #expect(counts.works == 1)- #expect(counts.entries == 2)- #expect(try root.markerText() == "8")+ #expect(counts.entries == 3)+ #expect(try root.markerText() == "9") #expect(try V4RecordedStoreFixture.recordedModelVersions(at: root.configuration.storeURL)- == ["8.0.0"])+ == ["9.0.0"], "the store the arm opened is recorded at the version it converted to") let facts = try await repository.withLockedContext( mode: .shared, operation: "reading the converted library"@@ -122,10 +123,15 @@ struct MarkerGenerationEightTests { } await repository.shutdown() #expect(facts.memberships == [- "\(V7RecordedStoreFixture.hostname)|\(V7RecordedStoreFixture.workIdentity)"- + "|\(V7RecordedStoreFixture.workID.uuidString)",+ "\(V8RecordedStoreFixture.hostname)|\(V8RecordedStoreFixture.workIdentity)"+ + "|\(V8RecordedStoreFixture.workID.uuidString)", ])- #expect(facts.blobless == 0)+ // The arm converts nothing, which is the point of Q9 — so the fixture's+ // one nil-blob Entry is still nil-blob on the far side. The `"8"` arm+ // ran a pass that would have filled it; this one has no pass and the+ // row is a report instead (Q25). `V8RecordedStoreTests` asserts what+ // else survived on that row.+ #expect(facts.blobless == 1) } /// A second open of the same library takes the `.ready` arm and changes@@ -133,7 +139,7 @@ struct MarkerGenerationEightTests { @Test("The second open is an ordinary ready open") func secondOpenIsReady() async throws { let root = try Root()- try root.seedV7Library()+ try root.seedV8Library() let (_, first) = try await LibraryRepository.openForApp(root.configuration) await first.shutdown() @@ -145,99 +151,140 @@ struct MarkerGenerationEightTests { Issue.record("expected a ready library, got \(result)") return }- #expect(try root.markerText() == "8")+ #expect(try root.markerText() == "9") withExtendedLifetime(root) {} } - /// Req 2.4: a failure in the pass leaves `"7"` on disk and fails the open,- /// and the next open re-enters the arm and completes it. The marker goes- /// **after** the work it certifies, so there is nothing to unwind.- @Test("A failing pass leaves the marker at \"7\", and the next open completes it")- func aFailedPassLeavesTheMarkerAlone() async throws {+ /// Req 2.4: **the marker goes last**, so a throw anywhere above it leaves+ /// `"8"` on disk and the next open re-enters the arm.+ ///+ /// The arm has exactly two things that can throw — the open and the+ /// validation — and both are above `publishReadiness`. This drives the open,+ /// because it is the one a test can force: the store is replaced by bytes no+ /// coordinator will read, restored, and opened again. What it pins is the+ /// ordering, not which of the two failed.+ @Test("A failed open leaves the marker at \"8\", and the next open completes it")+ func aFailedOpenLeavesTheMarkerAlone() async throws { let root = try Root()- try root.seedV7Library()- let failing = InstrumentedSaveStrategy()- failing.shouldFail = true+ try root.seedV8Library()+ let intact = try Data(contentsOf: root.configuration.storeURL)+ try Data("not a database".utf8).write(to: root.configuration.storeURL, options: .atomic) await #expect(throws: (any Error).self) {- try await LibraryRepository.openForApp(- root.configuration, saveStrategy: failing)+ try await LibraryRepository.openForApp(root.configuration) }- #expect(try root.markerText() == "7",- "the marker may not move over work that did not complete")+ #expect(try root.markerText() == "8",+ "the marker may not move over an open that did not complete") #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)- == .markerLagging(generation: "7"),+ == .markerLagging(generation: "8"), "the next open re-enters the same arm") + try intact.write(to: root.configuration.storeURL, options: .atomic) let (result, repository) = try await LibraryRepository.openForApp(root.configuration) await repository.shutdown() guard case .ready = result else { Issue.record("expected the retry to reach a ready library, got \(result)") return }- #expect(try root.markerText() == "8")+ #expect(try root.markerText() == "9") withExtendedLifetime(root) {} } - /// Q35: validation runs after the marker and opens with diagnoses rather- /// than refusing, exactly as the `.ready` arm does — a library that opened- /// on V7 opens on V8, quarantines and all.+ /// Validation opens with diagnoses rather than refusing, exactly as the+ /// `.ready` arm does — a library that opened on V8 opens on V9, quarantines+ /// and all. Only a validation that *throws* stops the marker. @Test("A library with a quarantined hostname still opens, and reports it") func validationDiagnosesRatherThanRefuses() async throws { let root = try Root()- try root.seedV7Library()- // Break the cited chapter pattern's arm, which is a `.siteTuple`- // quarantine on V7 and must stay one on V8.+ try root.seedV8Library()+ // Break the cited chapter pattern's definition, which is a+ // `.unreadableTitlePattern` quarantine on V8 and must stay one on V9. do { let container = try LibraryRepository.openContainer(at: root.configuration.storeURL) let context = ModelContext(container) let pattern = try #require(context.fetch(FetchDescriptor<TitlePattern>()).first)- pattern.formRaw = PatternForm.segment.rawValue- pattern.phrasePrefix = nil- pattern.phraseSeparator = nil- pattern.phraseSuffix = nil- pattern.fieldOrderRaw = nil+ // The blob is where a definition lives since V9, so this is what a+ // rule the reader taught and something later broke looks like.+ pattern.definitionData = nil try context.save() withExtendedLifetime(container) {} } let (result, repository) = try await LibraryRepository.openForApp(root.configuration) let quarantined = await repository.quarantineReason(- hostname: V7RecordedStoreFixture.hostname)+ hostname: V8RecordedStoreFixture.hostname) await repository.shutdown() guard case .ready = result else { Issue.record("a diagnosable library must still open, got \(result)") return }- #expect(try root.markerText() == "8")- #expect(quarantined != nil, "the broken title rule quarantines its hostname, as on V7")+ #expect(try root.markerText() == "9")+ #expect(quarantined != nil, "the broken title rule quarantines its hostname, as on V8")+ withExtendedLifetime(root) {}+ }++ /// Q36's other half: **the cleanup goes after the publish**, so a marker+ /// write that fails leaves the historical marker and the migration sidecar+ /// where the next open expects to find them.+ ///+ /// The publish is forced to fail by taking write permission off the+ /// directory the readiness marker sits in. The **sidecar** is the+ /// load-bearing half of the assertion: it lives beside the store, in a+ /// directory that stays writable, so `clearResidualEvidence` would have+ /// removed it had the arm reached it.+ @Test("A failed publish leaves the historical marker and the sidecar in place")+ func aFailedPublishKeepsTheResidualEvidence() throws {+ let root = try Root()+ try root.seedV8Library()+ try Data("3\n".utf8).write(+ to: root.configuration.historicalMarkerURL, options: .atomic)+ try Data("stale\n".utf8).write(+ to: root.configuration.migrationSidecarURL, options: .atomic)++ let files = FileManager.default+ try files.setAttributes(+ [.posixPermissions: NSNumber(value: Int16(0o500))], ofItemAtPath: root.url.path)+ defer {+ try? files.setAttributes(+ [.posixPermissions: NSNumber(value: Int16(0o700))], ofItemAtPath: root.url.path)+ }++ #expect(throws: (any Error).self) {+ try LibraryRepository.act(+ on: .markerLagging(generation: "8"), root.configuration, hooks: .production)+ }+ #expect(try root.markerText() == "8",+ "the marker may not move over a publish that did not complete")+ #expect(files.fileExists(atPath: root.configuration.historicalMarkerURL.path))+ #expect(files.fileExists(atPath: root.configuration.migrationSidecarURL.path),+ "the sidecar is in a writable directory, so only the ordering keeps it") withExtendedLifetime(root) {} } // MARK: - Req 2.3: the extension's restored fork - @Test("The extension refuses \"7\" and says to open the app")+ @Test("The extension refuses \"8\" and says to open the app") func extensionRefusesTheLaggingGeneration() async throws { let root = try Root()- try root.seedV7Library()+ try root.seedV8Library() await #expect(throws: LibraryRepositoryError.libraryUnavailable( operation: "opening library from extension", reason: "Open Asterism to finish updating the library")) { try await LibraryRepository.openForExtension(root.configuration) }- #expect(try root.markerText() == "7", "the extension may not convert or republish")+ #expect(try root.markerText() == "8", "the extension may not convert or republish") #expect(try V4RecordedStoreFixture.recordedModelVersions(at: root.configuration.storeURL)- == ["7.0.0"], "and may not let ModelContainer.init convert the store")+ == ["8.0.0"], "and may not let ModelContainer.init convert the store") withExtendedLifetime(root) {} } @Test("The extension keeps the existing reason for a digit no build opens",- arguments: ["4", "5", "6", "9"])+ arguments: ["4", "5", "6", "7"]) func extensionRefusesUnknownDigits(digit: String) async throws { let root = try Root()- try root.seedV7Library()+ try root.seedV8Library() try root.writeMarker("\(digit)\n") await #expect(throws: LibraryRepositoryError.libraryUnavailable(@@ -248,13 +295,13 @@ struct MarkerGenerationEightTests { withExtendedLifetime(root) {} } - @Test("The extension opens \"8\"")+ @Test("The extension opens \"9\"") func extensionOpensTheCurrentGeneration() async throws { let root = try Root()- try root.seedV7Library()+ try root.seedV8Library() let (_, repository) = try await LibraryRepository.openForApp(root.configuration) await repository.shutdown()- #expect(try root.markerText() == "8")+ #expect(try root.markerText() == "9") let (result, _) = try await LibraryRepository.openForExtension(root.configuration) guard case .ready = result else {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipColumnMirrorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipColumnMirrorTests.swiftdeleted file mode 100644index eb39e0c..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipColumnMirrorTests.swift+++ /dev/null@@ -1,204 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import AsterismCore--/// Decision 4's Work half, from the end that notices when it is broken.-///-/// Until V9 a Work's site presence has two homes: the `WorkSiteMembership` rows,-/// which every V8 reader consults, and the six superseded columns, which mirror-/// the **primary** membership. `BackupArchiveProjection` reads *both* for one-/// record — the hostname and the confirmed URL from the memberships, the-/// identity state, cited rule and rule version from the columns — so a write-/// that moves memberships without refreshing the mirror produces a Work record-/// naming one site and citing another's rule, and-/// `BackupArchiveReferenceChecks` refuses the whole archive.-///-/// That is the shape of all three regressions below: a removal, a heal and a-/// merge, each correct in its own subsystem and each leaving the library-/// unexportable. The suite asserts the mirror directly and then asserts the-/// export, because the mirror is the invariant and the export is the loss.-@Suite("Superseded Work columns mirror the primary membership", .serialized)-struct MembershipColumnMirrorTests {-- private static let workID = UUID(uuidString: "A1BB0000-0000-4000-8000-000000000001")!- private static let entryID = UUID(uuidString: "A1BB0000-0000-4000-8000-0000000000E1")!- private static let ruleID = UUID(uuidString: "A1BB0000-0000-4000-8000-000000000071")!-- /// A Work born on `first.example` with a rule-derived identity there and no- /// Entries, later joined to `second.example`, where its one Entry lives. The- /// primary membership is the entry-less one — which is precisely the one- /// Req 7.2 lets the reader remove.- private func twoSiteFixtureWithIdentity() async throws -> M5Fixture {- let fixture = try await M5Fixture()- try await fixture.repository.seedM5Rows(- sites: [- // Taught, because a current URL rule is only legal on a taught- // Site (`LibraryValidator`'s Site tuple), and a membership in- // state `.rule` has to be able to cite one.- M5SeedSite(hostname: "first.example", mode: .taught, wholeTitleRule: true),- M5SeedSite(hostname: "second.example"),- ],- works: [- M5SeedWork(- id: Self.workID, displayTitle: "A Serial", hostname: "first.example")- ],- entries: [])- try await fixture.repository.seedURLRule(hostname: "first.example", id: Self.ruleID)- try await fixture.repository.setMembershipIdentity(- workID: Self.workID, hostname: "first.example", urlIdentity: "42", state: .rule,- ruleID: Self.ruleID)- try await fixture.repository.seedMembership(- workID: Self.workID, hostname: "second.example",- createdAt: M5Fixture.epoch.addingTimeInterval(120))- try await fixture.repository.seedM5Rows(- entries: [- M5SeedEntry(- id: Self.entryID, captureTitle: "Chapter 1", hostname: "second.example",- path: "serial/1", workID: Self.workID)- ])- return fixture- }-- /// The archive is legal before the removal, so a refusal afterwards is the- /// removal's doing and nothing else's.- @Test("Removing the primary site re-points the columns at the survivor")- func removalRefreshesTheColumns() async throws {- let fixture = try await twoSiteFixtureWithIdentity()- try await fixture.repository.assertArchiveIsLegal("the fixture is already unexportable")-- try await fixture.repository.removeSiteMembership(- workID: Self.workID, hostname: "first.example")-- #expect(- try await fixture.repository.membershipRows(of: Self.workID).map(\.hostname)- == ["second.example"])- // The columns name the surviving site and carry none of the removed- // site's identity: a cited rule that belongs to a site the Work has left- // is what the reference checks refuse over.- let workID = Self.workID- try await fixture.repository.withLockedContext(- mode: .shared, operation: "reading the columns"- ) { context in- let work = try #require(- try context.fetch(- FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID })).first)- #expect(work.legacyColumns.hostname == "second.example")- #expect(work.legacyColumns.urlIdentity == nil)- #expect(work.legacyColumns.urlIdentityRuleID == nil)- #expect(work.legacyColumns.urlIdentityState == WorkURLIdentityState.none)- }- try await fixture.repository.assertArchiveIsLegal()- try await fixture.repository.assertWorkColumnsMirrorMemberships()- }-- /// Req 8.1's heal, on the Work shape the migration produces: one membership- /// carrying the Work's own `createdAt`. A healed row that took the same date- /// would tie with it and win the `(createdAt, id)` order half the time.- @Test("A heal on a migrated Work leaves the original membership primary")- func healKeepsTheOriginalPrimary() async throws {- let fixture = try await twoSiteFixtureWithIdentity()- // The Req 8.1 state: an Entry on a third site whose Work holds no- // membership there.- try await fixture.repository.seedM5Rows(- sites: [M5SeedSite(hostname: "third.example")],- entries: [- M5SeedEntry(- id: UUID(), captureTitle: "Chapter 2", hostname: "third.example",- path: "serial/2", workID: Self.workID)- ])- try await fixture.repository.deleteMemberships(- workID: Self.workID, hostname: "third.example")-- _ = try await fixture.repository.reconcileAfterSync()-- let rows = try await fixture.repository.membershipRows(of: Self.workID)- #expect(rows.map(\.hostname) == ["first.example", "second.example", "third.example"])- // Strictly later than every membership the Work already held, so it- // cannot become primary whatever identifier it drew.- let healed = try #require(rows.first { $0.hostname == "third.example" })- #expect(rows.filter { $0.hostname != "third.example" }.allSatisfy {- $0.createdAt < healed.createdAt- })- try await fixture.repository.assertArchiveIsLegal()- try await fixture.repository.assertWorkColumnsMirrorMemberships()- }-- /// Req 4.2's non-shared half: the source's membership *moves* to the target.- /// Where the target has no Entries on that site the planner retains the- /// identity rather than re-deriving it, so the moved row keeps its cited- /// rule — and if that row becomes the target's primary, the columns take the- /// rule's id from it. The version cannot come with it (Req 10.4, Q28), so it- /// has to be resolved from the site's rules, or the columns end up citing- /// rule *R* at whatever version the Work happened to carry before.- ///- /// The archive currently hides that: `BackupArchiveProjection` overrides the- /// stored version with the union's own rule-id → version map, so a mismatch- /// only reaches the wire for a rule the archive does not carry — which the- /// reference checks refuse for a different reason. The assertion is therefore- /// on the columns, which is where the fault is.- @Test("A merge that moves a membership into the primary slot takes its rule version too")- func mergeCarriesTheRuleVersion() async throws {- let target = UUID(uuidString: "A1BB0000-0000-4000-8000-000000000021")!- let source = UUID(uuidString: "A1BB0000-0000-4000-8000-000000000022")!- let sourceRule = UUID(uuidString: "A1BB0000-0000-4000-8000-000000000072")!- let fixture = try await M5Fixture()- try await fixture.repository.seedM5Rows(- sites: [- M5SeedSite(hostname: "first.example"),- M5SeedSite(hostname: "second.example", mode: .taught, wholeTitleRule: true),- ],- works: [- // The target's own membership is minted at the fixture epoch;- // the source's, seeded below, is older, so the moved row sorts- // first and becomes the merged Work's primary.- M5SeedWork(- id: target, displayTitle: "A Serial", hostname: "first.example",- createdAt: M5Fixture.epoch.addingTimeInterval(600)),- M5SeedWork(- id: source, displayTitle: "A Serial", hostname: "second.example"),- ],- entries: [- M5SeedEntry(- id: UUID(), captureTitle: "Chapter 1", hostname: "first.example",- path: "serial/1", workID: target)- ])- // Version 3, so a column left at the version the target's own site- // implies would not resolve.- try await fixture.repository.seedURLRule(- hostname: "second.example", id: sourceRule, version: 3)- try await fixture.repository.setMembershipIdentity(- workID: source, hostname: "second.example", urlIdentity: "99", state: .rule,- ruleID: sourceRule)-- let contract = try await fixture.repository.projectMerge(- sourceWorkID: source, targetWorkID: target)- let moved = try #require(contract.outcome.sites.first { $0.hostname == "second.example" })- // No Entries on that site for either Work, so there is no evidence to- // re-derive from and the identity is retained (Req 4.4) — cited rule and- // all, which is what the columns then have to resolve a version for.- guard case .retain(let retained) = moved.identityDisposition else {- Issue.record("expected the moved site's identity to be retained, got \(moved.identityDisposition)")- return- }- #expect(retained.ruleReference?.id == sourceRule)- #expect(try await fixture.repository.commitMerge(contract) == .committed(targetID: target))-- let rows = try await fixture.repository.membershipRows(of: target)- #expect(rows.first?.hostname == "second.example")- try await fixture.repository.withLockedContext(- mode: .shared, operation: "reading the columns"- ) { context in- let work = try #require(- try context.fetch(- FetchDescriptor<Work>(predicate: #Predicate { $0.id == target })).first)- #expect(work.legacyColumns.urlIdentityRuleID == sourceRule)- // The rule's own version, not the one the target carried before the- // moved membership became its primary.- #expect(work.urlIdentityRuleVersion == 3)- }- try await fixture.repository.assertArchiveIsLegal()- try await fixture.repository.assertWorkColumnsMirrorMemberships()- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swiftindex 7c190b9..8d9d856 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift@@ -89,7 +89,9 @@ struct MembershipReconcilerTests { /// /// Two Entries of one Work on two different rows of one hostname would /// otherwise heal onto different rows depending on which the enumeration- /// reached first, and the population pass already mints on the winner.+ /// reached first, and every production write mints on the winner:+ /// `siteForWrite` is `SiteResolutionOrder` (Req 1.4, Q16). V8's population+ /// pass picked the same row and is gone with the columns it read. @Test("A heal on a duplicated hostname pins the membership to the winning Site row") func healPinsToTheWinningSiteRow() throws { let store = try ReconcileStore()@@ -309,41 +311,47 @@ struct MembershipReconcilerTests { // MARK: - The wiring (Req 2.7, Q36) - /// A row a lagging V7 device syncs in after the migration: no membership, no- /// citation blob, every legacy column set. It reads correctly through the- /// fallback the whole time, and one `reconcileAfterSync` converts it —- /// `V8PopulationPass.run` and `MembershipReconciler.run` are steps 3 and 4 of- /// that pass.- @Test("A V7-shaped row synced in after the migration is converted by one reconcile")- func reconcileAfterSyncConvertsALaggingRow() async throws {+ /// A row a lagging writer syncs in: a Work with no membership at all.+ ///+ /// **The diagnosis arms the heal** (Q10). `V8PopulationPass` used to mint the+ /// membership from the Work's retained hostname column on every full-tier+ /// pass, unconditionally; V9 deletes the pass and gates the heal on+ /// `worksWithoutMembershipCount`, so the scan sees the row first and the+ /// next reconcile repairs it. The citation blob is *not* converted — nothing+ /// converts one — and the validator reports the row instead.+ @Test("A Work synced in without a membership is healed once the scan has counted it")+ func reconcileAfterSyncHealsACountedRow() async throws { let environment = try ReconcileRoot() let (_, repository) = try await LibraryRepository.openForApp(environment.configuration) let workID = UUID() try await repository.seedLaggingV7Row(hostname: "lagging.example", workID: workID)+ #expect(try await repository.laggingRowFacts().membershipHostnames.isEmpty) - // Before: readable through the fallback, and unconverted.- #expect(try await repository.laggingRowFacts().citationsData == nil)- #expect(try await repository.laggingRowFacts().hasCitations)+ // Ungated, nothing has been counted, so nothing is healed.+ _ = try await repository.reconcileAfterSync() #expect(try await repository.laggingRowFacts().membershipHostnames.isEmpty) + try await repository.refreshDiagnostics() let outcome = try await repository.reconcileAfterSync() - #expect(outcome.memberships.healed.isEmpty, "the population pass mints it, not the heal")- let after = try await repository.laggingRowFacts()- #expect(after.citationsData != nil)- #expect(after.membershipHostnames == ["lagging.example"])+ #expect(outcome.memberships.healed+ == [MembershipReconcileReport.Heal(workID: workID, hostname: "lagging.example")])+ #expect(try await repository.laggingRowFacts().membershipHostnames == ["lagging.example"])+ // The blob stays nil: it is reported, not repaired.+ #expect(try await repository.laggingRowFacts().citationsData == nil) await repository.shutdown() } - /// The other half of Req 2.7: a Work that arrives with **no** hostname- /// column to mint from is healed from its Entry instead, and the hostname is- /// re-validated even though nothing had diagnosed it.+ /// The other half: a Work with no hostname column either, reachable only+ /// through its Entry, healed from that and its hostname re-validated even+ /// though nothing had diagnosed it. @Test("An Entry-only Work is healed by the reconcile pass and its hostname re-validated") func reconcileAfterSyncHealsFromEntries() async throws { let environment = try ReconcileRoot() let (_, repository) = try await LibraryRepository.openForApp(environment.configuration) let workID = UUID() try await repository.seedMembershiplessWork(hostname: "healed.example", workID: workID)+ try await repository.refreshDiagnostics() let outcome = try await repository.reconcileAfterSync() @@ -368,12 +376,12 @@ private final class ReconcileStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismMembershipReconciler-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }@@ -389,7 +397,6 @@ private final class ReconcileStore { // Q59's standing guard, on the seam every test here goes through: a // phase that added, re-attached or removed a membership must leave the // superseded columns mirroring the primary one.- try assertWorkColumnsMirrorMemberships(in: context, sourceLocation: sourceLocation) return report } @@ -415,7 +422,7 @@ private final class ReconcileStore { @discardableResult func insertWork(id: UUID = UUID(), hostname: String, memberships: [String]) -> Work { let work = Work(- id: id, displayTitle: "A Work", siteHostname: hostname,+ id: id, displayTitle: "A Work", timestamp: Date(timeIntervalSince1970: 1_800_000_000)) context.insert(work) for host in memberships {@@ -456,11 +463,6 @@ private final class ReconcileStore { urlIdentityState: state, urlIdentityRuleID: ruleID, workID: workID ?? work?.id, work: work) context.insert(membership)- // A seeded Work has to satisfy the invariant the guard checks, or the- // guard reports the fixture rather than the reconciler: the columns- // mirror the primary membership, and inserting a row can change which- // one that is (Decision 4).- if let work { LegacyColumns.refreshMembership(on: work) } return membership } @@ -503,22 +505,20 @@ private struct LaggingRowFacts: Sendable { extension LibraryRepository { - /// A Work and Entry exactly as a V7 device writes them: every legacy column- /// populated, no membership, no citation blob.+ /// A Work with no membership and an Entry with no citation blob — what a+ /// V7 device left behind, as much of it as V9 can still hold. The Work's six+ /// site/identity/URL columns and the Entry's provenance raws went with the+ /// schema, so what is left is the two absences the reconciler heals and the+ /// diagnosis reports. fileprivate func seedLaggingV7Row(hostname: String, workID: UUID) async throws { try await withLockedContext(mode: .exclusive, operation: "seeding a lagging V7 row") { context in let site = Site(hostname: hostname, displayName: hostname) context.insert(site) let work = Work(- id: workID, displayTitle: "A Lagging Work", siteHostname: hostname,+ id: workID, displayTitle: "A Lagging Work", timestamp: Date(timeIntervalSince1970: 1_800_000_000))- work.setLegacyColumns {- $0.urlIdentity = "42"- $0.urlIdentityState = .legacyUnverified- } context.insert(work)- work.setLegacySite(site) let url = "https://\(hostname)/read/1" let entry = Entry(@@ -526,7 +526,9 @@ extension LibraryRepository { hostname: hostname, entryIdentityKey: url, timestamp: Date(timeIntervalSince1970: 1_800_000_000)) entry.conservativeIdentityKey = url- entry.workAssignmentProvenance = .manual+ // No blob at all: a row a V8 build wrote and this one has not+ // rewritten, which is the state the diagnosis counts.+ entry.citationsData = nil context.insert(entry) entry.site = site entry.work = work@@ -534,15 +536,16 @@ extension LibraryRepository { } } - /// A Work with no hostname column at all — nothing for the population pass- /// to mint from — reachable only through its Entry (Req 2.7's other half).+ /// A Work with no membership and no site of its own — since V9 dropped the+ /// hostname column there is nothing on the Work naming one — so it is+ /// reachable only through its Entry (Req 2.7's other half). fileprivate func seedMembershiplessWork(hostname: String, workID: UUID) async throws { try await withLockedContext(mode: .exclusive, operation: "seeding an Entry-only Work") { context in let site = Site(hostname: hostname, displayName: hostname) context.insert(site) let work = Work(- id: workID, displayTitle: "An Entry-only Work", siteHostname: "",+ id: workID, displayTitle: "An Entry-only Work", timestamp: Date(timeIntervalSince1970: 1_800_000_000)) context.insert(work) @@ -552,7 +555,9 @@ extension LibraryRepository { hostname: hostname, entryIdentityKey: url, timestamp: Date(timeIntervalSince1970: 1_800_000_000)) entry.conservativeIdentityKey = url- entry.workAssignmentProvenance = .manual+ // A row this build wrote, blob and all: what this fixture is about+ // is the Work's missing membership, not an unconverted citation.+ entry.editCitations { $0.workAssignment = .manual } context.insert(entry) entry.site = site entry.work = work
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swiftindex e6124ce..88ce3c6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipRemovalTests.swift@@ -58,7 +58,6 @@ struct MembershipRemovalTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("A membership holding Entries is refused")@@ -79,7 +78,6 @@ struct MembershipRemovalTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("The Work's only membership is refused")@@ -105,7 +103,6 @@ struct MembershipRemovalTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("A hostname the Work is not on is refused")@@ -118,7 +115,6 @@ struct MembershipRemovalTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } /// Q67: the gate refuses a diagnosis this write **introduced**, and a@@ -153,7 +149,6 @@ struct MembershipRemovalTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } /// The gate's other arm: a diagnosis the removal leaves exactly where it@@ -180,7 +175,6 @@ struct MembershipRemovalTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } // MARK: - Per-site Work counts (Req 6.5)@@ -201,7 +195,6 @@ struct MembershipRemovalTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("A membership whose Work has not arrived is not counted")@@ -216,7 +209,6 @@ struct MembershipRemovalTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } /// Two memberships for one Work and hostname exist until reconciliation@@ -234,6 +226,5 @@ struct MembershipRemovalTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swiftindex b3cce09..c1236da 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift@@ -64,10 +64,6 @@ extension LibraryRepository { row.urlIdentityState = state row.urlIdentityRuleID = ruleID if let workURLString { row.workURLString = workURLString }- // The columns mirror the primary membership (Decision 4), and a- // seeded state that skipped the mirror would fail the standing- // guard as a fixture fault rather than a code one.- if let work = row.work { LegacyColumns.refreshMembership(on: work) } } try context.save() }@@ -175,70 +171,21 @@ struct SeededMembership: Sendable, Equatable { } } -/// The six superseded `Work` columns, for the suites that still assert on what-/// V8 mirrors into them (Q59).+/// The Work's site presence, for suites that used to ask its columns. ///-/// They are `fileprivate` on `Work`, which shuts `@testable` out exactly as it-/// shuts the app out — deliberately, because a suite naming a column directly is-/// a reader the V9 drop would have to find. One door for the package-/// (`LegacyColumns`), one for the suites (this), and both go with the columns.+/// There were doors here onto `Work`'s six superseded site/identity/URL+/// columns — `v7Columns` / `setV7Columns`, `legacySite` / `setLegacySite`,+/// `legacyIdentityStateRaw` — because the columns were `fileprivate` on the+/// model and so shut `@testable` out exactly as they shut the app out (Q59).+/// V9 dropped the columns, and every question they answered is now asked of the+/// membership below. extension Work {- var legacyColumns: LegacyColumns.Membership { LegacyColumns.membership(from: self) }+ /// The Work's **primary** membership — the first in membership order, which+ /// is the row the six columns used to mirror.+ var primaryMembership: WorkSiteMembership? { membershipValues.first } - /// The identity-state column as stored, which `Membership` reads through- /// `ToleratedEnum` and therefore cannot report faithfully.- var legacyIdentityStateRaw: String { LegacyColumns.urlIdentityStateRaw(from: self) }-- var legacySite: Site? { LegacyColumns.site(from: self) }-- func setLegacyColumns(_ edit: (inout LegacyColumns.Membership) -> Void) {- LegacyColumns.update(self, edit)- }-- func setLegacyIdentityStateRaw(_ raw: String) {- LegacyColumns.setURLIdentityStateRaw(raw, on: self)- }-- func setLegacySite(_ site: Site?) { LegacyColumns.setSite(site, on: self) }-}--/// **The standing guard for Decision 4's Work half**: after any write that can-/// move a Work's memberships, the superseded columns still mirror the primary-/// one.-///-/// `refreshMembership` reports whether it moved anything, so "already mirrored"-/// is exactly "it moved nothing". Three separate regressions in one review hid-/// behind a missing call to it — a membership removal that left the columns-/// naming the removed site, a heal that changed which membership was primary,-/// and a merge that paired a mirrored rule id with a stale version — and every-/// one of them surfaced as a *refused export* two subsystems away. Asserting it-/// at the end of the write suites catches the next one where it happens.-func assertWorkColumnsMirrorMemberships(- in context: ModelContext,- _ comment: Comment? = nil,- sourceLocation: SourceLocation = #_sourceLocation-) throws {- for work in try context.fetch(FetchDescriptor<Work>()) {- #expect(- LegacyColumns.refreshMembership(on: work) == false,- comment ?? "Work \(work.id) columns do not mirror its primary membership",- sourceLocation: sourceLocation)- }- // The check is a write in disguise — a Work that failed it has just been- // repaired — so nothing it did reaches the store.- context.rollback()-}--extension LibraryRepository {- func assertWorkColumnsMirrorMemberships(- _ comment: Comment? = nil,- sourceLocation: SourceLocation = #_sourceLocation- ) async throws {- try await withLockedContext(- mode: .exclusive, operation: "checking the superseded Work columns"- ) { context in- try AsterismCoreTests.assertWorkColumnsMirrorMemberships(- in: context, comment, sourceLocation: sourceLocation)- }+ /// Edits the Work's primary membership where it has one.+ func primaryMembershipEdit(_ edit: (WorkSiteMembership) -> Void) {+ if let primary = primaryMembership { edit(primary) } } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swiftindex bd6fa1d..392d5af 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift@@ -346,12 +346,12 @@ private final class MembershipStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismMembershipValidation-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }@@ -370,7 +370,7 @@ private final class MembershipStore { @discardableResult func insertWork(hostname: String, memberships: [String]) -> Work { let work = Work(- displayTitle: "a work", siteHostname: hostname, timestamp: Self.epoch)+ displayTitle: "a work", timestamp: Self.epoch) context.insert(work) for host in memberships { context.insert(
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MigratedStoreCitationRewriteTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MigratedStoreCitationRewriteTests.swiftdeleted file mode 100644index e5bd846..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MigratedStoreCitationRewriteTests.swift+++ /dev/null@@ -1,136 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import AsterismCore--/// Decision 4, end to end: a rule-version renumbering over a **migrated** store-/// has to land in the legacy columns as well as the blob.-///-/// Through phase 1 the columns are still the source of truth. `LibraryValidator`-/// resolves an Entry's identity citation and a Work's URL-identity citation out-/// of them, `BackupArchiveProjection` exports the pair for import to re-check,-/// and only `Entry.citations` reads the blob. So a reconciliation that rewrote-/// the blob alone would leave every one of those readers citing a version no-/// rule holds — the hostname quarantined and the export refused — on a library-/// that had just migrated cleanly. Both halves of that failure have a case here,-/// and neither is visible on a store born at V8, because such a store has no-/// legacy columns populated to go stale.-///-/// The shape is the one the reconciler actually repairs: a Site row whose-/// **current** URL rule does not hold the greatest retained version, which-/// `LibraryValidator` reports and `SiteUnionProjection` fixes by renumbering the-/// hostname's rules — moving the version every record cites out from under them.-@Suite("Citation rewrites over a migrated store", .serialized)-struct MigratedStoreCitationRewriteTests {-- private typealias Fixture = V7RecordedStoreFixture-- /// A rule the reader taught later and retired, sitting above the current one- /// — which is what makes the union renumber.- private static let staleRuleID = UUID(uuidString: "33333333-3333-3333-3333-000000000107")!-- /// The version the union assigns the cited rule: the two rules renumber to a- /// dense 1…n sequence with the current one last, so the cited rule lands on- /// 2 and the retired one on 1.- private static let renumberedVersion = 2-- private final class Root {- let url: URL- let configuration: LibraryConfiguration-- init() throws {- url = FileManager.default.temporaryDirectory.appending(- path: "MigratedRewrite-\(UUID())", directoryHint: .isDirectory)- try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: url)- }-- deinit { try? FileManager.default.removeItem(at: url) }-- /// A genuinely 7.0.0-recorded library carrying the `"7"` marker, plus one- /// retired URL rule above the current one.- func seed() throws {- try Fixture.install(at: configuration.storeURL)- try Fixture.write(at: configuration.storeURL) { context in- let site = try #require(- context.fetch(FetchDescriptor<AsterismSchemaV7.Site>()).first)- let stale = AsterismSchemaV7.URLRulePattern()- stale.id = MigratedStoreCitationRewriteTests.staleRuleID- // Above the current rule's version 3, which is the state- // `LibraryValidator` reports and the union repairs.- stale.version = 9- stale.isCurrent = false- stale.createdAt = Fixture.timestamp- stale.originRaw = URLRuleOrigin.readerTaught.rawValue- stale.definitionData = try JSONEncoder().encode(Fixture.urlRuleDefinition)- context.insert(stale)- stale.site = site- }- try Data("7\n".utf8).write(- to: configuration.readinessMarkerURL, options: .atomic)- }- }-- @Test("A renumbering after migration reaches the columns, the blob, and the Work")- func renumberingReachesEveryReader() async throws {- let root = try Root()- try root.seed()- let (_, repository) = try await LibraryRepository.openForApp(root.configuration)- defer { withExtendedLifetime(root) {} }-- // The premise: the migration ran, the population pass filled the blobs,- // and the hostname arrives diagnosed because its current rule sits below- // a retained one.- let quarantinedAtOpen = await repository.diagnostics.quarantineMap()- #expect(quarantinedAtOpen[Fixture.hostname] != nil,- "the shape under test is the one the reconciler repairs")-- _ = try await repository.reconcileAfterSync()-- // Both homes moved together. The blob is what `Entry.citations` answers- // with; the columns are what the validator and the archive read.- let state = try await repository.withLockedContext(- mode: .shared, operation: "reading the renumbered citations"- ) { context in- let entry = try #require(- context.fetch(FetchDescriptor<Entry>()).first { $0.id == Fixture.entryAID })- let work = try #require(context.fetch(FetchDescriptor<Work>()).first)- return (- column: entry.identityURLRuleVersion,- sequenceColumn: entry.chapterSequenceRuleVersion,- blob: try entry.citations.identity.urlRule?.version,- workColumn: work.urlIdentityRuleVersion,- membershipRule: work.membership(for: Fixture.hostname)?.urlIdentityRuleID,- quarantined: try LibraryValidator.validate(context: context).quarantineMap()- )- }-- #expect(state.column == Self.renumberedVersion, "the Entry's identity column")- #expect(state.sequenceColumn == Self.renumberedVersion, "the Entry's sequence column")- #expect(state.blob == Self.renumberedVersion, "the Entry's citation blob")- // The Work's retained version column is **not** rewritten since task 21:- // the membership cites by UUID alone, the validator's `.rule` arm reads- // the membership, and the 7/8 record carries no version — so the column- // has no reader left and V9 deletes it.- #expect(- state.workColumn == Fixture.urlRuleVersion,- "the Work's identity column is left where the recorded store had it")- // The membership cites by UUID only (Req 10.4, Q28), so it has nothing to- // renumber and must still name the rule.- #expect(state.membershipRule == Fixture.urlRuleID)-- // Req 6.2's end: the hostname is no longer quarantined, so capture,- // teaching and the archive all still work over it.- #expect(state.quarantined.isEmpty,- "a converged hostname must not stay quarantined: \(state.quarantined)")-- // And the export, which refuses a citation it cannot resolve at the- // version the archive holds.- let payload = try await repository.backupV7Snapshot()- #expect(payload.urlRules.count == 2)- #expect(payload.entries.count == 2)-- await repository.shutdown()- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swiftindex ebbe870..0b896a1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift@@ -125,7 +125,7 @@ struct MirroringBootstrapLifecycleTests { // The whole point of the two-phase open: by the time the mirrored // container is constructed, the store is marked "6" — so CloudKit cannot // fill an unmarked store (Req 6.1, Q22, Q35).- #expect(call.markerVersion == "8")+ #expect(call.markerVersion == "9") #expect(call.storeExists) #expect(call.containerID == Self.fixtureContainer) #expect(call.storeURL == configuration.storeURL)@@ -148,7 +148,7 @@ struct MirroringBootstrapLifecycleTests { mirroring: recordingHooks(configuration, log: log, bootstrapBox: bootstrapBox)) #expect(log.callCount == 1)- #expect(log.calls.first?.markerVersion == "8")+ #expect(log.calls.first?.markerVersion == "9") #expect(await repository.mirroring.isMirroring) // Q35/Q43 on this path too. The already-certified branch opens its own // certification container to run the validator over an existing marker,@@ -175,7 +175,7 @@ struct MirroringBootstrapLifecycleTests { #expect(result == .ready(LibraryRecordCounts( entries: 0, works: 0, sites: 1, titlePatterns: 0).withSeededWorkTypes))- #expect(log.calls.first?.markerVersion == "8")+ #expect(log.calls.first?.markerVersion == "9") #expect(await repository.mirroring.isMirroring) withExtendedLifetime(dir) {} }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swiftindex 9392653..2893f3d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift@@ -101,7 +101,7 @@ struct ModelContractTests { func defaultsAndCollections() throws { let instant = Date(timeIntervalSince1970: 1_721_000_000.123) let site = Site(hostname: "example.com")- let work = Work(displayTitle: "A Work", siteHostname: site.hostname, timestamp: instant)+ let work = Work(displayTitle: "A Work", timestamp: instant) let entry = Entry( captureTitle: "Chapter 1", captureTitleSource: .host,@@ -128,39 +128,29 @@ struct ModelContractTests { #expect(entry.work == nil) #expect(entry.note.isEmpty) #expect(entry.rating == nil)- #expect(entry.identityKeyVersion == 1)+ // The blob is seeded at construction (Q13), so a row is born citing+ // nothing on the conservative basis rather than citing nothing because+ // it has no blob at all.+ #expect(entry.citationValues == EntryCitations())+ #expect(entry.citationsData != nil) #expect(entry.intentionallyUnattached == false) } - /// The entity list is the store's shape. V8 adds the ninth and tenth- /// entities and keeps the eight V7 already had, in the same order — the- /// frozen snapshots are the `from` sides of the three lightweight stages, so- /// a divergence here is a store that will not open, not a test that needs- /// updating.- @Test("V8 declares ten entities, V7 stays frozen at eight, V6 at six and V5 at five")+ /// The entity list is the store's shape. **V9 removes no table** — what it+ /// removes is 35 attributes and one inverse pair — so the ten entities V8+ /// declared are the ten V9 declares, in the same order. The frozen V8+ /// snapshot is the `from` side of the one lightweight stage, so a divergence+ /// here is a store that will not open, not a test that needs updating.+ @Test("V9 declares the same ten entities V8 froze") func schemaEntityLists() {+ let entities = [+ "Entry", "Work", "Site", "TitlePattern", "URLRulePattern", "WorkTypeEntity",+ "Character", "CharacterSuppression", "WorkSiteMembership", "WorkDistinctPair",+ ]+ #expect(AsterismSchemaV9.versionIdentifier == Schema.Version(9, 0, 0))+ #expect(AsterismSchemaV9.models.map { String(describing: $0) } == entities) #expect(AsterismSchemaV8.versionIdentifier == Schema.Version(8, 0, 0))- #expect(- AsterismSchemaV8.models.map { String(describing: $0) } == [- "Entry", "Work", "Site", "TitlePattern", "URLRulePattern", "WorkTypeEntity",- "Character", "CharacterSuppression", "WorkSiteMembership", "WorkDistinctPair",- ])- #expect(AsterismSchemaV7.versionIdentifier == Schema.Version(7, 0, 0))- #expect(- AsterismSchemaV7.models.map { String(describing: $0) } == [- "Entry", "Work", "Site", "TitlePattern", "URLRulePattern", "WorkTypeEntity",- "Character", "CharacterSuppression",- ])- #expect(AsterismSchemaV6.versionIdentifier == Schema.Version(6, 0, 0))- #expect(- AsterismSchemaV6.models.map { String(describing: $0) } == [- "Entry", "Work", "Site", "TitlePattern", "URLRulePattern", "WorkTypeEntity",- ])- #expect(AsterismSchemaV5.versionIdentifier == Schema.Version(5, 0, 0))- #expect(- AsterismSchemaV5.models.map { String(describing: $0) } == [- "Entry", "Work", "Site", "TitlePattern", "URLRulePattern",- ])+ #expect(AsterismSchemaV8.models.map { String(describing: $0) } == entities) } /// V7's additions, as CloudKit will materialise them: every property@@ -233,16 +223,17 @@ struct ModelContractTests { #expect(merged.canonicalID == id) } - // MARK: - Decision 3: the retained columns, and the one reader of them+ // MARK: - V9: the columns are gone, and so is the snapshot they lived on - /// Every column V8 superseded but did **not** drop, by entity.+ /// Every column V8 superseded and V9 dropped, by entity. ///- /// They stay in the stored shape because SwiftData's lightweight stage runs- /// inside `ModelContainer.init`, so a stage that dropped them would destroy- /// the source before `V8PopulationPass` could read it. V9 (T-2272) inherits- /// the drop — and can only inherit it if they are still here, which is what- /// the first test below pins.- static let retainedColumns: [(entity: String, columns: [String])] = [+ /// They stayed in the stored shape through V8 because SwiftData's+ /// lightweight stage runs inside `ModelContainer.init`, so a stage that+ /// dropped them would have destroyed the source before `V8PopulationPass`+ /// could copy it. The copy is done on every device (Q2), so V9 drops them —+ /// and what used to be pinned here was the opposite claim, that each was+ /// *still* in the schema with `LegacyColumns` as its one reader.+ static let droppedColumns: [(entity: String, columns: [String])] = [ ("Work", [ "siteHostname", "site", "urlIdentity", "urlIdentityStateRaw", "urlIdentityRuleID", "urlIdentityRuleVersion", "workURLString", "typeRaw",@@ -262,15 +253,21 @@ struct ModelContractTests { "phrasePrefix", "phraseSeparator", "phraseSuffix", "fieldOrderRaw", "trimPrefix", "trimSuffix", "chapterless", ]),- ("Site", ["urlIdentityRule"]),+ ("Site", ["urlIdentityRule", "works"]), ] - /// Half one of Q34: the columns are still in the schema. A column quietly- /// removed here would be a schema change nobody declared, and a store that- /// will not open.- @Test("Every retained legacy column is still in the V8 schema")- func retainedColumnsAreStillInTheSchema() {- let schema = Schema([Work.self, Entry.self, TitlePattern.self, Site.self])+ /// **None of the dropped names is in the live schema.** A column that came+ /// back would be a schema change nobody declared, and a store that will not+ /// open on the next device.+ ///+ /// It reads the schema rather than the source, because that is what the+ /// store is: a name a value type happens to reuse (`siteHostname` on the+ /// archive wire records, `trimPrefix` on `StoredPatternDefinition`) is not a+ /// column, and a textual grep could not tell them apart — which is why the+ /// V8-era half of this pin needed an allowlist and this one does not.+ @Test("No dropped column is in the V9 schema")+ func droppedColumnsAreGoneFromTheSchema() {+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) var propertiesByEntity: [String: Set<String>] = [:] for entity in schema.entities { propertiesByEntity[entity.name, default: []]@@ -278,151 +275,102 @@ struct ModelContractTests { propertiesByEntity[entity.name, default: []] .formUnion(entity.relationships.map(\.name)) }- for (entity, columns) in Self.retainedColumns {+ for (entity, columns) in Self.droppedColumns { let held = propertiesByEntity[entity] ?? [] for column in columns {- #expect(held.contains(column), "\(entity).\(column) has left the V8 schema")+ #expect(!held.contains(column), "\(entity).\(column) is back in the V9 schema") } }+ // The control: the columns that superseded them *are* there, so a run+ // that read an empty property set would fail rather than pass silently.+ #expect(propertiesByEntity["Entry"]?.contains("citationsData") == true)+ #expect(propertiesByEntity["TitlePattern"]?.contains("definitionData") == true)+ #expect(propertiesByEntity["Work"]?.contains("siteMemberships") == true) } - /// Half two of Q34: **the set of files that read a retired column only ever- /// shrinks.**- ///- /// Access control keeps the app and the extension out — every retained column- /// is `internal` on a `public` class. It cannot keep the *package* out, so- /// this grep does, by pinning the reader set exactly: a file that starts- /// naming a retired column fails here, and so does a file that stops, which- /// is what makes the list shrink to `LegacyColumns` alone rather than being- /// quietly forgotten at a dozen entries.+ /// **The frozen snapshot is exactly one file.** `AsterismSchemaV8.swift` is+ /// the `from` side of the only stage the plan declares; V5, V6 and V7 went+ /// with the stages that named them (Q2), and a file that starts declaring a+ /// snapshot without a stage to be the `from` side of is a store shape+ /// nothing can reach. ///- /// The pinned columns are the ones V8 has *finished* with — the Entry- /// citation set, the pattern arm columns, `typeRaw`, `Site.urlIdentityRule`- /// and, from task 12, `Work.urlIdentityRuleVersion`.- ///- /// **`Work`'s six site/identity/URL columns are not pinned here, because- /// they no longer need to be** (Q59). `siteHostname`, `site`, `urlIdentity`,- /// `urlIdentityStateRaw`, `urlIdentityRuleID` and `workURLString` are the- /// names `WorkSiteMembership` carries *by design* — the membership is what- /// superseded them, field for field — and `WorkURLBasis`, the archive wire- /// records and half a dozen value types carry them too, so a textual grep- /// cannot tell `work.urlIdentity` from `membership.urlIdentity` and pinning- /// them would fail on every file reading the **V8** value. They are- /// `fileprivate` on `Work` instead, which is a stronger enforcement than- /// this grep and needs no allowlist: the *compiler* refuses every reader- /// outside `Models.swift`, the package and the `@testable` suites included,- /// and the two doors left are `LegacyColumns.membership(from:)` / `update`- /// and the suites' `Work.legacyColumns` / `setLegacyColumns`.- /// `trimPrefix`/`trimSuffix` stay excluded for the naming reason alone —- /// they are fields of half a dozen value types — and `urlIdentityRuleVersion`- /// is pinned here rather than closed off because the fixtures that seed a- /// V7-shaped Work still write it. Since task 21 nothing in the package- /// outside `Models.swift` reads it: the 7/8 record dropped the version, so- /// `LegacyColumns.rewriteVersion` and the reconcilers' Work walks went with- /// it.- @Test("The set of files naming a retired column is exactly the recorded one")- func retiredColumnsAreNamedNowhereElse() throws {- let retired = Self.retainedColumns- .first { $0.entity == "Entry" }!.columns- + ["formRaw", "segmentWorkAnchor", "segmentIgnoredAnchors",- "phrasePrefix", "phraseSeparator", "phraseSuffix", "fieldOrderRaw", "chapterless"]- + ["typeRaw"]- // Task 8: `LibraryValidator`'s "dormant V2 URL rule cannot persist"- // refusal was this column's last reader, and `SiteSnapshot` no- // longer carries it.- + ["urlIdentityRule"]- // Task 12-14: a membership cites its rule by UUID alone (Req 10.4,- // Q28), so nothing outside `LegacyColumns` and the archive wire- // records has a version to read. It is the one Work column whose- // name no V8 type reuses, which is what makes it pinnable.- + ["urlIdentityRuleVersion"]-- /// The one reader Decision 3 sanctions, plus the frozen snapshots whose- /// whole purpose is to declare the old shape.- let sanctioned: Set<String> = [- "Models.swift",- "AsterismSchemaV5.swift", "AsterismSchemaV6.swift", "AsterismSchemaV7.swift",- ]-- /// **Everything still to be rewritten**, and the task that empties it.- ///- /// Most of the archive half is **gone** (task 21). The 4/4 and 5/6 wire- /// records carried fields named after the store columns; the 7/8 records- /// carry one `EntryCitations` blob and one `StoredPatternDefinition`- /// instead, so the reference checks and the import mapper name no retired- /// column at all — and `BackupV4Types.swift`, `BackupV5Types.swift` and- /// `EntryRuleCitations.swift` were deleted with the format. What is left- /// genuinely still reads the columns, and moves to `Entry.citations` /- /// `TitlePattern.storedDefinition`.- ///- /// `BackupArchiveProjection.swift` reads three of them on purpose and- /// comes off this list only when V9 drops them: an Entry with no- /// citation blob is read through `LegacyColumns.citations`, which- /// coerces an unrecognised provenance raw to `.none`, and Q8 forbids the- /// export archiving a coerced value. The guards are over the columns- /// because that is where the unrepresentable value is.- ///- /// A file removed from this set and still naming a column fails, and so- /// does one that has been rewritten but left behind here. Emptying it is- /// what finishes Q34.- let pendingRewrite: Set<String> = [- // The export's Req 3.6 guards, over the three Entry provenance raws- // (see above): they leave with the columns, not before.- "BackupArchiveProjection.swift",- // Store readers awaiting the blob rewrite. `LibraryValidator.swift`- // came off this list at task 8: every citation arm reads- // `Entry.citations`, the pattern arms read `storedDefinition`, and- // the Work-side site checks moved to the membership arm.- "LibraryRepository.swift",- "LibraryRepository+Articles.swift", "LibraryRepository+ComposedTeaching.swift",- "LibraryRepository+Groups.swift", "LibraryRepository+ReparseCapture.swift",- "LibraryRepository+URLIdentity.swift", "M4PerformanceFixture.swift",- // `EntrySnapshot.identityKeyVersion` is a *snapshot field* of the- // same name, not the column — the ambiguity the note above describes.- "Snapshots.swift",+ /// The dropped columns are named in exactly that file and nowhere else in+ /// the package's sources — comments excluded, because a comment saying which+ /// column a blob superseded is the point of the comment.+ @Test("The frozen snapshot files are exactly the ones the plan names")+ func onlyTheFrozenSnapshotDeclaresTheDroppedColumns() throws {+ // The subset whose names no live type reuses. `siteHostname`,+ // `trimPrefix`, `site` and the rest are fields of the archive wire+ // records and half a dozen value types, so a textual grep cannot tell+ // `work.siteHostname` from `record.siteHostname`; the schema check above+ // is what covers those, and it is the stronger claim anyway.+ // `identityKeyVersion` and `chapterless` are excluded for exactly that+ // reason: `EntrySnapshot` and `StoredPatternDefinition` each carry a+ // field of that name, derived from the blob (Q26).+ let unambiguous = [+ "identityURLRuleID", "identityURLRuleVersion",+ "identityNameTitleRuleID", "identityNameTitleRuleVersion",+ "urlWorkRuleID", "urlWorkRuleVersion",+ "chapterSequenceRuleID", "chapterSequenceRuleVersion",+ "chapterTitleProvenanceRaw", "chapterPatternID", "chapterPatternVersion",+ "workAssignmentProvenanceRaw", "workPatternID", "workPatternVersion",+ "workURLRuleID", "workURLRuleVersion", "workURLAssignmentKindRaw",+ "formRaw", "segmentWorkAnchor", "segmentIgnoredAnchors",+ "phrasePrefix", "phraseSeparator", "phraseSuffix", "fieldOrderRaw",+ "typeRaw", "urlIdentityRule", "urlIdentityRuleVersion", ] - var found: Set<String> = []+ var declaringSnapshots: [String] = []+ var naming: Set<String> = [] for file in try Self.coreSourceFiles() {- let name = file.lastPathComponent- guard !sanctioned.contains(name) else { continue } let text = try String(contentsOf: file, encoding: .utf8)- // Comments name these columns constantly and reading them is the- // point of the comment, so only code counts.+ if text.contains(": VersionedSchema") {+ declaringSnapshots.append(file.lastPathComponent)+ } let code = text.split(separator: "\n", omittingEmptySubsequences: false) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.hasPrefix("//") } .joined(separator: "\n")- if retired.contains(where: { Self.namesMember($0, in: code) }) { found.insert(name) }+ if unambiguous.contains(where: { Self.namesMember($0, in: code) }) {+ naming.insert(file.lastPathComponent)+ } } #expect(- found.subtracting(pendingRewrite).isEmpty,+ declaringSnapshots.sorted() == ["AsterismSchemaV8.swift", "AsterismSchemaV9.swift"], """- a file that is not on the rewrite list reads a retired column: \- \(found.subtracting(pendingRewrite).sorted()). Route it through \- `LegacyColumns`, or read the V8 value instead.+ the package declares versioned schemas in \(declaringSnapshots.sorted()); \+ the plan is [V8, V9] and every snapshot must be a stage's `from` side """) #expect(- pendingRewrite.subtracting(found).isEmpty,+ naming == ["AsterismSchemaV8.swift"], """- these files no longer read a retired column: \- \(pendingRewrite.subtracting(found).sorted()). Take them off the \- rewrite list so it keeps shrinking to nothing.+ these files name a dropped column: \(naming.sorted()). Only the \+ frozen V8 snapshot may — read the blob or the membership instead. """) } - /// Whether `code` reads `.member` — at a word boundary, so `.chapterless`- /// does not match `.chapterlessSegment`, which is an enum case rather than a- /// column.++ /// Whether `code` names `member` at all — declaring it or reading it, at a+ /// word boundary, so `chapterless` does not match `chapterlessSegment`,+ /// which is an enum case rather than a column.+ ///+ /// It used to look for `.member` specifically, because every occurrence that+ /// mattered was a *read*. The frozen snapshot is the one file that may name+ /// these and it only ever declares them, so the boundary check is on the+ /// bare identifier now. private static func namesMember(_ member: String, in code: String) -> Bool { var remainder = Substring(code)- while let range = remainder.range(of: ".\(member)") {- let next = range.upperBound- if next == remainder.endIndex { return true }- let character = remainder[next]- if !character.isLetter, !character.isNumber, character != "_" { return true }- remainder = remainder[next...]+ while let range = remainder.range(of: member) {+ let before = range.lowerBound == remainder.startIndex+ ? nil : remainder[remainder.index(before: range.lowerBound)]+ let after = range.upperBound == remainder.endIndex ? nil : remainder[range.upperBound]+ func isWord(_ character: Character?) -> Bool {+ guard let character else { return false }+ return character.isLetter || character.isNumber || character == "_"+ }+ if !isWord(before), !isWord(after) { return true }+ remainder = remainder[range.upperBound...] } return false }@@ -516,6 +464,9 @@ struct ModelContractTests { #expect( work.membershipValues.first === tieLow, "a tie on the date falls back to the lower identifier")+ #expect(+ work.primaryMembership === tieLow,+ "the unsorted primary accessor answers the same row as the sorted first") } @Test("Tagged pattern arms persist without cross-arm values")@@ -540,12 +491,14 @@ struct ModelContractTests { let verification = ModelContext(fixture.container) let patterns = try verification.fetch(FetchDescriptor<TitlePattern>()) #expect(patterns.count == 1)- #expect(try patterns[0].definition == definition)- #expect(patterns[0].formRaw == PatternForm.segment.rawValue)- #expect(patterns[0].phrasePrefix == nil)- #expect(patterns[0].phraseSeparator == nil)- #expect(patterns[0].phraseSuffix == nil)- #expect(patterns[0].fieldOrderRaw == nil)+ // The blob is the whole definition surface since V9, so the arm and both+ // trims are one round trip rather than an arm plus four columns that+ // have to be absent.+ let stored = try patterns[0].storedDefinition+ #expect(stored.definition == definition)+ #expect(stored.form == .segment)+ #expect(stored.trimPrefix == nil)+ #expect(stored.trimSuffix == nil) } @Test("Optional relationships preserve explicit inverses")@@ -562,7 +515,7 @@ struct ModelContractTests { ), site: site )- let work = Work(displayTitle: "A Work", siteHostname: site.hostname, timestamp: timestamp)+ let work = Work(displayTitle: "A Work", timestamp: timestamp) let entry = Entry( captureTitle: "Chapter 1", captureTitleSource: .manual,@@ -610,7 +563,7 @@ private struct ModelFixture { /// The live schema, in memory. It was `AsterismSchemaV2` — a schema no /// library was written by, which is exactly the divergence Req 4.1 is about. init() throws {- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swiftindex 7eca3f3..a28dce5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift@@ -121,9 +121,21 @@ struct MultiSiteReadPathTests { // The model itself: `siteMemberships` is declared here and // `membershipValues` / `membership(for:)` are its accessors. "Models.swift",+ // The frozen V8 snapshot **declares** the inverse array; it reads+ // nothing, and nothing reads it — a snapshot carries stored columns+ // and no accessors at all.+ "AsterismSchemaV8.swift", // The reconcilers and the scan, which are *about* the membership // graph and read it whole per pass rather than per row. "DuplicateReconciler.swift", "DuplicateScan.swift", "MembershipReconciler.swift",+ // **Per row, and on every write path** (V9, T-2280). A Work's site+ // presence and confirmed URL are what order its rows and what count+ // as its authored content, and until V9 both were read off the six+ // columns that mirrored the primary membership. The mirror is gone,+ // so the primary membership is read directly — one fault per Work+ // inside `sortedWorkRows`, which is exactly the Q84 cost, accepted+ // here because the alternative is a mirror with no writer.+ "GroupOrdering.swift", // Whole-library reads whose fan-out is already the point: the Works // list, the validator's graph arm, the deletion and merge cascades, // the resolution sheet, the Sites count.@@ -142,7 +154,11 @@ struct MultiSiteReadPathTests { .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.hasPrefix("//") } .joined(separator: "\n")- if code.contains("siteMemberships") || code.contains("membershipValues") {+ // `primaryMembership` counts too: it is `membershipValues.first`+ // without the sort, so a file reading it faults the relationship+ // exactly as one reading `membershipValues` does.+ if code.contains("siteMemberships") || code.contains("membershipValues")+ || code.contains("primaryMembership") { found.insert(file.lastPathComponent) } }@@ -185,12 +201,12 @@ private final class MembershipStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismMultiSiteRead-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }@@ -218,7 +234,7 @@ private final class MembershipStore { ) -> Work { let timestamp = MultiSiteReadPathTests.epoch.addingTimeInterval(TimeInterval(offset)) if let memberships {- let work = Work(id: id, displayTitle: "Work \(offset)", siteHostname: hostname,+ let work = Work(id: id, displayTitle: "Work \(offset)", timestamp: timestamp) context.insert(work) for host in memberships { insertMembership(work: work, hostname: host, offset: offset) }@@ -252,7 +268,6 @@ private final class MembershipStore { createdAt: MultiSiteReadPathTests.epoch.addingTimeInterval(TimeInterval(offset)), workID: workID ?? work?.id, work: work) context.insert(membership)- if let work { LegacyColumns.refreshMembership(on: work) } return membership } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swiftindex d2eca70..11e676a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift@@ -137,7 +137,6 @@ struct MultiSiteReviewFixTests { createdAt: 0, site: site) let membership = try #require(work.membershipValues.first) membership.workURLString = "https://b.example/serial"- LegacyColumns.refreshMembership(on: work) try store.commit() let diagnosis = try store.diagnose().quarantineMap()["a.example"]@@ -169,9 +168,7 @@ struct MultiSiteReviewFixTests { let corrupt = store.addEntry(key: "one", capturedAt: 0, site: site) let sound = store.addEntry(key: "two", capturedAt: 60, site: site) for entry in [corrupt, sound] {- entry.chapterSequenceRuleID = ruleID- entry.chapterSequenceRuleVersion = 1- LegacyColumns.refreshCitations(on: entry)+ entry.editCitations { $0.chapterSequence = CitedRule(id: ruleID, version: 1) } } // Bytes no decoder will read — what a partially written blob leaves. corrupt.citationsData = Data("not json".utf8)@@ -187,44 +184,62 @@ struct MultiSiteReviewFixTests { try context.save() } - // Both rows were reached: the corrupt one used to throw out of the- // enumeration and leave every later row citing the old version.+ // Both rows moved, and the point is that *neither* aborted the walk: the+ // corrupt row used to throw out of the enumeration and leave every+ // *later* row citing the old version.+ //+ // The corrupt one moves by being **repaired** (Q24 of+ // `drop-superseded-columns`): the edit lands over the default value, so+ // the row stops being unreadable. It does not come back citing the rule+ // at version 4 — there was nothing readable to rewrite — but it also no+ // longer sits in the store as bytes no pass can touch. #expect(rewritten == 2) try store.read { context in- for entry in try context.fetch(FetchDescriptor<Entry>()) {- #expect(entry.chapterSequenceRuleVersion == 4)- // And the corrupt blob is repaired from the columns rather than- // left as a permanent diagnosis.- #expect(try entry.citations.chapterSequence?.version == 4)- }+ let rows = try context.fetch(FetchDescriptor<Entry>())+ // Nothing is left undecodable.+ #expect(rows.allSatisfy { (try? $0.citations) != nil })+ // The sound row kept its citation and moved it to the new version;+ // the repaired one holds the default, which cites nothing.+ let versions = try rows.map { try $0.citations.chapterSequence?.version }+ #expect(versions.compactMap { $0 } == [4])+ #expect(versions.count { $0 == nil } == 1) } } - // MARK: - C5 / Q42: two differently-broken patterns are not converged+ // MARK: - C5 / Q42, as V9's Q11 restates it - @Test("Column-only patterns with different broken columns order differently")- func canonicalDefinitionSeparatesBrokenColumnRows() throws {+ /// Q42 gave a pattern with **no blob** an ordering key derived from its ten+ /// definition columns, so that two rows broken in two different ways did not+ /// compare converged. V9 unwrites the columns: two nil-blob rows now hold the+ /// same nothing, and one constant key is the truthful order for them (Q11).+ ///+ /// What still has to hold is the other half — the constant must not collide+ /// with the key an *undecodable* row gets, or a nil row and a row whose bytes+ /// will not read compare converged and the group stops being reported.+ @Test("A pattern with no definition orders under a constant key, distinct from an unreadable one")+ func canonicalDefinitionSeparatesNilFromUnreadable() throws { let store = try DuplicateStore() let site = store.addSite(hostname: "a.example")- let first = try store.addPattern(id: UUID(), site: site)- let second = try store.addPattern(id: UUID(), site: site)- // A row a lagging V7 writer produced: no blob, and columns that form no- // legal arm (Q42). The two are broken *differently*.- first.definitionData = nil- first.formRaw = "phrase"- first.phrasePrefix = "["- second.definitionData = nil- second.formRaw = "phrase"- second.phrasePrefix = "{"+ let missing = try store.addPattern(id: UUID(), site: site)+ let alsoMissing = try store.addPattern(id: UUID(), site: site)+ let unreadable = try store.addPattern(id: UUID(), site: site)+ // Two rows holding no definition, differing in nothing else — the ten+ // columns that used to tell such rows apart went with the schema at V9.+ missing.definitionData = nil+ alsoMissing.definitionData = nil+ unreadable.definitionData = Data("not json".utf8) try store.commit() - #expect((try? first.storedDefinition) == nil)- #expect((try? second.storedDefinition) == nil)- // A constant fallback read both as one definition, so a group holding- // them compared *converged* and the validator stopped reporting it.+ #expect((try? missing.storedDefinition) == nil)+ #expect((try? unreadable.storedDefinition) == nil)+ #expect(+ GroupOrdering.canonicalDefinition(missing)+ == GroupOrdering.canonicalDefinition(alsoMissing),+ "two rows holding no definition hold the same nothing") #expect(- GroupOrdering.canonicalDefinition(first)- != GroupOrdering.canonicalDefinition(second))+ GroupOrdering.canonicalDefinition(missing)+ != GroupOrdering.canonicalDefinition(unreadable),+ "no definition and an unreadable one must never compare converged") } // MARK: - C6 / Q81: the export gate refuses a cross-site citation
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteWritePathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteWritePathTests.swiftindex 3f695ac..e23aab2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteWritePathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteWritePathTests.swift@@ -170,36 +170,11 @@ struct MultiSiteWritePathTests { #expect(try await fixture.repository.canonicalWorkIDParity() == (true, 0)) } - // MARK: - Req 2.4: a part-written conversion leaves the marker where it was-- @Test("A partly committed V8 pass leaves the marker at 7 and the next open completes it")- func aPartlyCommittedPassIsResumable() async throws {- let root = try MarkerRoot()- try V7RecordedStoreFixture.install(at: root.configuration.storeURL)- try root.writeMarker("7\n")-- // **Partly** committed, not all-or-nothing: the first save lands and- // every one after it throws, so the store on disk holds some of the- // conversion and not the rest. An empty store is the easy half of- // resumption; a half-written one is the half that can be doubled.- let failing = InstrumentedSaveStrategy()- failing.failAfter = 1-- await #expect(throws: (any Error).self) {- try await LibraryRepository.openForApp(root.configuration, saveStrategy: failing)- }- #expect(failing.successCount == 1, "the fixture must actually have committed something")- #expect(try root.markerText() == "7")-- let (result, repository) = try await LibraryRepository.openForApp(root.configuration)- await repository.shutdown()- guard case .ready = result else {- Issue.record("expected the retry to reach a ready library, got \(result)")- return- }- #expect(try root.markerText() == "8")- withExtendedLifetime(root) {}- }+ // "A partly committed V8 pass leaves the marker at 7 and the next open+ // completes it" stood here. Its subject was the marker-lagging arm running+ // `V8PopulationPass` and resuming a half-written run; V9's arm runs no data+ // pass at all (Q9), so there is nothing left to half-write.+ // `MarkerGenerationNineTests` pins the ordering the requirement is about. private final class MarkerRoot { let url: URL
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swiftindex c9da3a7..525cb61 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift@@ -237,7 +237,7 @@ struct PostCollapseRedirectTests { row.titleProvenanceRaw = TitleProvenance.parsed.rawValue // The survivor holds no URL identity at all; the collapsed loser's // basis carries a blank one. Both are the *title* bucket.- row.setLegacyColumns { $0.urlIdentity = nil }+ row.primaryMembershipEdit { $0.urlIdentity = nil } } let repository = try await library.openForApp()
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swiftindex 431febd..40b7feb 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift@@ -59,7 +59,7 @@ struct RecentPresentationToleranceTests { let attached = store.insertEntry( hostname: "present.example", title: "attached", offset: 10) attached.work = blank- attached.workAssignmentProvenance = .manual+ attached.editCitations { $0.workAssignment = .manual } store.insertEntry(hostname: "present.example", title: "unattached", offset: 20) } let repository = try await library.openForApp()@@ -133,9 +133,9 @@ struct RecentPresentationToleranceTests { let entry = store.insertEntry( id: entryID, hostname: "dup.example", title: "A Cited Work - Chapter 3", offset: 0)- entry.workAssignmentProvenance = .pattern- entry.workPatternID = losingPatternID- entry.workPatternVersion = 1+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: losingPatternID, version: 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@@ -177,9 +177,9 @@ struct RecentPresentationToleranceTests { 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+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: UUID(), version: 1))+ } store.insertEntry( hostname: "taught.example", title: "Unrelated Capture", offset: 10) }@@ -224,9 +224,9 @@ struct RecentPresentationToleranceTests { 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+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: citedID, version: 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@@ -273,7 +273,7 @@ struct RecentPresentationToleranceTests { // The Entry points at the loser; the group ordering still names the // row the whole library resolves that UUID to. entry.work = latest- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .manual } _ = earliest } let repository = try await library.openForApp()@@ -644,7 +644,7 @@ private final class RecentSeedStore { id: UUID = UUID(), hostname: String, title: String, offset: TimeInterval ) -> Work { let work = Work(- id: id, displayTitle: title, siteHostname: hostname,+ id: id, displayTitle: title, timestamp: RecentToleranceFixture.epoch.addingTimeInterval(offset)) context.insert(work) return work
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileAfterSyncTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileAfterSyncTests.swiftindex 4e49973..03f851e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileAfterSyncTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileAfterSyncTests.swift@@ -284,9 +284,9 @@ extension LibraryRepository { // The citation the taught rule left on the Entry (Req 1.4): it names // the rule the losing device minted, at the version that device gave // it, and the renumbering has to carry it.- entry.workAssignmentProvenance = .pattern- entry.workPatternID = minted[1].id- entry.workPatternVersion = 1+ entry.editCitations {+ $0.workAssignment = .pattern(CitedRule(id: minted[1].id, version: 1))+ } context.insert(entry) try context.save() }@@ -336,16 +336,13 @@ extension LibraryRepository { for rule in try context.fetch(FetchDescriptor<URLRulePattern>()) { versions[rule.id, default: []].insert(rule.version) }+ // Read from the blob, which is where a citation lives since V9 —+ // and every one of the seven, rather than the four the columns this+ // walked could spell. for entry in try context.fetch(FetchDescriptor<Entry>()) {- let citations = [- (entry.workPatternID, entry.workPatternVersion),- (entry.chapterPatternID, entry.chapterPatternVersion),- (entry.identityNameTitleRuleID, entry.identityNameTitleRuleVersion),- (entry.identityURLRuleID, entry.identityURLRuleVersion),- ]- for case (let id?, let version?) in citations- where versions[id]?.contains(version) != true {- return false+ for citation in entry.citationValues.ruleCitations {+ guard let cited = citation.cited else { continue }+ if versions[cited.id]?.contains(cited.version) != true { return false } } } return true
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileTierTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileTierTests.swiftindex 87e575b..1505d3d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileTierTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReconcileTierTests.swift@@ -4,90 +4,89 @@ import Testing @testable import AsterismCore -/// Decision 5: `reconcileAfterSync` runs the two whole-library V8 conversion-/// passes at the full tier only, and at the arrival tier only where the last-/// tolerance scan counted rows for them.+/// Q10: `reconcileAfterSync` runs `MembershipReconciler`'s heal phase only where+/// the last tolerance scan counted a Work with no membership, and runs the+/// dedupe phases whatever it counted. ///-/// The three cells are the three halves of that bargain — what an arrival over a-/// converged library declines to pay for (Req 8.6), what it still converges-/// because the counters can see it (Req 2.7), and what only the full tier-/// repairs because no counter can see it (Decision 4).+/// The gate used to be Decision 5's tier gate over `V8PopulationPass` and the+/// heal beside it. V9 deletes the pass — nothing converts a library any more —+/// and keeps the heal, on one gate and on both tiers: heal walks the whole Entry+/// table (0.505 s over the M4 fixture with nothing to do), and a library whose+/// every Work holds a membership has nothing for it to find, whichever tier+/// asked. @Suite("Tiered reconciliation", .serialized) struct ReconcileTierTests { private static let primary = "tiered.example" private static let secondary = "second.example" - // MARK: - Req 8.6: the arrival tier declines+ // MARK: - Q10: the gate is the diagnosis counter, on both tiers /// The gate's whole purpose, stated as behaviour rather than as a flag. ///- /// Two states are planted that the conversion passes *would* repair and that- /// the tolerance scan cannot count: a blob made stale under its columns- /// (Decision 4 — the row has a blob, so the nil count is zero) and an Entry- /// on a hostname its Work holds no membership for (the Work holds one on the- /// other hostname, so the membership-less count is zero). An arrival leaves- /// both, which is the only observable proof that neither whole-library pass- /// ran.- @Test("A no-op arrival runs neither whole-library pass")- func anArrivalOverAConvergedLibraryRunsNeitherPass() async throws {+ /// A state is planted that the heal *would* repair and that the tolerance+ /// scan cannot count: an Entry on a hostname its Work holds no membership+ /// for. The Work holds one on the other hostname, so the count is zero and+ /// no pass heals it — which is the cost Q10 accepts for taking a+ /// whole-table walk off every reconcile of a coherent library.+ @Test("A coherent library heals nothing, on either tier")+ func aCoherentLibraryRunsNoHeal() async throws { let env = try TierEnvironment() let (_, repository) = try await LibraryRepository.openForApp(env.configuration) try await repository.seedConvergedLibrary() try await repository.plantInvisibleWork() try await repository.refreshDiagnostics()- #expect(await repository.pendingConversionPhases.isEmpty)+ #expect(await repository.worksWithoutMembership == false) - let outcome = try await repository.reconcileAfterSync(tier: .arrival)+ #expect(try await repository.reconcileAfterSync(tier: .arrival).isEmpty)+ #expect(try await repository.membershipHostnames() == [Self.primary]) - #expect(outcome.isEmpty)- #expect(try await repository.citationsOfPrimaryEntry().workAssignment == .none)+ // The full tier asks the same question and gets the same answer: the+ // tier stopped deciding this at V9.+ _ = try await repository.reconcileAfterSync(tier: .full) #expect(try await repository.membershipHostnames() == [Self.primary])+ #expect(try await repository.citationsOfPrimaryEntry().workAssignment == .manual) } - // MARK: - Req 2.7: the arrival tier still converges what it can see+ // MARK: - Req 8.1: what the counter can see is healed - /// A lagging V7 device writes a Work with no membership row and an Entry- /// with no citation blob. Both are counted by the scan, so the arrival that- /// follows runs the phases that convert them — the gate narrows the pass, it- /// does not retire it.- @Test("An arrival carrying a V7-shaped row still converges it")- func anArrivalConvergesTheShapeItCanCount() async throws {+ /// A Work whose membership never arrived — a lagging writer's row, or one+ /// half of a sync that landed out of order. The scan counts it, so the very+ /// next reconcile heals it from its Entry, on the arrival tier: the gate+ /// narrows the phase, it does not retire it.+ @Test("A reconcile heals a Work the diagnosis counted without a membership")+ func aCountedWorkIsHealed() async throws { let env = try TierEnvironment() let (_, repository) = try await LibraryRepository.openForApp(env.configuration) try await repository.seedV7ShapedRow() try await repository.refreshDiagnostics()- #expect(await repository.pendingConversionPhases == [.memberships, .citations])+ #expect(await repository.worksWithoutMembership) _ = try await repository.reconcileAfterSync(tier: .arrival) #expect(try await repository.membershipHostnames() == [Self.primary])- #expect(try await repository.primaryEntryHasCitationBlob()) } - // MARK: - Decision 4: the full tier still value-guards+ // MARK: - The dedupe phases are not gated on any of this - /// The consequence Decision 5 accepts, asserted from the other side: what an- /// arrival leaves standing, the next full-tier pass repairs. Launch, import- /// completion and every reader action are full-tier, so a stale blob's- /// lifetime is bounded by those rather than by the debounce.- @Test("A full-tier reconcile re-derives a stale blob and heals the membership")- func theFullTierRepairsWhatNoCounterCanSee() async throws {+ /// Whatever the membership counter says, the Site and duplicate phases run:+ /// the gate Q10 adds is the heal's alone.+ @Test("A coherent library still runs the Site phase on both tiers")+ func theDedupePhasesAreUngated() async throws { let env = try TierEnvironment() let (_, repository) = try await LibraryRepository.openForApp(env.configuration) try await repository.seedConvergedLibrary()- try await repository.plantInvisibleWork() try await repository.refreshDiagnostics()+ #expect(await repository.worksWithoutMembership == false) - _ = try await repository.reconcileAfterSync(tier: .full)-- // The value guard re-derived the blob from the columns (Decision 4).- #expect(try await repository.citationsOfPrimaryEntry().workAssignment == .manual)- // And heal minted the missing hostname's membership (Req 8.1).- #expect(try await repository.membershipHostnames() == [Self.secondary, Self.primary])+ // Nothing to do, and no throw: the phases ran and found the library+ // coherent, which is what an empty outcome over a coherent library means.+ #expect(try await repository.reconcileAfterSync(tier: .arrival).isEmpty)+ #expect(try await repository.reconcileAfterSync(tier: .full).isEmpty) }+ } // MARK: - Environment@@ -113,13 +112,42 @@ extension LibraryRepository { /// One Site, one Work and one Entry, converged by a full-tier pass: the Work /// holds its membership and the Entry holds its citation blob.+ /// A library the app itself produced: the Work minted through `create`, so+ /// it holds its membership, and the Entry born with its citation blob.+ ///+ /// It used to be `seedV7ShapedRow` plus a full reconcile, because the pass+ /// converted whatever the seed left behind. Nothing converts anything since+ /// V9, so the seed writes the converged shape directly. fileprivate func seedConvergedLibrary() async throws {- try await seedV7ShapedRow()- _ = try await reconcileAfterSync(tier: .full)+ try await withLockedContext(mode: .exclusive, operation: "seeding a converged library") {+ context in+ for hostname in [Self.tierPrimary, Self.tierSecondary] {+ context.insert(Site(hostname: hostname, displayName: hostname))+ }+ let site = try LibraryRepository.fetchSites(+ hostname: Self.tierPrimary, context: context).first+ let work = Work.create(+ in: context, title: "The Serial", hostname: Self.tierPrimary, site: site,+ timestamp: Self.tierEpoch)+ work.lastParsedTitle = "The Serial"+ work.titleProvenance = .parsed+ let entry = Entry(+ captureTitle: "Chapter 1", captureTitleSource: .host,+ rawURLString: "https://\(Self.tierPrimary)/chapter-1",+ hostname: Self.tierPrimary, entryIdentityKey: "chapter-1",+ timestamp: Self.tierEpoch)+ entry.conservativeIdentityKey = entry.rawURLString+ entry.entryIdentityKey = entry.rawURLString+ context.insert(entry)+ entry.work = work+ entry.editCitations { $0.workAssignment = .manual }+ entry.site = site+ try context.save()+ } } - /// The Req 2.7 shape: a Work with its retained hostname column and no- /// membership row, and an Entry with no citation blob.+ /// The Req 8.1 shape: a Work whose membership never arrived, reachable only+ /// through its Entry. The diagnosis counts it, which is what arms the heal. fileprivate func seedV7ShapedRow() async throws { try await withLockedContext(mode: .exclusive, operation: "seeding a V7-shaped row") { context in@@ -127,7 +155,7 @@ extension LibraryRepository { context.insert(Site(hostname: hostname, displayName: hostname)) } let work = Work(- displayTitle: "The Serial", siteHostname: Self.tierPrimary,+ displayTitle: "The Serial", timestamp: Self.tierEpoch) work.lastParsedTitle = "The Serial" work.titleProvenance = .parsed@@ -141,27 +169,25 @@ extension LibraryRepository { entry.entryIdentityKey = entry.rawURLString context.insert(entry) entry.work = work- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .manual } entry.site = try LibraryRepository.fetchSites( hostname: Self.tierPrimary, context: context).first try context.save() } } - /// The two states the tolerance scan cannot count, planted on a converged- /// library: a blob out of step with its columns, and an Entry on a hostname- /// its Work holds no membership for.+ /// The state the tolerance scan cannot count, planted on a converged+ /// library: an Entry on a hostname its Work holds no membership for.+ ///+ /// It used to plant a second one beside it — a blob out of step with its+ /// columns — which V9 made unreachable: the blob *is* the citations, so+ /// there is nothing for it to fall out of step with. fileprivate func plantInvisibleWork() async throws {- try await withLockedContext(mode: .exclusive, operation: "planting a stale blob") {+ try await withLockedContext(mode: .exclusive, operation: "planting an invisible work") { context in let entry = try #require( context.fetch(FetchDescriptor<Entry>()) .first { $0.hostname == Self.tierPrimary })- var stale = try entry.citations- // The columns say `.manual`; a lagging writer's blob says otherwise.- stale.workAssignment = .none- try entry.setCitations(stale)- let work = try #require(entry.work) let second = Entry( captureTitle: "Chapter 1", captureTitleSource: .host,@@ -172,12 +198,11 @@ extension LibraryRepository { second.entryIdentityKey = second.rawURLString context.insert(second) second.work = work- second.workAssignmentProvenance = .manual+ second.editCitations { $0.workAssignment = .manual } second.site = try LibraryRepository.fetchSites( hostname: Self.tierSecondary, context: context).first- // Its blob is written here, so the nil-blob counter stays zero and- // the gate has nothing to see.- _ = LegacyColumns.refreshCitations(on: second)+ // `init` and the edit above wrote its blob, so the nil-blob counter+ // stays zero and the gate has nothing to see. try context.save() } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swiftindex c8c8c2a..915f459 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift@@ -214,7 +214,7 @@ private func seedWorkAssignment( descriptor.fetchLimit = 2 let entries = try context.fetch(descriptor) guard entries.count == 1, let entry = entries.first else { throw CocoaError(.fileReadCorruptFile) }- let work = Work(id: workID, displayTitle: "Work", siteHostname: entry.hostname, timestamp: timestamp)+ let work = Work(id: workID, displayTitle: "Work", timestamp: timestamp) context.insert(work) entry.work = work try context.save()
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swiftindex a9d1096..5fd6cab 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift@@ -1503,9 +1503,7 @@ private struct TeachingFixture { desc.fetchLimit = 1 guard let entry = try context.fetch(desc).first else { return } entry.chapterTitle = chapter- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.manual.rawValue- entry.chapterPatternID = nil- entry.chapterPatternVersion = nil+ entry.editCitations { $0.chapterTitle = .manual } try context.save() } @@ -1518,13 +1516,11 @@ private struct TeachingFixture { desc.fetchLimit = 1 guard let entry = try context.fetch(desc).first else { return } entry.chapterTitle = nil- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.none.rawValue- entry.chapterPatternID = nil- entry.chapterPatternVersion = nil entry.work = nil- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.none.rawValue- entry.workPatternID = nil- entry.workPatternVersion = nil+ entry.editCitations {+ $0.chapterTitle = .absent+ $0.workAssignment = .none+ } entry.intentionallyUnattached = false try context.save() }@@ -1548,9 +1544,10 @@ private struct TeachingFixture { throw LibraryRepositoryError.recordNotFound(type: "Entry", id: entryID) } entry.work = nil- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.workPatternID = patternID- entry.workPatternVersion = patternVersion+ entry.editCitations {+ $0.workAssignment = .pattern(+ CitedRule(id: patternID, version: patternVersion))+ } entry.intentionallyUnattached = false try context.save() }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swiftindex f611184..a137626 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift@@ -31,7 +31,7 @@ struct RuleSuggestionCandidatesTests { rawURLString: url, hostname: hostname, entryIdentityKey: url, timestamp: capturedAt) entry.conservativeIdentityKey = url- entry.identityKeyVersion = 1+ entry.editCitations { $0.identity = .rawURL } entry.identityBasis = .conservative context.insert(entry) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swiftindex 5f17b2e..4da0169 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift@@ -759,22 +759,20 @@ struct ShareWorkContextReadTests { ], characters: [M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID)]) - // A `.pattern` chapter provenance citing no pattern, which- // `snapshot(EntryGroup)` refuses through `FieldProvenance`'s combination- // check. Set in the read's own context and never saved: the store on+ // Citation bytes no decoder will read, which `snapshot(EntryGroup)`+ // refuses. Set in the read's own context and never saved: the store on // disk stays as seeded, and the failure is the notes half's alone. // // It used to be an unrecognised rating raw. T-2271 made unknown enum // raws read as the column's default (Q2), so that no longer fails- // anything — but the combination invariant is untouched by that policy- // and drives the same half, which is all this test needs.+ // anything. It was then a `.pattern` provenance citing no pattern, which+ // T-2280 made unspellable by folding the citations into a typed blob.+ // A malformed row still fails closed there, which is all this test needs. let context = try await fixture.repository.withLockedContext( mode: .exclusive, operation: "corrupting a row for the test" ) { context in for row in try context.fetch(FetchDescriptor<Entry>()) {- row.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- row.chapterPatternID = nil- row.chapterPatternVersion = nil+ row.citationsData = Data("not json".utf8) } return try LibraryRepository.shareWorkContext( forWorkID: Self.workID, currentChapterSequence: nil,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swiftindex a7701c3..296695d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift@@ -88,9 +88,6 @@ struct SiteReconcilerTests { // the site, whichever row now represents it. #expect(workOnLoser.membershipValues.count == 1) #expect(workOnLoser.membershipValues.first?.site === winner)- // `Work.site` is left where it was: nothing reads it any more, and V9- // drops it.- #expect(workOnLoser.legacySite === loser) #expect(outcome.repinnedRecords == 2) } @@ -134,18 +131,20 @@ struct SiteReconcilerTests { try store.addPattern(to: loser, version: 2, active: false, rank: 6) let entry = store.addEntry(site: winner, offset: 0) entry.chapterTitle = "Chapter 1"- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = cited.id- entry.chapterPatternVersion = 1+ entry.editCitations {+ $0.chapterTitle = FieldProvenance.tolerant(+ kind: .pattern, patternID: cited.id, patternVersion: 1)+ } try store.commit() try store.reconcile() - #expect(entry.chapterPatternVersion == cited.version)- #expect(entry.chapterPatternVersion != 1)+ let chapter = entry.citationValues.chapterTitle+ #expect(chapter.patternVersion == cited.version)+ #expect(chapter.patternVersion != 1) let owned = try #require(entry.site).patternValues let resolved = owned.filter {- $0.id == entry.chapterPatternID && $0.version == entry.chapterPatternVersion+ $0.id == chapter.patternID && $0.version == chapter.patternVersion } #expect(resolved.count == 1) // And the whole library still validates — an unresolved citation on a@@ -236,7 +235,6 @@ struct SiteReconcilerTests { #expect(dangling.site === site) // The Work half of the heal is the membership half now (Req 1.5). #expect(danglingWork.membershipValues.first?.site === site)- #expect(danglingWork.legacySite == nil) #expect(outcome.healedRecords == 2) // Q40: "no row yet" is absence of evidence, not a fact to write against. // Materialising one here is what would mint a duplicate per hostname@@ -417,12 +415,12 @@ private final class ReconcilerStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismSiteReconciler-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) self.saveStrategy = saveStrategy ?? saveRecorder
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRelationshipPopulationPassTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRelationshipPopulationPassTests.swiftdeleted file mode 100644index 4820b83..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRelationshipPopulationPassTests.swift+++ /dev/null@@ -1,773 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import AsterismCore--/// The site-relationship population 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("Site relationship population pass", .serialized)-struct SiteRelationshipPopulationPassTests {-- // 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)- try FileManager.default.createDirectory(- at: configuration.storeURL.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.openContainer(at: storeURL)- let context = ModelContext(container)- try SiteRelationshipPopulationPass.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.openContainer(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.setLegacySite(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.openContainer(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.legacySite != 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.legacyColumns.hostname- urlIdentity = work.legacyColumns.urlIdentity- urlIdentityStateRaw = work.legacyIdentityStateRaw- urlIdentityRuleID = work.legacyColumns.urlIdentityRuleID- urlIdentityRuleVersion = work.urlIdentityRuleVersion- workURLString = work.legacyColumns.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.openContainer(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.openContainer(at: cfg.storeURL)- 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.storeURL)- 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.storeURL)- #expect(try linkedRecordCounts(at: cfg.storeURL) == (0, 0),- "the pass must start from an all-nil graph or it proves nothing")-- try runPass(at: cfg.storeURL)-- #expect(try linkedRecordCounts(at: cfg.storeURL)- == (LibraryRepository.m4FixtureEntryCount, expectedWorks),- "the pass populates every relationship whose hostname carries a Site row (Req 2.1)")-- let after = try GraphSnapshot(storeURL: cfg.storeURL)- #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.openContainer(at: cfg.storeURL)- 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.legacySite)- #expect(site.hostname == work.legacyColumns.hostname)- if let ruleID = work.legacyColumns.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.openContainer(at: cfg.storeURL)- 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 SiteRelationshipPopulationPass.run(context: context)- #expect(entry.site === taught)- #expect(work.legacySite === 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.setLegacySite(untaught)- try context.save()- try SiteRelationshipPopulationPass.run(context: context)- #expect(entry.site === taught, "a record pinned to a losing row is reassigned to the winner (Q33)")- #expect(work.legacySite === 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 SiteRelationshipPopulationPass.run(context: context)- #expect(entry.site === taught)- #expect(work.legacySite === 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.openContainer(at: cfg.storeURL)- 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.storeURL)- try runPass(at: cfg.storeURL)- let after = try GraphSnapshot(storeURL: cfg.storeURL)- #expect(after == before, "seed \(seed): the pass must change no field, tuple, timestamp or count")-- let assignments = try assignmentsByRecord(at: cfg.storeURL, seed: seed)-- // Re-run over the same store from a stripped state: same rows again.- try stripRelationships(at: cfg.storeURL)- try runPass(at: cfg.storeURL)- let rerun = try assignmentsByRecord(at: cfg.storeURL, 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.openContainer(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.legacyColumns.hostname, context: context)- if rows.isEmpty {- #expect(work.legacySite == nil, "seed \(seed): \(work.displayTitle) names no Site row, so its relationship stays nil")- } else {- #expect(work.legacySite === rows.first, "seed \(seed): \(work.displayTitle) must pin to the SiteResolutionOrder winner")- }- assignments["work:\(work.displayTitle)"] = work.legacySite?.displayName- }- return assignments- }-- // MARK: - Pre-conversion losslessness at scale (Req 2.2)-- /// Everything else in this suite is built at the current schema, so it never- /// crosses a conversion boundary at all. This runs the whole thing over a- /// 432-Entry store recorded one version back: 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.- ///- /// The store was a committed 4.0.0 `.sqlite` until the V6 plan declared a- /// real stage, which refuses a model version the plan does not declare. It- /// is now seeded in-process through the frozen `AsterismSchemaV5` snapshot —- /// the same shape, at the version installed libraries actually hold and the- /// only one a container will still raise.- @Test("A 432-Entry 5.0.0-recorded store converts and migrates with every field intact")- func scaleRecordedStoreConvertsLosslessly() throws {- typealias Fixture = V5RecordedScaleStoreFixture- let (dir, cfg) = try config()- try Fixture.install(at: cfg.storeURL)- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["5.0.0"],- "the seed must be written by the frozen snapshot, not the live classes")-- try runPass(at: cfg.storeURL)- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["8.0.0"])-- let container = try LibraryRepository.openContainer(at: cfg.storeURL)- 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(try 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.legacyColumns.hostname == hostname)- #expect(work.legacyColumns.urlIdentity == "\(workIndex)")- #expect(work.legacyColumns.urlIdentityState == .rule)- #expect(work.legacyColumns.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.legacySite === winner, "\(work.displayTitle) pins to the winner")- #expect(work.entryValues.count == Fixture.entriesPerWork)- #expect(winner.urlRuleValues.contains {- $0.id == work.legacyColumns.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 with relationships nil")- func convergesTheConvertedStore() throws {- let (dir, cfg) = try config()- try V5RecordedStoreFixture.install(at: cfg.storeURL)-- // `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 the live version- // whose relationships are nil — not an unconverted store.- do {- let container = try LibraryRepository.openContainer(at: cfg.storeURL)- _ = ModelContext(container)- withExtendedLifetime(container) {}- }- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["8.0.0"],- "the conversion is committed before the pass runs")-- let container = try LibraryRepository.openContainer(at: cfg.storeURL)- 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.legacySite == nil)-- // The re-run converges on the already-converted store.- try SiteRelationshipPopulationPass.run(context: context)- #expect(entry.site?.hostname == V5RecordedStoreFixture.hostname)- #expect(work.legacySite?.hostname == V5RecordedStoreFixture.hostname)- expectCitationsResolve(entry: entry, in: try #require(entry.site))-- // And a run over the converged store is a no-op that converges again.- try SiteRelationshipPopulationPass.run(context: context)- #expect(entry.site?.hostname == V5RecordedStoreFixture.hostname)- #expect(work.legacySite?.hostname == V5RecordedStoreFixture.hostname)- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swiftindex 0af07e6..da30b4e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift@@ -322,12 +322,12 @@ private final class ProjectionStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismSiteUnion-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swiftindex c5d28d9..38ca916 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift@@ -176,28 +176,28 @@ struct StoreMetadataTests { /// state is constructed here — the container stays live, holding its /// conversion in the log — and the reader has to see through it. ///- /// The store is seeded at 5.0.0 through the frozen snapshot. It used to be+ /// The store is seeded at 8.0.0 through the frozen snapshot. It used to be /// the 4.0.0 fixture, which made the stale reading directly visible- /// (`.below("4.0.0")` versus `.atOrAboveV5`); the declared V5 → V6 stage- /// refuses that store outright, so the only conversion left to hold in a log- /// is one both readings classify the same way. What survives is the property- /// that matters for the classifier: the recorded version read across an- /// in-flight conversion is the converted one, and no reading is+ /// (`.below("4.0.0")` versus `.atOrAboveV5`); each declared stage in turn+ /// refused an older store outright, so the only conversion left to hold in a+ /// log is one both readings classify the same way. What survives is the+ /// property that matters for the classifier: the recorded version read+ /// across an in-flight conversion is the converted one, and no reading is /// `.indeterminate` or stale. @Test("A conversion still in the write-ahead log is read from the log, not the main file") func uncommittedConversionIsReadFromTheLog() throws { let dir = try TempDir()- try V5RecordedStoreFixture.install(at: dir.storeURL)+ try V8RecordedStoreFixture.install(at: dir.storeURL) #expect(StoreMetadata.recordedVersion(at: dir.storeURL) == .atOrAboveV5, "the premise: the seed is a store the classifier admits")- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["5.0.0"])+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["8.0.0"]) let container = try LibraryRepository.openContainer(at: dir.storeURL) _ = ModelContext(container) try #require(FileManager.default.fileExists(atPath: dir.storeURL.path + "-wal"), "the conversion has to be in the log for this to be the hazard") - #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["8.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["9.0.0"], "the conversion is committed in the log, so a reader of the log sees it") #expect(StoreMetadata.recordedVersion(at: dir.storeURL) == .atOrAboveV5, "a reader that ignored the log would still have to answer, not refuse")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swiftindex bbb7449..4d9681c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceIntegrationTests.swift@@ -252,7 +252,8 @@ struct URLOptionalSequenceDerivationTests { // Req 3.3: the same key *shape* the siblings receive, not merely a key. let sibling = try library.entry(siblingIDs[0])- #expect(entry.identityKeyVersion == sibling.identityKeyVersion)+ #expect(entry.citationValues.identity.keyVersion+ == sibling.citationValues.identity.keyVersion) #expect(entry.identityBasis == sibling.identityBasis) #expect(entry.entryIdentityKey == (try TthRules.identityKey(work: "30975", sequence: "1"))) let decoded = try EntryIdentityKeyV2Codec.decode(entry.entryIdentityKey)@@ -264,16 +265,14 @@ struct URLOptionalSequenceDerivationTests { // Req 3.1: attached to the Work carrying that identity, and only it. #expect(try library.works().count == 1) #expect(entry.work?.id == sibling.work?.id)- #expect(entry.work?.legacyColumns.urlIdentity == "30975")+ #expect(entry.work?.primaryMembership?.urlIdentity == "30975") // Req 3.6: per-field provenance names the rule and version that derived // the identity, on both the extraction and the assignment it drove. let rule = try library.currentURLRule()- #expect(entry.urlWorkRuleID == rule.id)- #expect(entry.urlWorkRuleVersion == rule.version)+ #expect(entry.citationValues.urlWork == CitedRule(id: rule.id, version: rule.version)) #expect(entry.workAssignmentProvenance == .urlRule)- #expect(entry.workURLRuleID == rule.id)- #expect(entry.workURLRuleVersion == rule.version)+ #expect(entry.citationValues.workURL == CitedRule(id: rule.id, version: rule.version)) #expect(entry.workURLAssignmentKind == .identity) } @@ -296,7 +295,7 @@ struct URLOptionalSequenceDerivationTests { let entry = try library.entry(id) #expect(entry.urlWorkIdentity == "40404")- #expect(entry.work?.legacyColumns.urlIdentity == "40404")+ #expect(entry.work?.primaryMembership?.urlIdentity == "40404") #expect(entry.work?.displayTitle == "A Different Story") #expect(try library.works().count == 2) }@@ -341,7 +340,7 @@ struct URLOptionalSequenceDerivationTests { #expect(entry.urlWorkIdentity == "30975") #expect(entry.chapterSequence == "1") // No Work was invented from the URL identity.- #expect(try library.works().allSatisfy { $0.legacyColumns.urlIdentity != nil })+ #expect(try library.works().allSatisfy { $0.primaryMembership?.urlIdentity != nil }) #expect(try library.works().count == 1) } @@ -438,7 +437,7 @@ struct URLOptionalSequenceTeachingCommitTests { let priorMaxVersion = try library.urlRules().map(\.version).max() ?? 0 let before = try library.entry(chapterOneID) #expect(before.urlWorkIdentity == nil)- #expect(before.identityKeyVersion == 1)+ #expect(before.citationValues.identity.keyVersion == 1) #expect(before.entryIdentityKey == TthRules.chapterOneURL) #expect(before.work != nil, "the pre-state groups chapter 1 by title claim") #expect(try library.works().count == 1)@@ -472,11 +471,11 @@ struct URLOptionalSequenceTeachingCommitTests { // Work that holds it — no second Work for the same identity. let works = try library.works() #expect(works.count == 1)- #expect(works[0].legacyColumns.urlIdentity == "30975")+ #expect(works[0].primaryMembership?.urlIdentity == "30975") let after = try library.entry(chapterOneID) #expect(after.urlWorkIdentity == "30975") #expect(after.chapterSequence == "1")- #expect(after.identityKeyVersion == 2)+ #expect(after.citationValues.identity.keyVersion == 2) #expect(after.work?.id == works[0].id) for entry in try library.entries() { #expect(entry.work?.id == works[0].id, "\(entry.rawURLString) is not in the story's Work")@@ -484,8 +483,7 @@ struct URLOptionalSequenceTeachingCommitTests { // Req 4.2's provenance half: the chapter-1 entry cites the rule. let rule = try library.currentURLRule()- #expect(after.urlWorkRuleID == rule.id)- #expect(after.urlWorkRuleVersion == rule.version)+ #expect(after.citationValues.urlWork == CitedRule(id: rule.id, version: rule.version)) // Req 6.1: the declaration is a property of the one current rule. It // mints the next version, and no second current rule appears.@@ -537,26 +535,22 @@ struct URLOptionalSequenceTeachingCommitTests { let unattached = try #require(entries.first { $0.id == unattachedID }) unattached.work = nil unattached.intentionallyUnattached = true- unattached.workAssignmentProvenance = .manual- unattached.workPatternID = nil- unattached.workPatternVersion = nil- unattached.workURLRuleID = nil- unattached.workURLRuleVersion = nil- unattached.workURLAssignmentKindRaw = nil+ unattached.editCitations {+ $0.workAssignment = .manual+ $0.workURL = nil+ $0.workURLAssignmentKind = nil+ } let manualChapter = try #require(entries.first { $0.id == manualChapterID }) manualChapter.chapterTitle = "The one I named myself"- manualChapter.chapterTitleProvenance = .manual- manualChapter.chapterPatternID = nil- manualChapter.chapterPatternVersion = nil+ manualChapter.editCitations { $0.chapterTitle = .manual } let manualAssignment = try #require(entries.first { $0.id == manualAssignmentID })- manualAssignment.workAssignmentProvenance = .manual- manualAssignment.workPatternID = nil- manualAssignment.workPatternVersion = nil- manualAssignment.workURLRuleID = nil- manualAssignment.workURLRuleVersion = nil- manualAssignment.workURLAssignmentKindRaw = nil+ manualAssignment.editCitations {+ $0.workAssignment = .manual+ $0.workURL = nil+ $0.workURLAssignmentKind = nil+ } } try await library.teach(TthRules.rule(.optional))@@ -572,7 +566,9 @@ struct URLOptionalSequenceTeachingCommitTests { let manualAssignment = try library.entry(manualAssignmentID) #expect(manualAssignment.workAssignmentProvenance == .manual) #expect(manualAssignment.work?.id == manualWorkID)- #expect(manualAssignment.workURLRuleID == nil, "the rule overwrote a manual assignment")+ #expect(+ manualAssignment.citationValues.workURL == nil,+ "the rule overwrote a manual assignment") // The declaration still did its work for everything it was allowed to // touch, so this is a protection test rather than a no-op test.@@ -635,7 +631,7 @@ struct URLOptionalSequenceTeachingCommitTests { // the collision has to be reported rather than shrugged off. let stranded = try #require(works.first { $0.id == strandedWorkID }) #expect(stranded.entryValues.isEmpty)- #expect(stranded.legacyColumns.urlIdentity == "30975")+ #expect(stranded.primaryMembership?.urlIdentity == "30975") } /// Decision 4: removal is another re-teach, not a rollback. The documented@@ -657,16 +653,16 @@ struct URLOptionalSequenceTeachingCommitTests { let after = try library.entry(chapterOneID) #expect(after.urlWorkIdentity == nil) #expect(after.chapterSequence == nil)- #expect(after.identityKeyVersion == 1)+ #expect(after.citationValues.identity.keyVersion == 1) #expect(after.entryIdentityKey == TthRules.chapterOneURL)- #expect(after.urlWorkRuleID == nil)+ #expect(after.citationValues.urlWork == nil) // And the site is not back where it started: the Work that held every // capture has had its identity cleared by the failed extraction, which // is the re-derivation's outcome and not the pre-declaration state. let declaredWork = try #require(try library.works().first { $0.id == declaredWorkID })- #expect(declaredWork.legacyColumns.urlIdentity == nil)- #expect(declaredWork.legacyColumns.urlIdentityState == .none)+ #expect(declaredWork.primaryMembership?.urlIdentity == nil)+ #expect(declaredWork.primaryMembership?.urlIdentityState == WorkURLIdentityState.none) // The documented post-state, and the reason the warning exists: chapter // 1 is left behind in the Work whose identity the failed extraction just
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swiftindex 38ad825..0ae9d3a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift@@ -945,7 +945,7 @@ struct URLOptionalSequenceArchiveTests { #expect(decoded.payload == payload) #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .optional) - let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let container = try ModelContainer( for: schema, configurations: [
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/UnreadableURLRuleTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/UnreadableURLRuleTests.swiftindex 9f1c950..ae32ee0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/UnreadableURLRuleTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/UnreadableURLRuleTests.swift@@ -200,15 +200,15 @@ struct UnreadableURLRuleTests { let context = fixture.freshContext() let entry = try #require( try context.fetch(FetchDescriptor<Entry>()).first { $0.id == snapshot.id })- // The conservative path: the raw URL as the key, version 1, and no- // citation of the rule that could not be read.+ // The conservative path: the raw URL as the key, the `.rawURL` identity+ // arm, and no citation of the rule that could not be read. #expect(entry.entryIdentityKey == rawURL) #expect(entry.conservativeIdentityKey == rawURL)- #expect(entry.identityKeyVersion == 1) #expect(entry.identityBasis == .conservative) #expect(entry.urlWorkIdentity == nil)- #expect(entry.identityURLRuleID == nil)- #expect(entry.urlWorkRuleID == nil)+ let citations = entry.citationValues+ #expect(citations.identity == .rawURL)+ #expect(citations.urlWork == nil) } /// Re-teaching is the repair, so the basis has to build. It seeds nothing@@ -253,9 +253,9 @@ struct UnreadableURLRuleTests { let context = fixture.freshContext() let work = try #require( try context.fetch(FetchDescriptor<Work>()).first { $0.id == workID })- #expect(work.legacyColumns.urlIdentity == UnreadableRuleFixture.identityValue)- #expect(work.legacyColumns.urlIdentityState == .rule)- #expect(work.legacyColumns.urlIdentityRuleID == unreadableID)+ #expect(work.primaryMembership?.urlIdentity == UnreadableRuleFixture.identityValue)+ #expect(work.primaryMembership?.urlIdentityState == .rule)+ #expect(work.primaryMembership?.urlIdentityRuleID == unreadableID) } @Test("Merge over an unreadable rule retains the identity rather than clearing it")@@ -270,8 +270,9 @@ struct UnreadableURLRuleTests { sourceWorkID: sourceID, targetWorkID: targetID) #expect(contract.basis.ruleUnreadable)- guard case .retain = contract.outcome.identityDisposition else {- Issue.record("expected .retain, got \(contract.outcome.identityDisposition)")+ let site = try #require(contract.outcome.sites.first)+ guard case .retain = site.identityDisposition else {+ Issue.record("expected .retain, got \(site.identityDisposition)") return } // The reader is still told, so the state is loud rather than silent.@@ -294,8 +295,9 @@ struct UnreadableURLRuleTests { sourceWorkID: sourceID, targetWorkID: targetID) #expect(!contract.basis.ruleUnreadable)- guard case .clear = contract.outcome.identityDisposition else {- Issue.record("expected .clear, got \(contract.outcome.identityDisposition)")+ let site = try #require(contract.outcome.sites.first)+ guard case .clear = site.identityDisposition else {+ Issue.record("expected .clear, got \(site.identityDisposition)") return } }@@ -479,8 +481,9 @@ struct UnreadableRuleFixture { let context = ModelContext(container) let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first) entry.urlWorkIdentity = Self.identityValue- entry.urlWorkRuleID = ruleID- entry.urlWorkRuleVersion = ruleVersion+ entry.editCitations {+ $0.urlWork = CitedRule(id: ruleID, version: ruleVersion)+ } try context.save() }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swiftindex 9ed69e8..d597044 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift@@ -7,15 +7,17 @@ import Testing /// The one store in the repository actually **recorded at 4.0.0**. ///-/// Nothing on this branch can write one any more: the live classes are V6's, so-/// every store a test creates today is recorded at 6.0.0 (or at 5.0.0, through-/// the frozen snapshot — see `V5RecordedStoreFixture`). It is the only input+/// Nothing on this branch can write one any more: the live classes are V9's, so+/// every store a test creates today is recorded at 9.0.0 (or at 8.0.0, through+/// the frozen snapshot — see `V8RecordedStoreFixture`). It is the only input /// that positively reads *below* V5, which is what `StoreMetadataTests`, /// `BootstrapClassifierTests` and `BootstrapActionTests` need it for. ///-/// It is no longer a *convertible* store: since the V6 plan declares a real-/// stage, a model version the plan does not declare is refused rather than-/// raised implicitly. What can still be asserted about it is the refusal.+/// It is no longer a *convertible* store: since the plan declares real stages, a+/// model version the plan does not declare is refused rather than raised+/// implicitly. Under `AsterismV9MigrationPlan` = `[V8, V9]` the floor has risen+/// three versions since that first became true, so 4.0.0 is refused with more+/// room to spare than ever. What can still be asserted about it is the refusal. /// /// 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@@ -53,7 +55,8 @@ enum V4RecordedStoreFixture { /// The schema versions Core Data recorded into the store's own metadata — /// `["4.0.0"]` for this fixture (nothing converts a 4.0.0 store any more;- /// the V5-seeded suites use this helper to observe `"5.0.0"`/`"6.0.0"`).+ /// `V8RecordedStoreTests` uses this helper to observe `"8.0.0"` before the+ /// stage and `"9.0.0"` after it). /// 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] {@@ -93,24 +96,26 @@ enum V4RecordedStoreFixture { enum FixtureError: Error { case unreadable(String) } } -/// A store a *pre-freeze* build wrote is **refused** by the V6 plan, and the+/// A store a *pre-freeze* build wrote is **refused** by the V9 plan, and the /// refusal leaves it exactly as it was. /// /// This suite used to measure the opposite: with a single-schema plan and no /// stages, the coordinator raised a 4.0.0 store through Core Data's *implicit* /// lightweight migration (Q23 of `retire-migration-chain`). Declaring a real-/// stage for V5 → V6 ends that — a staged migration refuses a model version the-/// plan does not declare — so the conversion those tests asserted, and the 5.0.0-/// end state they pinned, no longer exist to assert. The 432-Entry scale fixture-/// went with them: nothing can open it.+/// stage ended that — a staged migration refuses a model version the plan does+/// not declare — so the conversion those tests asserted, and the 5.0.0 end state+/// they pinned, no longer exist to assert. The 432-Entry scale fixture went with+/// them: nothing can open it. `AsterismV9MigrationPlan` is `[V8, V9]`, so the+/// floor has since risen three more versions and 4.0.0 is refused by a wider+/// margin than when this suite was written. /// /// Nothing shipped changes. `classify` already refuses a below-V5 store before /// any container is constructed (Req 2.9, Decision 1 of /// `retire-migration-chain`), and the recovery for one remains the 4/4 backup-/// archive. The conversion coverage moved to `V5RecordedStoreTests`, which seeds-/// its input through the frozen snapshot — the version installed libraries-/// actually hold.-@Suite("A 4.0.0-recorded store under the V6 plan", .serialized)+/// archive. The conversion coverage is `V8RecordedStoreTests`, which seeds its+/// input through the frozen V8 snapshot — the version installed libraries+/// actually hold, and under `[V8, V9]` the only one that converts at all.+@Suite("A 4.0.0-recorded store under the V9 plan", .serialized) struct V4RecordedStoreTests { private final class TempDir {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreFixture.swiftdeleted file mode 100644index 0e3015f..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreFixture.swift+++ /dev/null@@ -1,287 +0,0 @@-import Foundation-import SwiftData--@testable import AsterismCore--/// Stores genuinely **recorded at 5.0.0**, seeded in-process through the frozen-/// `AsterismSchemaV5` snapshot.-///-/// It replaces `V4RecordedStoreFixture` as the *convertible* input. Since the-/// V6 plan declares a real stage (`.lightweight(V5 → V6)`), a store recorded at-/// 4.0.0 is no longer raised implicitly — the coordinator refuses a model-/// version the plan does not declare — so 5.0.0 is now the only version below-/// the live one that a container will open. That is also the version every-/// installed library actually holds, which makes this the more faithful input-/// than a committed binary from a build nobody runs.-///-/// Seeding through the snapshot rather than shipping another `.sqlite` is what-/// the nesting buys: a container over `AsterismSchemaV5` records 5.0.0 in the-/// store's own metadata, and the fixture cannot drift out of sync with the-/// snapshot it is built from.-enum V5RecordedStoreFixture {- static let hostname = "frozen.example"- static let siteDisplayName = "Frozen Example"- static let patternID = UUID(uuidString: "22222222-2222-2222-2222-000000000005")!- static let urlRuleID = UUID(uuidString: "33333333-3333-3333-3333-000000000005")!- static let workID = UUID(uuidString: "44444444-4444-4444-4444-000000000005")!- static let entryID = UUID(uuidString: "55555555-5555-5555-5555-000000000005")!- static let captureTitle = "Chapter 7 — A Frozen Work"- static let note = "Recorded at 5.0.0 ✓"- static let rawURLString = "https://frozen.example/read?series=42&chapter=7"- static let timestamp = Date(timeIntervalSince1970: 1_800_000_000)-- /// Opens a container over the frozen snapshot at `storeURL`, hands its- /// context to `seed`, saves, and releases the container so the file on disk- /// is a closed store recorded at 5.0.0.- static func write(at storeURL: URL, seed: (ModelContext) throws -> Void) throws {- try FileManager.default.createDirectory(- at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV5.self)- let configuration = ModelConfiguration(- // The same store-configuration name `openContainer` uses; a- // mismatch here would make the reopen create a second store.- "AsterismV3", schema: schema, url: storeURL, cloudKitDatabase: .none)- let container = try ModelContainer(for: schema, configurations: [configuration])- let context = ModelContext(container)- try seed(context)- try context.save()- withExtendedLifetime(container) {}- }-- /// One row of each model, on a taught Site: the V5 counterpart of the- /// one-row `V4RecordedStoreFixture`. Relationships are left nil — that is- /// the state `SiteRelationshipPopulationPass` exists to repair, and the- /// state a marker-lagging library is in.- static func install(at storeURL: URL) throws {- try write(at: storeURL) { context in- let site = AsterismSchemaV5.Site()- site.hostname = hostname- site.displayName = siteDisplayName- site.modeRaw = SiteMode.taught.rawValue- context.insert(site)-- // A taught Site owes the validator exactly one active title rule, so- // the seed carries the rule rows — which also puts every rule column- // across the conversion boundary.- let pattern = AsterismSchemaV5.TitlePattern()- pattern.id = patternID- pattern.version = 3- pattern.isActive = true- pattern.createdAt = timestamp- pattern.formRaw = PatternForm.phrase.rawValue- pattern.phrasePrefix = ""- pattern.phraseSeparator = " — "- pattern.phraseSuffix = ""- pattern.fieldOrderRaw = FieldOrder.chapterThenWork.rawValue- pattern.trimPrefix = "["- pattern.trimSuffix = "]"- context.insert(pattern)- pattern.site = site-- let rule = AsterismSchemaV5.URLRulePattern()- rule.id = urlRuleID- rule.version = 2- rule.isCurrent = true- rule.createdAt = timestamp- rule.originRaw = URLRuleOrigin.readerTaught.rawValue- rule.definitionData = try JSONEncoder().encode(- URLRuleDefinition.work(locator: .query(name: ExactScalarString("series"))))- context.insert(rule)- rule.site = site-- let work = AsterismSchemaV5.Work()- work.id = workID- work.displayTitle = "A Frozen Work"- work.siteHostname = hostname- work.typeRaw = WorkType.toon.rawValue- work.urlIdentity = "42"- work.urlIdentityStateRaw = WorkURLIdentityState.rule.rawValue- work.urlIdentityRuleID = urlRuleID- work.urlIdentityRuleVersion = 2- work.genreTags = ["frozen", "fixture"]- work.genericNotes = "notes"- work.createdAt = timestamp- work.modifiedAt = timestamp- context.insert(work)-- let entry = AsterismSchemaV5.Entry()- entry.id = entryID- entry.captureTitle = captureTitle- entry.rawURLString = rawURLString- entry.entryIdentityKey = rawURLString- entry.conservativeIdentityKey = rawURLString- entry.hostname = hostname- entry.note = note- entry.ratingRaw = Rating.up.rawValue- entry.firstCapturedAt = timestamp- entry.lastSharedAt = timestamp- entry.modifiedAt = timestamp- // Citation provenance, so the pass's "every cited rule resolves- // inside the assigned Site" check has something to resolve.- entry.chapterTitle = "Chapter 7"- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = patternID- entry.chapterPatternVersion = 3- entry.chapterSequence = "7"- entry.chapterSequenceRuleID = urlRuleID- entry.chapterSequenceRuleVersion = 2- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.urlRule.rawValue- entry.workURLRuleID = urlRuleID- entry.workURLRuleVersion = 2- entry.workURLAssignmentKindRaw = URLWorkAssignmentKind.identity.rawValue- entry.urlWorkIdentity = "42"- entry.urlWorkRuleID = urlRuleID- entry.urlWorkRuleVersion = 2- context.insert(entry)- entry.work = work- }- }-}--/// The 432-Entry V5 store: three hostnames, one of them carrying a tolerated-/// duplicate Site row, twelve Works each, twelve Entries per Work.-///-/// Every value is a pure function of its indices, so the assertions against it-/// are the seeded values rather than a re-read of the store. It reproduces the-/// shape the retired `v4-recorded-4.0.0-scale.sqlite` carried — the store that-/// suite could no longer open once the V6 stage was declared — at the version-/// the shipped conversion actually starts from.-enum V5RecordedScaleStoreFixture {- 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 func install(at storeURL: URL) throws {- try V5RecordedStoreFixture.write(at: storeURL) { context in- let urlDefinition = URLRuleDefinition.sequence(- locator: .query(name: ExactScalarString("chapter")))-- for (hostIndex, hostname) in hostnames.enumerated() {- let site = AsterismSchemaV5.Site()- site.hostname = hostname- site.displayName = siteDisplayName(hostIndex: hostIndex)- site.modeRaw = SiteMode.taught.rawValue- context.insert(site)-- let pattern = AsterismSchemaV5.TitlePattern()- pattern.id = patternID(hostIndex: hostIndex)- pattern.version = patternVersion- pattern.isActive = true- pattern.createdAt = ts- pattern.formRaw = PatternForm.phrase.rawValue- pattern.phrasePrefix = "Chapter "- pattern.phraseSeparator = " of "- pattern.phraseSuffix = "."- pattern.fieldOrderRaw = FieldOrder.chapterThenWork.rawValue- pattern.trimPrefix = "["- pattern.trimSuffix = "]"- context.insert(pattern)- pattern.site = site-- let rule = AsterismSchemaV5.URLRulePattern()- rule.id = urlRuleID(hostIndex: hostIndex)- rule.version = urlRuleVersion- rule.isCurrent = true- rule.createdAt = ts- rule.originRaw = URLRuleOrigin.readerTaught.rawValue- rule.definitionData = try JSONEncoder().encode(urlDefinition)- context.insert(rule)- rule.site = site-- if hostname == duplicatedHostname {- let duplicate = AsterismSchemaV5.Site()- duplicate.hostname = hostname- duplicate.displayName = duplicateRowDisplayName- duplicate.modeRaw = SiteMode.untaught.rawValue- context.insert(duplicate)- }-- for workIndex in 0..<worksPerHost {- let work = AsterismSchemaV5.Work()- work.id = workID(hostIndex: hostIndex, workIndex: workIndex)- work.displayTitle = workTitle(hostIndex: hostIndex, workIndex: workIndex)- work.lastParsedTitle = work.displayTitle- work.siteHostname = hostname- work.urlIdentity = "\(workIndex)"- work.urlIdentityStateRaw = WorkURLIdentityState.rule.rawValue- work.urlIdentityRuleID = urlRuleID(hostIndex: hostIndex)- work.urlIdentityRuleVersion = urlRuleVersion- work.genericNotes = "notes \(hostIndex)-\(workIndex)"- work.genreTags = ["genre\(hostIndex)"]- work.titleProvenanceRaw = TitleProvenance.parsed.rawValue- work.createdAt = ts.addingTimeInterval(Double(workIndex))- work.modifiedAt = work.createdAt- context.insert(work)-- for entryIndex in 0..<entriesPerWork {- let raw = rawURL(- hostname: hostname, workIndex: workIndex, entryIndex: entryIndex)- let entry = AsterismSchemaV5.Entry()- entry.id = entryID(- hostIndex: hostIndex, workIndex: workIndex, entryIndex: entryIndex)- entry.captureTitle = captureTitle(- hostIndex: hostIndex, workIndex: workIndex, entryIndex: entryIndex)- entry.captureTitleSourceRaw = CaptureTitleSource.host.rawValue- entry.rawURLString = raw- entry.entryIdentityKey = raw- entry.conservativeIdentityKey = raw- entry.hostname = hostname- entry.note = "note \(entryIndex)"- entry.firstCapturedAt = ts.addingTimeInterval(Double(entryIndex))- entry.lastSharedAt = entry.firstCapturedAt- entry.modifiedAt = entry.firstCapturedAt- entry.chapterTitle = "Chapter \(entryIndex + 1)"- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = patternID(hostIndex: hostIndex)- entry.chapterPatternVersion = patternVersion- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.workPatternID = patternID(hostIndex: hostIndex)- entry.workPatternVersion = patternVersion- entry.chapterSequence = "\(entryIndex + 1)"- entry.chapterSequenceRuleID = urlRuleID(hostIndex: hostIndex)- entry.chapterSequenceRuleVersion = urlRuleVersion- context.insert(entry)- entry.work = work- }- }- }- }- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreTests.swiftdeleted file mode 100644index 6c4f6ed..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreTests.swift+++ /dev/null@@ -1,152 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import AsterismCore--/// The V5 → V6 → V7 conversion, over a store genuinely **recorded at 5.0.0**.-///-/// This is the path every installed library takes on the update that ships-/// `configurable-work-types`: the store on disk was written by the V5 classes,-/// and `ModelContainer.init` runs the plan's lightweight stage on the way in.-/// Nothing else in the suite crosses that boundary — every other store a test-/// builds is born at 8.0.0 — so a regression here would otherwise only be-/// visible on the owner's phone.-///-/// It is the successor to `V4RecordedStoreTests`' conversion coverage, which the-/// declared stage made unreachable: a 4.0.0-recorded store is now refused rather-/// than raised implicitly.-@Suite("A 5.0.0-recorded store under the V8 plan", .serialized)-struct V5RecordedStoreTests {-- private final class TempDir {- let url: URL- init() throws {- url = FileManager.default.temporaryDirectory.appending(- path: "V5Recorded-\(UUID())", directoryHint: .isDirectory)- try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)- }- deinit { try? FileManager.default.removeItem(at: url) }- }-- private typealias Fixture = V5RecordedStoreFixture-- @Test("The seeded store really is recorded at 5.0.0")- func seedIsRecordedAtFiveZeroZero() throws {- let dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try Fixture.install(at: storeURL)-- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["5.0.0"])- }-- @Test("openContainer converts it to 8.0.0 and reads every field back intact")- func convertsAndReadsBackIntact() throws {- let dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try Fixture.install(at: storeURL)-- let container = try LibraryRepository.openContainer(at: storeURL)- 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 patterns = try context.fetch(FetchDescriptor<TitlePattern>())- let rules = try context.fetch(FetchDescriptor<URLRulePattern>())- let counts: [Int] = [sites.count, works.count, entries.count, patterns.count, rules.count]- #expect(counts == [1, 1, 1, 1, 1])-- let site = try #require(sites.first)- #expect(site.hostname == Fixture.hostname)- #expect(site.displayName == Fixture.siteDisplayName)- #expect(site.mode == .taught)-- let pattern = try #require(patterns.first)- #expect(pattern.id == Fixture.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 == Fixture.hostname)-- let rule = try #require(rules.first)- #expect(rule.id == Fixture.urlRuleID)- #expect(rule.version == 2)- #expect(rule.isCurrent)- #expect(rule.origin == .readerTaught)- #expect(try rule.definition == .work(locator: .query(name: ExactScalarString("series"))))-- let work = try #require(works.first)- #expect(work.id == Fixture.workID)- #expect(work.displayTitle == "A Frozen Work")- #expect(work.genreTags == ["frozen", "fixture"])- #expect(work.genericNotes == "notes")- // The compatibility column crosses the boundary untouched, and the new- // one arrives nil: a V5 library holds no configured assignments, so- // every work reads as legacy-typed or untyped (Req 2.4).- #expect(work.typeRaw == WorkType.toon.rawValue)- #expect(work.workTypeID == nil)-- let entry = try #require(entries.first)- #expect(entry.id == Fixture.entryID)- #expect(entry.captureTitle == Fixture.captureTitle)- #expect(entry.note == Fixture.note)- #expect(entry.rating == .up)- #expect(entry.rawURLString == Fixture.rawURLString)- #expect(entry.firstCapturedAt == Fixture.timestamp)- #expect(entry.work?.id == Fixture.workID)-- // The conversion leaves the Site relationships as it found them. That is- // the state the relationship pass exists to repair.- #expect(entry.site == nil)- #expect(work.legacySite == nil)-- // The new table arrives empty. Seeding the default list is an- // app-bootstrap step, not part of the conversion.- #expect(try context.fetch(FetchDescriptor<WorkTypeEntity>()).isEmpty)-- // V7's additions arrive empty and nil: a V5 library holds no characters,- // no suppressions and no extraction coverage (Req 6.8).- #expect(try context.fetch(FetchDescriptor<CharacterRecord>()).isEmpty)- #expect(try context.fetch(FetchDescriptor<CharacterSuppression>()).isEmpty)- #expect(entry.characterExtractionFingerprint == nil)- #expect(work.genericNotesExtractionFingerprint == nil)- #expect(work.characterValues.isEmpty)- }-- @Test("The store is left recorded at 8.0.0 once it has been opened")- func openingRecordsTheNewVersion() throws {- let dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try Fixture.install(at: storeURL)- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["5.0.0"])-- do {- let container = try LibraryRepository.openContainer(at: storeURL)- _ = ModelContext(container)- withExtendedLifetime(container) {}- }-- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["8.0.0"],- "ModelContainer.init is what runs both of the plan's lightweight stages")- }-- @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 Fixture.install(at: storeURL)-- let container = try LibraryRepository.openContainer(at: storeURL)- let context = ModelContext(container)- defer { withExtendedLifetime((dir, container)) {} }-- let diagnostics = try LibraryRepository.validateStore(context: context)- #expect(diagnostics.quarantineMap().isEmpty,- "the graph was legal when it was written at 5.0.0 and nothing was dropped")- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreFixture.swiftdeleted file mode 100644index 29cc5d9..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreFixture.swift+++ /dev/null@@ -1,153 +0,0 @@-import Foundation-import SwiftData--@testable import AsterismCore--/// A store genuinely **recorded at 6.0.0**, seeded in-process through the frozen-/// `AsterismSchemaV6` snapshot.-///-/// It is the successor `docs/agent-notes/schema-migration.md` predicted: the-/// convertible input at the version every installed library actually holds when-/// `character-extraction` ships. `V5RecordedStoreFixture` keeps working beside-/// it because the plan retains the V5 stage (Q80) — the note's warning that-/// every 5.0.0-seeded fixture would become unopenable applies to a plan that-/// *drops* V5, which this one deliberately does not.-///-/// Seeding through the snapshot rather than committing another `.sqlite` is what-/// the nesting buys: a container over `AsterismSchemaV6` records 6.0.0 in the-/// store's own metadata, and the fixture cannot drift out of sync with the-/// snapshot it is built from.-enum V6RecordedStoreFixture {- static let hostname = "frozen6.example"- static let siteDisplayName = "Frozen Six"- static let patternID = UUID(uuidString: "22222222-2222-2222-2222-000000000006")!- static let urlRuleID = UUID(uuidString: "33333333-3333-3333-3333-000000000006")!- static let workID = UUID(uuidString: "44444444-4444-4444-4444-000000000006")!- static let entryID = UUID(uuidString: "55555555-5555-5555-5555-000000000006")!- static let workTypeID = UUID(uuidString: "66666666-6666-6666-6666-000000000006")!- static let workTypeName = "Web Serial"- static let captureTitle = "Chapter 9 — A Frozen Six"- static let note = "Recorded at 6.0.0 ✓"- static let genericNotes = "generic notes, recorded at 6.0.0"- static let rawURLString = "https://frozen6.example/read?series=99&chapter=9"- static let timestamp = Date(timeIntervalSince1970: 1_820_000_000)-- /// Opens a container over the frozen V6 snapshot at `storeURL`, hands its- /// context to `seed`, saves, and releases the container so the file on disk- /// is a closed store recorded at 6.0.0.- static func write(at storeURL: URL, seed: (ModelContext) throws -> Void) throws {- try FileManager.default.createDirectory(- at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV6.self)- let configuration = ModelConfiguration(- // The same store-configuration name `openContainer` uses; a mismatch- // here would make the reopen create a second store.- "AsterismV3", schema: schema, url: storeURL, cloudKitDatabase: .none)- let container = try ModelContainer(for: schema, configurations: [configuration])- let context = ModelContext(container)- try seed(context)- try context.save()- withExtendedLifetime(container) {}- }-- /// One row of each V6 model, on a taught Site, with the Site relationships- /// **populated** — a V6 library has been through the relationship pass, which- /// is what distinguishes it from the V5 fixture's deliberately nil ones.- static func install(at storeURL: URL) throws {- try write(at: storeURL) { context in- let site = AsterismSchemaV6.Site()- site.hostname = hostname- site.displayName = siteDisplayName- site.modeRaw = SiteMode.taught.rawValue- context.insert(site)-- let pattern = AsterismSchemaV6.TitlePattern()- pattern.id = patternID- pattern.version = 4- pattern.isActive = true- pattern.createdAt = timestamp- pattern.formRaw = PatternForm.phrase.rawValue- pattern.phrasePrefix = ""- pattern.phraseSeparator = " — "- pattern.phraseSuffix = ""- pattern.fieldOrderRaw = FieldOrder.chapterThenWork.rawValue- context.insert(pattern)- pattern.site = site-- let rule = AsterismSchemaV6.URLRulePattern()- rule.id = urlRuleID- rule.version = 3- rule.isCurrent = true- rule.createdAt = timestamp- rule.originRaw = URLRuleOrigin.readerTaught.rawValue- // Both fields from one rule, so the entry below can cite it for its- // work identity *and* its chapter sequence and still replay equal —- // which is what keeps the converted store validating cleanly.- rule.definitionData = try JSONEncoder().encode(- URLRuleDefinition.workAndSequence(- work: URLFieldSelector(locator: .query(name: ExactScalarString("series"))),- sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter")))))- context.insert(rule)- rule.site = site-- // V6's own additions, so the conversion carries them across rather- // than merely tolerating their absence.- let type = AsterismSchemaV6.WorkTypeEntity()- type.id = workTypeID- type.name = workTypeName- type.stateRaw = WorkTypeState.active.rawValue- type.createdAt = timestamp- type.modifiedAt = timestamp- type.nameModifiedAt = timestamp- type.stateModifiedAt = timestamp- context.insert(type)-- let work = AsterismSchemaV6.Work()- work.id = workID- work.displayTitle = "A Frozen Six"- work.siteHostname = hostname- work.typeRaw = WorkType.other.rawValue- work.workTypeID = workTypeID- work.urlIdentity = "99"- work.urlIdentityStateRaw = WorkURLIdentityState.rule.rawValue- work.urlIdentityRuleID = urlRuleID- work.urlIdentityRuleVersion = 3- work.genreTags = ["frozen", "six"]- work.genericNotes = genericNotes- work.createdAt = timestamp- work.modifiedAt = timestamp- context.insert(work)- work.site = site-- let entry = AsterismSchemaV6.Entry()- entry.id = entryID- entry.captureTitle = captureTitle- entry.rawURLString = rawURLString- entry.entryIdentityKey = rawURLString- entry.conservativeIdentityKey = rawURLString- entry.hostname = hostname- entry.note = note- entry.ratingRaw = Rating.up.rawValue- entry.firstCapturedAt = timestamp- entry.lastSharedAt = timestamp- entry.modifiedAt = timestamp- entry.chapterTitle = "Chapter 9"- entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entry.chapterPatternID = patternID- entry.chapterPatternVersion = 4- entry.chapterSequence = "9"- entry.chapterSequenceRuleID = urlRuleID- entry.chapterSequenceRuleVersion = 3- entry.workAssignmentProvenanceRaw = FieldProvenanceKind.urlRule.rawValue- entry.workURLRuleID = urlRuleID- entry.workURLRuleVersion = 3- entry.workURLAssignmentKindRaw = URLWorkAssignmentKind.identity.rawValue- entry.urlWorkIdentity = "99"- entry.urlWorkRuleID = urlRuleID- entry.urlWorkRuleVersion = 3- context.insert(entry)- entry.work = work- entry.site = site- }- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreTests.swiftdeleted file mode 100644index 2a3f494..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreTests.swift+++ /dev/null@@ -1,153 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import AsterismCore--/// The V6 → V7 conversion, over a store genuinely **recorded at 6.0.0**.-///-/// This is the path every installed library takes on the update that ships-/// `character-extraction`: the store on disk was written by the V6 classes, and-/// `ModelContainer.init` runs the plan's second lightweight stage on the way in.-/// Every other store a test builds is born at 8.0.0, so a regression here would-/// otherwise only be visible on the owner's phone (Req 6.8).-@Suite("A 6.0.0-recorded store under the V8 plan", .serialized)-struct V6RecordedStoreTests {-- private final class TempDir {- let url: URL- init() throws {- url = FileManager.default.temporaryDirectory.appending(- path: "V6Recorded-\(UUID())", directoryHint: .isDirectory)- try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)- }- deinit { try? FileManager.default.removeItem(at: url) }- }-- private typealias Fixture = V6RecordedStoreFixture-- @Test("The seeded store really is recorded at 6.0.0")- func seedIsRecordedAtSixZeroZero() throws {- let dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try Fixture.install(at: storeURL)-- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["6.0.0"])- }-- @Test("openContainer converts it to 8.0.0 and reads every field back intact")- func convertsAndReadsBackIntact() throws {- let dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try Fixture.install(at: storeURL)-- let container = try LibraryRepository.openContainer(at: storeURL)- 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 types = try context.fetch(FetchDescriptor<WorkTypeEntity>())- let counts: [Int] = [sites.count, works.count, entries.count, types.count]- #expect(counts == [1, 1, 1, 1])-- let work = try #require(works.first)- #expect(work.id == Fixture.workID)- #expect(work.displayTitle == "A Frozen Six")- #expect(work.genericNotes == Fixture.genericNotes)- #expect(work.genreTags == ["frozen", "six"])- // V6's own column survives the second stage, and the relationships a V6- // library already carries are untouched.- #expect(work.workTypeID == Fixture.workTypeID)- #expect(work.legacySite?.hostname == Fixture.hostname)-- let entry = try #require(entries.first)- #expect(entry.id == Fixture.entryID)- #expect(entry.captureTitle == Fixture.captureTitle)- #expect(entry.note == Fixture.note)- #expect(entry.rating == .up)- #expect(entry.work?.id == Fixture.workID)- #expect(entry.site?.hostname == Fixture.hostname)-- #expect(try #require(types.first).name == Fixture.workTypeName)-- // Req 6.8: the migration carries everything forward and creates zero- // characters. The new columns arrive nil, which is what "uncovered"- // means — the sweep has never seen this library.- #expect(try context.fetch(FetchDescriptor<CharacterRecord>()).isEmpty)- #expect(try context.fetch(FetchDescriptor<CharacterSuppression>()).isEmpty)- #expect(entry.characterExtractionFingerprint == nil)- #expect(work.genericNotesExtractionFingerprint == nil)- #expect(work.characterValues.isEmpty)- #expect(work.characterSuppressionValues.isEmpty)- }-- @Test("The store is left recorded at 8.0.0 once it has been opened")- func openingRecordsTheNewVersion() throws {- let dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try Fixture.install(at: storeURL)- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["6.0.0"])-- do {- let container = try LibraryRepository.openContainer(at: storeURL)- _ = ModelContext(container)- withExtendedLifetime(container) {}- }-- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["8.0.0"],- "ModelContainer.init is what runs the plan's second lightweight stage")- }-- @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 Fixture.install(at: storeURL)-- let container = try LibraryRepository.openContainer(at: storeURL)- let context = ModelContext(container)- defer { withExtendedLifetime((dir, container)) {} }-- let diagnostics = try LibraryRepository.validateStore(context: context)- #expect(diagnostics.quarantineMap().isEmpty,- "the graph was legal when it was written at 6.0.0 and nothing was dropped")- }-- /// Req 6.8 through the shipped door rather than the container opener: the- /// V6 → V7 conversion is what `ModelContainer.init` performs inside- /// `openForApp`, and the store comes out recorded at 8.0.0 with its rows.- ///- /// The marker says `"7"` over a store still recorded at 6.0.0, which is the- /// only way to reach this path now: the `"6"` generation this fixture was- /// written for is refused since `data-model-cleanups` Decision 2, and a- /// refusal happens before any container exists, so it would convert nothing.- /// The marker's word governing a store whose recorded version disagrees is- /// the classifier's documented stance (Decision 5 of- /// `retire-migration-chain`), not a contrivance.- @Test("The app-role open converts a 6.0.0-recorded library on its way in")- func appOpenConvertsOnTheWayIn() async throws {- let dir = try TempDir()- let configuration = LibraryConfiguration(rootDirectory: dir.url)- try Fixture.install(at: configuration.storeURL)- try Data("7\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)-- let (result, repository) = try await LibraryRepository.openForApp(configuration)- await repository.shutdown()-- guard case .ready(let counts) = result else {- Issue.record("expected a ready library, got \(result)")- return- }- #expect(counts.entries == 1)- #expect(counts.works == 1)- #expect(counts.sites == 1)- #expect(try String(contentsOf: configuration.readinessMarkerURL, encoding: .utf8)- .trimmingCharacters(in: .whitespacesAndNewlines) == "8",- "the \"7\" marker is the lagging generation: the open runs the pass and republishes")- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: configuration.storeURL)- == ["8.0.0"])- withExtendedLifetime(dir) {}- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V7RecordedStoreFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V7RecordedStoreFixture.swiftdeleted file mode 100644index 1dcc8de..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V7RecordedStoreFixture.swift+++ /dev/null@@ -1,320 +0,0 @@-import Foundation-import SwiftData--@testable import AsterismCore--/// A store genuinely **recorded at 7.0.0**, seeded in-process through the frozen-/// `AsterismSchemaV7` snapshot.-///-/// It is the successor `docs/agent-notes/schema-migration.md` predicted: the-/// convertible input at the version every installed library actually holds when-/// `multi-site-works` ships. `V5RecordedStoreFixture` and-/// `V6RecordedStoreFixture` keep working beside it because the plan retains-/// both earlier stages.-///-/// Seeding through the snapshot rather than committing another `.sqlite` is what-/// the nesting buys: a container over `AsterismSchemaV7` records 7.0.0 in the-/// store's own metadata, and the fixture cannot drift out of sync with the-/// snapshot it is built from.-///-/// **Every superseded column is populated**, because that is what V8's-/// population pass reads: the Work's six site/identity/URL columns and its-/// legacy `typeRaw`, the pattern's ten definition columns, and all seventeen-/// Entry citation columns plus `identityKeyVersion`.-///-/// V7's own two additions are seeded too, even though V8 converts neither: a-/// `Character` carrying a fact and a `CharacterSuppression`, plus both coverage-/// fingerprint columns. Nothing reads them across the stage, which is the-/// reason to assert they survive it.-///-/// One row per V7 model, **except `Entry`, which gets two**. The seventeen-/// citation columns cannot all be set on one Entry in a graph the validator-/// accepts: the v2 identity arm forbids a name contributor, the v3 arm forbids a-/// Work extraction, and pattern assignment and URL assignment exclude each-/// other. Entry A carries the v2/URL-rule half, Entry B the v3/pattern half, and-/// between them every column is non-nil.-enum V7RecordedStoreFixture {- static let hostname = "frozen7.example"- static let siteDisplayName = "Frozen Seven"- static let patternID = UUID(uuidString: "22222222-2222-2222-2222-000000000007")!- static let patternVersion = 5- static let urlRuleID = UUID(uuidString: "33333333-3333-3333-3333-000000000007")!- static let urlRuleVersion = 3- static let workID = UUID(uuidString: "44444444-4444-4444-4444-000000000007")!- static let entryAID = UUID(uuidString: "55555555-5555-5555-5555-000000000007")!- static let entryBID = UUID(uuidString: "55555555-5555-5555-5555-000000000017")!- static let workTypeID = UUID(uuidString: "66666666-6666-6666-6666-000000000007")!- static let workTypeName = "Web Serial"- static let characterID = UUID(uuidString: "77777777-7777-7777-7777-000000000007")!- static let characterName = "Seven of Frozen"- static let characterNameKey = "seven of frozen"- static let characterAliases = ["Seven", "Frozen Seven"]- static let characterNote = "The one the fixture names."- static let suppressionID = UUID(uuidString: "88888888-8888-8888-8888-000000000007")!- static let suppressionNameKey = "the narrator"- static let workName = "A Frozen Seven"- static let genericNotes = "generic notes, recorded at 7.0.0"- static let workURLString = "https://frozen7.example/series/77"- static let workIdentity = "77"- /// A pre-feature `typeRaw` with no `workTypeID` beside it: Q16's case, which- /// V8 presents as untyped (Req 10.3).- static let legacyTypeRaw = "novel"- static let timestamp = Date(timeIntervalSince1970: 1_830_000_000)-- static let trimPrefix = "Read: "- static let trimSuffix = " | Frozen Seven"- static let phraseSeparator = " — "-- static let entryANote = "Recorded at 7.0.0 ✓"- static let entryASequence = "11"- static let entryACaptureTitle = "Read: Chapter 11 — A Frozen Seven | Frozen Seven"- static let entryARawURL = "https://frozen7.example/read?series=77&chapter=11"-- static let entryBNote = "Recorded at 7.0.0, name-keyed"- static let entryBSequence = "12"- static let entryBCaptureTitle = "Read: Chapter 12 — A Frozen Seven | Frozen Seven"- static let entryBRawURL = "https://frozen7.example/read?series=77&chapter=12"-- /// The fact the seeded character carries, cited from Entry A — so the- /// `factsData` blob is genuinely populated rather than an empty array.- static var characterFact: CharacterFact {- CharacterFact(- statement: "Seven is the narrator.", quote: "I am Seven.",- nameKey: characterNameKey, source: .entry(entryAID))- }-- /// The two V7 coverage fingerprints, which are the *fingerprint of the text- /// they cover*: a pass that covered this note would have written exactly- /// this, so the seeded pair is self-consistent (Q81).- static var entryACoverage: String { CharacterCoverageFingerprint.of(entryANote) }- static var workNotesCoverage: String { CharacterCoverageFingerprint.of(genericNotes) }-- /// The pattern arm the fixture seeds, as the live V8 type sees it.- static var patternDefinition: PatternDefinition {- .phrase(prefix: "", separator: phraseSeparator, suffix: "", order: .chapterThenWork)- }-- /// The URL rule the fixture seeds, as the live V8 type sees it.- static var urlRuleDefinition: URLRuleDefinition {- .workAndSequence(- work: URLFieldSelector(locator: .query(name: ExactScalarString("series"))),- sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter"))))- }-- /// Opens a container over the frozen V7 snapshot at `storeURL`, hands its- /// context to `seed`, saves, and releases the container so the file on disk- /// is a closed store recorded at 7.0.0.- static func write(at storeURL: URL, seed: (ModelContext) throws -> Void) throws {- try FileManager.default.createDirectory(- at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV7.self)- let configuration = ModelConfiguration(- // The same store-configuration name `openContainer` uses; a mismatch- // here would make the reopen create a second store.- "AsterismV3", schema: schema, url: storeURL, cloudKitDatabase: .none)- let container = try ModelContainer(for: schema, configurations: [configuration])- let context = ModelContext(container)- try seed(context)- try context.save()- withExtendedLifetime(container) {}- }-- /// The Work name the seeded title rule derives from either capture title —- /// computed rather than written out, so the v3 identity key the fixture- /// stores always replays from the rule it cites.- static func derivedWorkName(from captureTitle: String) throws -> String {- guard case .success(let parsed) = TitleRuleApplicator.apply(- definition: patternDefinition, trimPrefix: trimPrefix, trimSuffix: trimSuffix,- to: captureTitle) else {- throw ModelInvariantError.invalidCombination(field: "V7 fixture title replay")- }- return parsed.workName- }-- static func install(at storeURL: URL) throws {- let v2Key = EntryIdentityKeyV2Codec.encode(- try URLDerivedEntryIdentity(- hostname: ExactScalarString(hostname),- workIdentity: ExactScalarString(workIdentity),- chapterSequence: ExactScalarString(entryASequence)))- let v3Key = EntryIdentityKeyV3Codec.encode(- try URLSequenceNameIdentity(- hostname: ExactScalarString(hostname),- workName: ExactScalarString(try derivedWorkName(from: entryBCaptureTitle)),- chapterSequence: ExactScalarString(entryBSequence)))-- try write(at: storeURL) { context in- let site = AsterismSchemaV7.Site()- site.hostname = hostname- site.displayName = siteDisplayName- site.modeRaw = SiteMode.taught.rawValue- context.insert(site)-- // All ten definition columns of the phrase arm, trims included.- let pattern = AsterismSchemaV7.TitlePattern()- pattern.id = patternID- pattern.version = patternVersion- pattern.isActive = true- pattern.createdAt = timestamp- pattern.formRaw = PatternForm.phrase.rawValue- pattern.segmentWorkAnchor = nil- pattern.segmentIgnoredAnchors = nil- pattern.phrasePrefix = ""- pattern.phraseSeparator = phraseSeparator- pattern.phraseSuffix = ""- pattern.fieldOrderRaw = FieldOrder.chapterThenWork.rawValue- pattern.trimPrefix = trimPrefix- pattern.trimSuffix = trimSuffix- pattern.chapterless = false- context.insert(pattern)- pattern.site = site-- let rule = AsterismSchemaV7.URLRulePattern()- rule.id = urlRuleID- rule.version = urlRuleVersion- rule.isCurrent = true- rule.createdAt = timestamp- rule.originRaw = URLRuleOrigin.readerTaught.rawValue- // Both fields from one rule, so an entry can cite it for its work- // identity *and* its chapter sequence and still replay equal.- rule.definitionData = try JSONEncoder().encode(urlRuleDefinition)- context.insert(rule)- rule.site = site-- let type = AsterismSchemaV7.WorkTypeEntity()- type.id = workTypeID- type.name = workTypeName- type.stateRaw = WorkTypeState.active.rawValue- type.createdAt = timestamp- type.modifiedAt = timestamp- type.nameModifiedAt = timestamp- type.stateModifiedAt = timestamp- context.insert(type)-- // The six site/identity/URL columns V8 moves into a membership, and- // the legacy `typeRaw` V8 stops reading.- let work = AsterismSchemaV7.Work()- work.id = workID- work.displayTitle = workName- work.lastParsedTitle = workName- work.siteHostname = hostname- work.urlIdentity = workIdentity- work.urlIdentityStateRaw = WorkURLIdentityState.rule.rawValue- work.urlIdentityRuleID = urlRuleID- work.urlIdentityRuleVersion = urlRuleVersion- work.workURLString = workURLString- work.typeRaw = legacyTypeRaw- work.workTypeID = nil- work.genreTags = ["frozen", "seven"]- work.genericNotes = genericNotes- work.titleProvenanceRaw = TitleProvenance.manual.rawValue- work.createdAt = timestamp- work.modifiedAt = timestamp- // V7's second addition: the generic-notes coverage fingerprint.- work.genericNotesExtractionFingerprint = workNotesCoverage- context.insert(work)- work.site = site-- // Entry A: the v2 identity arm and URL-rule work assignment.- let entryA = AsterismSchemaV7.Entry()- entryA.id = entryAID- entryA.captureTitle = entryACaptureTitle- entryA.captureTitleSourceRaw = CaptureTitleSource.host.rawValue- entryA.rawURLString = entryARawURL- entryA.entryIdentityKey = v2Key- entryA.conservativeIdentityKey = entryARawURL- entryA.identityBasisRaw = EntryIdentityBasis.urlRule.rawValue- entryA.identityKeyVersion = 2- entryA.identityURLRuleID = urlRuleID- entryA.identityURLRuleVersion = urlRuleVersion- entryA.hostname = hostname- entryA.note = entryANote- entryA.ratingRaw = Rating.up.rawValue- entryA.firstCapturedAt = timestamp- entryA.lastSharedAt = timestamp- entryA.modifiedAt = timestamp- entryA.urlWorkIdentity = workIdentity- entryA.urlWorkRuleID = urlRuleID- entryA.urlWorkRuleVersion = urlRuleVersion- entryA.chapterSequence = entryASequence- entryA.chapterSequenceRuleID = urlRuleID- entryA.chapterSequenceRuleVersion = urlRuleVersion- entryA.chapterTitle = "Chapter 11"- entryA.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entryA.chapterPatternID = patternID- entryA.chapterPatternVersion = patternVersion- entryA.workAssignmentProvenanceRaw = FieldProvenanceKind.urlRule.rawValue- entryA.workURLRuleID = urlRuleID- entryA.workURLRuleVersion = urlRuleVersion- entryA.workURLAssignmentKindRaw = URLWorkAssignmentKind.identity.rawValue- // V7's first addition: the note-coverage fingerprint.- entryA.characterExtractionFingerprint = entryACoverage- context.insert(entryA)- entryA.work = work- entryA.site = site-- // Entry B: the v3 identity arm — which is the only place- // `identityNameTitleRule*` can be set — and pattern work assignment,- // which is the only place `workPattern*` can be.- let entryB = AsterismSchemaV7.Entry()- entryB.id = entryBID- entryB.captureTitle = entryBCaptureTitle- entryB.captureTitleSourceRaw = CaptureTitleSource.host.rawValue- entryB.rawURLString = entryBRawURL- entryB.entryIdentityKey = v3Key- entryB.conservativeIdentityKey = entryBRawURL- entryB.identityBasisRaw = EntryIdentityBasis.urlRule.rawValue- entryB.identityKeyVersion = 3- entryB.identityURLRuleID = urlRuleID- entryB.identityURLRuleVersion = urlRuleVersion- entryB.identityNameTitleRuleID = patternID- entryB.identityNameTitleRuleVersion = patternVersion- entryB.hostname = hostname- entryB.note = entryBNote- entryB.firstCapturedAt = timestamp- entryB.lastSharedAt = timestamp- entryB.modifiedAt = timestamp- entryB.chapterSequence = entryBSequence- entryB.chapterSequenceRuleID = urlRuleID- entryB.chapterSequenceRuleVersion = urlRuleVersion- entryB.chapterTitle = "Chapter 12"- entryB.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entryB.chapterPatternID = patternID- entryB.chapterPatternVersion = patternVersion- entryB.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue- entryB.workPatternID = patternID- entryB.workPatternVersion = patternVersion- context.insert(entryB)- entryB.work = work- entryB.site = site-- // V7's two tables. Nothing in V8 converts them — they ride through- // the stage untouched — which is exactly why they are seeded: task- // 2 asks for one row per V7 model, and a stage that lost a table- // nobody read would be found on the owner's phone.- let character = AsterismSchemaV7.Character()- character.id = characterID- character.name = characterName- character.nameKey = characterNameKey- character.aliases = characterAliases- character.note = characterNote- character.factsData = CharacterFactCodec.encode([characterFact])- character.createdAt = timestamp- character.modifiedAt = timestamp- context.insert(character)- character.work = work-- let suppression = AsterismSchemaV7.CharacterSuppression()- suppression.id = suppressionID- suppression.kindRaw = CharacterSuppressionKind.candidate.rawValue- suppression.nameKey = suppressionNameKey- suppression.sourceKindRaw = nil- suppression.sourceEntryID = nil- suppression.evidence = nil- suppression.statusRaw = CharacterSuppressionStatus.active.rawValue- suppression.actionAt = timestamp- context.insert(suppression)- suppression.work = work- }- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V7RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V7RecordedStoreTests.swiftdeleted file mode 100644index a589976..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V7RecordedStoreTests.swift+++ /dev/null@@ -1,194 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import AsterismCore--/// The V7 → V8 conversion, over a store genuinely **recorded at 7.0.0**.-///-/// This is the path every installed library takes on the update that ships-/// `multi-site-works`: the store on disk was written by the V7 classes, and-/// `ModelContainer.init` runs the plan's third lightweight stage on the way in.-/// Every other store a test builds is born at 8.0.0, so a regression here would-/// otherwise only be visible on the owner's phone (Req 2.1).-///-/// The stage **only adds** (Decision 3), so what this suite pins is that every-/// superseded column survives the conversion with its value: they are what-/// `V8PopulationPass` reads afterwards, and a stage that lost one would lose the-/// library's site presence with it.-@Suite("A 7.0.0-recorded store under the V8 plan", .serialized)-struct V7RecordedStoreTests {-- private final class TempDir {- let url: URL- init() throws {- url = FileManager.default.temporaryDirectory.appending(- path: "V7Recorded-\(UUID())", directoryHint: .isDirectory)- try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)- }- deinit { try? FileManager.default.removeItem(at: url) }- }-- private typealias Fixture = V7RecordedStoreFixture-- @Test("The seeded store really is recorded at 7.0.0")- func seedIsRecordedAtSevenZeroZero() throws {- let dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try Fixture.install(at: storeURL)-- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["7.0.0"])- }-- @Test("openContainer converts it to 8.0.0 and reads every legacy column back")- func convertsAndReadsBackIntact() throws {- let dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try Fixture.install(at: storeURL)-- let container = try LibraryRepository.openContainer(at: storeURL)- 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 patterns = try context.fetch(FetchDescriptor<TitlePattern>())- let types = try context.fetch(FetchDescriptor<WorkTypeEntity>())- let counts: [Int] = [- sites.count, works.count, entries.count, patterns.count, types.count,- ]- #expect(counts == [1, 1, 2, 1, 1])-- // The Work's six site/identity/URL columns and its legacy type, which is- // exactly what the population pass copies into a membership.- let work = try #require(works.first)- #expect(work.id == Fixture.workID)- #expect(work.displayTitle == Fixture.workName)- #expect(work.legacyColumns.hostname == Fixture.hostname)- #expect(work.legacySite?.hostname == Fixture.hostname)- #expect(work.legacyColumns.urlIdentity == Fixture.workIdentity)- #expect(work.legacyIdentityStateRaw == WorkURLIdentityState.rule.rawValue)- #expect(work.legacyColumns.urlIdentityRuleID == Fixture.urlRuleID)- #expect(work.urlIdentityRuleVersion == Fixture.urlRuleVersion)- #expect(work.legacyColumns.workURLString == Fixture.workURLString)- #expect(work.typeRaw == Fixture.legacyTypeRaw)- #expect(work.genericNotes == Fixture.genericNotes)- #expect(work.genreTags == ["frozen", "seven"])-- // The pattern's ten definition columns.- let pattern = try #require(patterns.first)- #expect(pattern.formRaw == PatternForm.phrase.rawValue)- #expect(pattern.segmentWorkAnchor == nil)- #expect(pattern.segmentIgnoredAnchors == nil)- #expect(pattern.phrasePrefix == "")- #expect(pattern.phraseSeparator == Fixture.phraseSeparator)- #expect(pattern.phraseSuffix == "")- #expect(pattern.fieldOrderRaw == FieldOrder.chapterThenWork.rawValue)- #expect(pattern.trimPrefix == Fixture.trimPrefix)- #expect(pattern.trimSuffix == Fixture.trimSuffix)- #expect(pattern.chapterless == false)-- // The seventeen Entry citation columns and `identityKeyVersion`, across- // the two entries the fixture needs to cover them all.- let entryA = try #require(entries.first { $0.id == Fixture.entryAID })- #expect(entryA.identityKeyVersion == 2)- #expect(entryA.identityURLRuleID == Fixture.urlRuleID)- #expect(entryA.identityURLRuleVersion == Fixture.urlRuleVersion)- #expect(entryA.urlWorkRuleID == Fixture.urlRuleID)- #expect(entryA.urlWorkRuleVersion == Fixture.urlRuleVersion)- #expect(entryA.chapterSequenceRuleID == Fixture.urlRuleID)- #expect(entryA.chapterSequenceRuleVersion == Fixture.urlRuleVersion)- #expect(entryA.chapterTitleProvenanceRaw == FieldProvenanceKind.pattern.rawValue)- #expect(entryA.chapterPatternID == Fixture.patternID)- #expect(entryA.chapterPatternVersion == Fixture.patternVersion)- #expect(entryA.workAssignmentProvenanceRaw == FieldProvenanceKind.urlRule.rawValue)- #expect(entryA.workURLRuleID == Fixture.urlRuleID)- #expect(entryA.workURLRuleVersion == Fixture.urlRuleVersion)- #expect(entryA.workURLAssignmentKindRaw == URLWorkAssignmentKind.identity.rawValue)- #expect(entryA.note == Fixture.entryANote)- #expect(entryA.rating == .up)- #expect(entryA.work?.id == Fixture.workID)- #expect(entryA.site?.hostname == Fixture.hostname)-- let entryB = try #require(entries.first { $0.id == Fixture.entryBID })- #expect(entryB.identityKeyVersion == 3)- #expect(entryB.identityNameTitleRuleID == Fixture.patternID)- #expect(entryB.identityNameTitleRuleVersion == Fixture.patternVersion)- #expect(entryB.workPatternID == Fixture.patternID)- #expect(entryB.workPatternVersion == Fixture.patternVersion)- #expect(entryB.workAssignmentProvenanceRaw == FieldProvenanceKind.pattern.rawValue)-- #expect(try #require(types.first).name == Fixture.workTypeName)-- // V7's own two tables and its two coverage fingerprints. V8 converts- // none of them, so what this pins is that the stage carried them- // through — a table or a column silently lost here would only surface- // as missing characters on the owner's phone.- let characters = try context.fetch(FetchDescriptor<CharacterRecord>())- #expect(characters.count == 1)- let character = try #require(characters.first)- #expect(character.id == Fixture.characterID)- #expect(character.name == Fixture.characterName)- #expect(character.nameKey == Fixture.characterNameKey)- #expect(character.aliases == Fixture.characterAliases)- #expect(character.note == Fixture.characterNote)- #expect(character.facts == [Fixture.characterFact])- #expect(character.work?.id == Fixture.workID)-- let suppressions = try context.fetch(FetchDescriptor<CharacterSuppression>())- #expect(suppressions.count == 1)- let suppression = try #require(suppressions.first)- #expect(suppression.id == Fixture.suppressionID)- #expect(suppression.kind == .candidate)- #expect(suppression.nameKey == Fixture.suppressionNameKey)- #expect(suppression.source == nil)- #expect(suppression.status == .active)- #expect(suppression.actionAt == Fixture.timestamp)- #expect(suppression.work?.id == Fixture.workID)-- #expect(entryA.characterExtractionFingerprint == Fixture.entryACoverage)- #expect(work.genericNotesExtractionFingerprint == Fixture.workNotesCoverage)-- // The stage only adds: the new tables are empty and the new columns nil- // until `V8PopulationPass` runs.- #expect(try context.fetch(FetchDescriptor<WorkSiteMembership>()).isEmpty)- #expect(try context.fetch(FetchDescriptor<WorkDistinctPair>()).isEmpty)- #expect(work.membershipValues.isEmpty)- #expect(entryA.citationsData == nil)- #expect(entryB.citationsData == nil)- #expect(pattern.definitionData == nil)- }-- @Test("The store is left recorded at 8.0.0 once it has been opened")- func openingRecordsTheNewVersion() throws {- let dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try Fixture.install(at: storeURL)- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["7.0.0"])-- do {- let container = try LibraryRepository.openContainer(at: storeURL)- _ = ModelContext(container)- withExtendedLifetime(container) {}- }-- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["8.0.0"],- "ModelContainer.init is what runs the plan's third lightweight stage")- }-- @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 Fixture.install(at: storeURL)-- let container = try LibraryRepository.openContainer(at: storeURL)- let context = ModelContext(container)- defer { withExtendedLifetime((dir, container)) {} }-- let diagnostics = try LibraryRepository.validateStore(context: context)- #expect(diagnostics.quarantineMap().isEmpty,- "the graph was legal when it was written at 7.0.0 and nothing was dropped")- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V8PopulationPassTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V8PopulationPassTests.swiftdeleted file mode 100644index 3537bf9..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V8PopulationPassTests.swift+++ /dev/null@@ -1,428 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import AsterismCore--/// The data pass the `"7"` marker generation owes (Req 2.1, 2.4).-///-/// It runs over `V7RecordedStoreFixture` rather than a hand-built V8 store,-/// because what it converts is a store the V7 classes wrote: a fixture that-/// spelled the legacy columns onto live V8 rows would prove the pass reads-/// *some* columns, not that it reads the ones an installed library holds.-@Suite("V8PopulationPass", .serialized)-struct V8PopulationPassTests {-- private final class TempDir {- let url: URL- init() throws {- url = FileManager.default.temporaryDirectory.appending(- path: "V8Pass-\(UUID())", directoryHint: .isDirectory)- try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)- }- deinit { try? FileManager.default.removeItem(at: url) }- }-- /// A converted 7.0.0 store, open under the V8 schema and not yet passed- /// over. The container is returned so the caller keeps it alive: a- /// `ModelContext` does not retain its container.- private struct Converted {- let dir: TempDir- let container: ModelContainer-- /// - Parameter extraWorks: further V7-shaped Works seeded into the same- /// 7.0.0 store before it is converted, so a chunked run has more than- /// one chunk to fail part-way through.- init(extraWorks: Int = 0) throws {- dir = try TempDir()- let storeURL = dir.url.appending(path: "store.sqlite")- try V7RecordedStoreFixture.install(at: storeURL)- if extraWorks > 0 {- try V7RecordedStoreFixture.write(at: storeURL) { context in- for index in 0..<extraWorks {- let work = AsterismSchemaV7.Work()- // Fixed ids: two stores seeded the same way have to- // produce byte-identical `Facts`, and a random UUID- // would make the comparison below meaningless.- work.id = UUID(uuidString: "44444444-4444-4444-4444-00000000010\(index)")!- work.displayTitle = "Extra \(index)"- work.siteHostname = V7RecordedStoreFixture.hostname- work.urlIdentity = "extra-\(index)"- work.urlIdentityStateRaw =- WorkURLIdentityState.legacyUnverified.rawValue- work.createdAt = V7RecordedStoreFixture.timestamp- .addingTimeInterval(TimeInterval(index + 1))- work.modifiedAt = work.createdAt- context.insert(work)- }- }- }- container = try LibraryRepository.openContainer(at: storeURL)- }-- func context() -> ModelContext { ModelContext(container) }- }-- /// Everything the pass is supposed to have produced, as plain values.- private struct Facts: Equatable {- var memberships: [String]- var citations: [String]- var definitions: [String]-- init(_ context: ModelContext) throws {- memberships = try context.fetch(FetchDescriptor<WorkSiteMembership>())- .map {- [- $0.hostname, String($0.createdAt.timeIntervalSince1970),- $0.urlIdentity ?? "-", $0.urlIdentityState.rawValue,- $0.urlIdentityRuleID?.uuidString ?? "-", $0.workURLString ?? "-",- $0.workID?.uuidString ?? "-", $0.work?.id.uuidString ?? "-",- $0.site?.hostname ?? "-",- ].joined(separator: "|")- }- .sorted()- citations = try context.fetch(FetchDescriptor<Entry>())- .map { entry in- let rows = ((try? entry.citations)?.ruleCitations ?? []).map {- "\($0.id?.uuidString ?? "-"):\($0.version.map(String.init) ?? "-")"- }- return entry.id.uuidString + "=" + rows.joined(separator: ",")- }- .sorted()- // **A replay, not a description.** Req 10.1 is that the *parse* is- // unchanged, and `String(describing:)` over a definition would call- // two rules equal whenever their descriptions happened to match and- // unequal whenever a case gained a field. So each pattern parses the- // fixture's two capture titles and records what it derived: a- // conversion that dropped a trim or swapped the field order shows up- // as a different Work name, which is the thing that would be wrong.- definitions = try context.fetch(FetchDescriptor<TitlePattern>())- .map { pattern in- let stored = try? pattern.storedDefinition- let replays = [- V7RecordedStoreFixture.entryACaptureTitle,- V7RecordedStoreFixture.entryBCaptureTitle,- ].map { title -> String in- guard let stored else { return "unreadable" }- switch TitleRuleApplicator.apply(- definition: stored.definition, trimPrefix: stored.trimPrefix,- trimSuffix: stored.trimSuffix, to: title) {- case .success(let parsed):- return "\(parsed.workName)⇢\(parsed.chapterTitle ?? "-")"- case .failure(let error):- return "failed:\(error)"- }- }- return pattern.id.uuidString + "=" + replays.joined(separator: ";")- }- .sorted()- }- }-- // MARK: - Req 2.1: what one run produces-- @Test("Every Work gets one membership carrying its six columns")- func membershipsCopyTheColumns() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- try V8PopulationPass.run(context: context)-- let memberships = try context.fetch(FetchDescriptor<WorkSiteMembership>())- #expect(memberships.count == 1)- let membership = try #require(memberships.first)- #expect(membership.hostname == V7RecordedStoreFixture.hostname)- #expect(membership.createdAt == V7RecordedStoreFixture.timestamp)- #expect(membership.urlIdentity == V7RecordedStoreFixture.workIdentity)- #expect(membership.urlIdentityState == .rule)- #expect(membership.urlIdentityRuleID == V7RecordedStoreFixture.urlRuleID)- #expect(membership.workURLString == V7RecordedStoreFixture.workURLString)- #expect(membership.workID == V7RecordedStoreFixture.workID)- #expect(membership.work?.id == V7RecordedStoreFixture.workID)- // The Work was pinned to a Site row, so the membership inherits it- // rather than re-resolving.- #expect(membership.site?.hostname == V7RecordedStoreFixture.hostname)- #expect(try #require(context.fetch(FetchDescriptor<Work>()).first)- .membershipValues.count == 1)- }-- /// Req 10.2 and 10.1 over a recorded store: every citation and every parsed- /// definition reads back the same after the pass as before it.- @Test("Citations and definitions are unchanged by the conversion")- func blobsMatchTheColumns() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- let before = try Facts(context)- try V8PopulationPass.run(context: context)- let after = try Facts(context)-- #expect(before.citations == after.citations)- #expect(before.definitions == after.definitions)- #expect(try context.fetch(FetchDescriptor<Entry>()).allSatisfy { $0.citationsData != nil })- #expect(try context.fetch(FetchDescriptor<TitlePattern>())- .allSatisfy { $0.definitionData != nil })- }-- /// The membership pins to the `SiteResolutionOrder` winner when the Work- /// itself was never pinned, and to nothing when no Site row has arrived- /// (Req 1.5) — both tolerated.- @Test("An unpinned Work's membership resolves the winner, or nothing")- func siteResolution() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- let work = try #require(context.fetch(FetchDescriptor<Work>()).first)- work.setLegacySite(nil)- let orphan = Work(- displayTitle: "Elsewhere", siteHostname: "nowhere.example",- timestamp: V7RecordedStoreFixture.timestamp)- context.insert(orphan)- try context.save()-- try V8PopulationPass.run(context: context)-- let byHost = Dictionary(- uniqueKeysWithValues: try context.fetch(FetchDescriptor<WorkSiteMembership>())- .map { ($0.hostname, $0) })- #expect(byHost[V7RecordedStoreFixture.hostname]?.site?.hostname- == V7RecordedStoreFixture.hostname)- #expect(byHost["nowhere.example"]?.site == nil)- }-- /// Q30: a Work with a blank hostname gets no membership, and that is not a- /// failure — `assertComplete` passes over it.- @Test("A blank-hostname Work gets no membership and does not fail completion")- func blankHostname() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- let blank = Work(- displayTitle: "No site", siteHostname: "",- timestamp: V7RecordedStoreFixture.timestamp)- context.insert(blank)- try context.save()-- try V8PopulationPass.run(context: context)-- #expect(try context.fetch(FetchDescriptor<WorkSiteMembership>()).count == 1)- #expect(blank.membershipValues.isEmpty)- try V8PopulationPass.assertComplete(context: context)- }-- // MARK: - Req 2.4: idempotence and resumption-- @Test("A second run changes nothing")- func idempotent() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- try V8PopulationPass.run(context: context)- let first = try Facts(context)- let strategy = InstrumentedSaveStrategy()-- try V8PopulationPass.run(context: context, saveStrategy: strategy)-- #expect(try Facts(context) == first)- #expect(strategy.attemptCount == 0, "a converged library dirties no chunk")- }-- /// Req 2.4: a save that throws part-way leaves the marker's work unfinished,- /// and the next run completes it to exactly the state a single run reaches.- @Test("A run interrupted by a failing save is completed by the next one")- func resumesAfterAFailedSave() throws {- // Two runs over two stores seeded identically: one uninterrupted, one- // interrupted at its first save and re-run.- let reference = try Converted()- let referenceContext = reference.context()- try V8PopulationPass.run(context: referenceContext)- let expected = try Facts(referenceContext)- withExtendedLifetime(reference) {}-- let converted = try Converted()- defer { withExtendedLifetime(converted) {} }- let failing = InstrumentedSaveStrategy()- failing.shouldFail = true- #expect(throws: (any Error).self) {- try V8PopulationPass.run(context: converted.context(), saveStrategy: failing)- }-- // A fresh context, as the next open would build: the interrupted run's- // unsaved inserts are gone.- let resumed = converted.context()- #expect(try resumed.fetch(FetchDescriptor<WorkSiteMembership>()).isEmpty)- #expect(throws: LibraryRepositoryError.self) {- try V8PopulationPass.assertComplete(context: resumed)- }- try V8PopulationPass.run(context: resumed)- #expect(try Facts(resumed) == expected)- try V8PopulationPass.assertComplete(context: resumed)- }-- /// The half the all-or-nothing failure above cannot reach: a run that- /// **commits some chunks and then throws**, leaving a store that is neither- /// unconverted nor converted.- ///- /// That is the state the existence guards exist for, and the only one in- /// which they can be wrong: a second run over a partly populated store can- /// double a membership it already minted or skip one it never did. Four- /// Works at `batchSize: 1` make four chunks; failing after the second stops- /// the pass with two memberships on disk and two still owed.- @Test("A run that commits some chunks and then fails is completed by the next one")- func resumesAfterAPartlyCommittedRun() throws {- let reference = try Converted(extraWorks: 3)- let referenceContext = reference.context()- try V8PopulationPass.run(context: referenceContext, batchSize: 1)- let expected = try Facts(referenceContext)- withExtendedLifetime(reference) {}-- let converted = try Converted(extraWorks: 3)- defer { withExtendedLifetime(converted) {} }- let failing = InstrumentedSaveStrategy()- failing.failAfter = 2- #expect(throws: (any Error).self) {- try V8PopulationPass.run(- context: converted.context(), batchSize: 1, saveStrategy: failing)- }-- // The partly converted store, as the next open would find it: two of the- // four memberships committed, and the pass not complete.- let resumed = converted.context()- #expect(try resumed.fetch(FetchDescriptor<WorkSiteMembership>()).count == 2,- "the premise: some chunks committed and the rest did not")- #expect(throws: LibraryRepositoryError.self) {- try V8PopulationPass.assertComplete(context: resumed)- }-- try V8PopulationPass.run(context: resumed, batchSize: 1)-- #expect(try resumed.fetch(FetchDescriptor<WorkSiteMembership>()).count == 4)- #expect(try Facts(resumed) == expected)- try V8PopulationPass.assertComplete(context: resumed)- }-- /// Decision 4: through phase 1 the columns are the source of truth and the- /// blob is a derived mirror, so the pass re-derives a blob that has fallen- /// behind its columns instead of skipping a row that already holds one.- ///- /// The state is not hypothetical: every citation writer in the repository- /// still writes the columns and none calls `setCitations`, so a re-teach or- /// a re-parse after the migration leaves exactly this row behind.- @Test("A blob that has fallen behind its columns is re-derived, not skipped")- func staleBlobIsRefreshed() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- try V8PopulationPass.run(context: context)- let entry = try #require(- context.fetch(FetchDescriptor<Entry>()).first { $0.id == V7RecordedStoreFixture.entryAID })- #expect(entry.citationsData != nil)- // What a writer that has not moved to the blob yet does: a column, and- // only a column.- entry.chapterSequenceRuleVersion = V7RecordedStoreFixture.urlRuleVersion + 4- try context.save()- #expect(try entry.citations.chapterSequence?.version == V7RecordedStoreFixture.urlRuleVersion,- "the premise: the blob is now stale")-- try V8PopulationPass.run(context: context)-- #expect(try entry.citations.chapterSequence?.version- == V7RecordedStoreFixture.urlRuleVersion + 4)- #expect(try entry.citations == LegacyColumns.citations(from: entry))- }-- /// An undecodable blob is repaired from the columns for the same reason: it- /// is a mirror, and a mirror that will not read is re-derived rather than- /// left for the validator to report for ever.- @Test("An undecodable blob is rewritten from the columns")- func undecodableBlobIsRepaired() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- try V8PopulationPass.run(context: context)- let entry = try #require(context.fetch(FetchDescriptor<Entry>()).first)- entry.citationsData = Data("not json".utf8)- try context.save()-- try V8PopulationPass.run(context: context)-- #expect(try entry.citations == LegacyColumns.citations(from: entry))- }-- // MARK: - Q35: the pass certifies itself-- @Test("assertComplete throws on a Work with a hostname and no membership")- func assertCompleteCatchesAMissingMembership() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- try V8PopulationPass.run(context: context)- for membership in try context.fetch(FetchDescriptor<WorkSiteMembership>()) {- context.delete(membership)- }- try context.save()-- #expect(throws: LibraryRepositoryError.self) {- try V8PopulationPass.assertComplete(context: context)- }- }-- @Test("assertComplete throws on an Entry with no citation blob")- func assertCompleteCatchesAMissingCitationBlob() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- try V8PopulationPass.run(context: context)- try #require(context.fetch(FetchDescriptor<Entry>()).first).citationsData = nil- try context.save()-- #expect(throws: LibraryRepositoryError.self) {- try V8PopulationPass.assertComplete(context: context)- }- }-- @Test("assertComplete throws on a pattern with no definition blob")- func assertCompleteCatchesAMissingDefinitionBlob() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- try V8PopulationPass.run(context: context)- try #require(context.fetch(FetchDescriptor<TitlePattern>()).first).definitionData = nil- try context.save()-- #expect(throws: LibraryRepositoryError.self) {- try V8PopulationPass.assertComplete(context: context)- }- }-- /// A pattern whose legacy columns are not a legal arm has nothing honest to- /// convert, so the pass leaves it and `assertComplete` does not demand it —- /// otherwise one malformed row, which the library opens with today, would- /// make it unopenable on V8.- @Test("A pattern whose columns will not decode is left alone and tolerated")- func unreadablePatternIsTolerated() throws {- let converted = try Converted()- let context = converted.context()- defer { withExtendedLifetime(converted) {} }-- let pattern = try #require(context.fetch(FetchDescriptor<TitlePattern>()).first)- pattern.formRaw = PatternForm.segment.rawValue- pattern.segmentWorkAnchor = nil- try context.save()-- try V8PopulationPass.run(context: context)-- #expect(pattern.definitionData == nil)- try V8PopulationPass.assertComplete(context: context)- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreFixture.swiftnew file mode 100644index 0000000..701e60d--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreFixture.swift@@ -0,0 +1,567 @@+import Foundation+import SwiftData++@testable import AsterismCore++/// A store genuinely **recorded at 8.0.0**, seeded in-process through the frozen+/// `AsterismSchemaV8` snapshot — the library a device that ran the V8 build+/// holds on the morning of the V9 update.+///+/// It succeeds `V5`/`V6`/`V7RecordedStoreFixture`, which went with the stages+/// that named them: the plan is `[V8, V9]`, so V8 is the only convertible input+/// left and anything older fails closed.+///+/// Seeding through the snapshot rather than committing a `.sqlite` is what the+/// nesting buys: a container over `AsterismSchemaV8` records 8.0.0 in the+/// store's own metadata, and the fixture cannot drift out of sync with the+/// snapshot it is built from.+///+/// **It is populated on both sides of every supersession**, which is the whole+/// point of a V9 conversion input. Each Work carries its six site/identity/URL+/// columns *and* the `WorkSiteMembership` the population pass minted from them;+/// each Entry its seventeen citation columns and `identityKeyVersion` *and* the+/// `citationsData` blob; the title rule its ten definition columns *and*+/// `definitionData`. `Work.site` and its `Site.works` inverse are populated too,+/// because dropping an inverse pair is the one relationship change the V8 → V9+/// stage makes. Everything on the left of those pairs is what V9 destroys;+/// everything on the right is what has to survive.+///+/// V7's two tables ride through untouched, which is exactly why a `Character`+/// with a fact, a `CharacterSuppression` and a `WorkDistinctPair` are seeded: a+/// stage that lost a table nobody reads would otherwise be found on the owner's+/// phone.+///+/// **The composite Codable columns are seeded non-nil on both sides of the+/// drop.** Three of them go — `Site.urlIdentityRule`, and `TitlePattern`'s+/// `segmentWorkAnchor` / `segmentIgnoredAnchors` — and one stays,+/// `Site.junkSuffixRule`. A stage removing an *external-storage* composite is+/// not the same operation as removing a scalar, and a fixture that let all four+/// cross as nil would exercise neither the removal nor the survival.+///+/// One row per model, **except `Entry`, which gets three, and `TitlePattern`,+/// which gets two**. The seventeen citation columns cannot all be set on one+/// Entry in a graph the validator accepts: the v2 identity arm forbids a name+/// contributor, the v3 arm forbids a Work extraction, and pattern assignment and+/// URL assignment exclude each other. Entry A carries the v2/URL-rule half,+/// Entry B the v3/pattern half, and between them every column is non-nil.+/// **Entry C carries no citation blob at all** — the row the drop stage reaches+/// before anything converted it, which is this spec's headline risk (Q6, Q25).+/// The second title rule is the retained segment arm, whose two anchor columns+/// are the composites above.+///+/// ## Registry ordering: why seeding through a frozen snapshot is safe here+///+/// SwiftData keeps a **process-global, entity-name-keyed** registry, so a+/// container over this snapshot and a container over the live schema both claim+/// "Site", "Entry", … (`docs/agent-notes/schema-migration.md`; Q29 of this+/// spec's decision log, where a scratch container over the snapshot aborted the+/// whole test process with `NSUnknownKeyException` on `Site.works`).+///+/// What keeps this fixture out of that is **ordering, not luck**: `write(at:)`+/// creates the snapshot container, seeds through it, saves and releases it+/// before returning, and every caller opens the live container only afterwards.+/// The two are therefore never *in use* at the same time, which is the condition+/// that breaks — Q29's call site built its scratch container while a live one+/// was already open. `make test-core` passes `--no-parallel`, which is what+/// extends that guarantee across suites; dropping it would put another suite's+/// live container in use while this one holds the registration, and the package+/// would abort rather than fail a test.+///+/// Belt and braces underneath it: V9's *stored* shape is a strict subset of+/// V8's — the stage only removes — so a live key that a stale V8 registration+/// could not answer does not exist. The dangerous direction is the other one, a+/// V8-shaped object used while V9 holds the registry, and that is exactly what+/// the ordering above forbids.+enum V8RecordedStoreFixture {+ static let hostname = "frozen8.example"+ static let siteDisplayName = "Frozen Eight"+ static let patternID = UUID(uuidString: "22222222-2222-2222-2222-000000000008")!+ static let patternVersion = 5+ /// The Site's second, retained title rule — the segment arm, whose+ /// `segmentWorkAnchor` / `segmentIgnoredAnchors` columns are two of the+ /// three composite Codables V9 drops. Inactive, because Decision 5 of+ /// `unified-teaching-composition` gives a taught Site exactly one active+ /// rule, and on a Site-unique version (`LibraryValidator`'s Site tuple).+ static let segmentPatternID = UUID(uuidString: "22222222-2222-2222-2222-000000000018")!+ static let segmentPatternVersion = 4+ static let urlRuleID = UUID(uuidString: "33333333-3333-3333-3333-000000000008")!+ static let urlRuleVersion = 3+ static let workID = UUID(uuidString: "44444444-4444-4444-4444-000000000008")!+ static let membershipID = UUID(uuidString: "99999999-9999-9999-9999-000000000008")!+ static let entryAID = UUID(uuidString: "55555555-5555-5555-5555-000000000008")!+ static let entryBID = UUID(uuidString: "55555555-5555-5555-5555-000000000018")!+ static let entryCID = UUID(uuidString: "55555555-5555-5555-5555-000000000028")!+ static let distinctPairID = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-000000000008")!+ /// The other half of the seeded `WorkDistinctPair`. No `Work` carries it:+ /// the pair table names Works by application UUID and nothing prunes a row+ /// whose Work has gone, so a dangling id is a state the live library holds+ /// and the conversion must carry across unchanged.+ static let distinctPairOtherWorkID = UUID(uuidString: "44444444-4444-4444-4444-000000000018")!+ static let workTypeID = UUID(uuidString: "66666666-6666-6666-6666-000000000008")!+ static let workTypeName = "Web Serial"+ static let characterID = UUID(uuidString: "77777777-7777-7777-7777-000000000008")!+ static let characterName = "Eight of Frozen"+ static let characterNameKey = "eight of frozen"+ static let characterAliases = ["Eight", "Frozen Eight"]+ static let characterNote = "The one the fixture names."+ static let suppressionID = UUID(uuidString: "88888888-8888-8888-8888-000000000008")!+ static let suppressionNameKey = "the narrator"+ static let workName = "A Frozen Eight"+ static let genericNotes = "generic notes, recorded at 8.0.0"+ static let workURLString = "https://frozen8.example/series/88"+ static let workIdentity = "88"+ /// A pre-feature `typeRaw` with no `workTypeID` beside it: the Q16 case a V8+ /// build presents as untyped (Req 10.3) and V9 drops outright.+ static let legacyTypeRaw = "novel"+ static let timestamp = Date(timeIntervalSince1970: 1_840_000_000)++ static let trimPrefix = "Read: "+ static let trimSuffix = " | Frozen Eight"+ static let phraseSeparator = " — "++ static let entryANote = "Recorded at 8.0.0 ✓"+ static let entryASequence = "11"+ static let entryAChapterTitle = "Chapter 11"+ static let entryACaptureTitle = "Read: Chapter 11 — A Frozen Eight | Frozen Eight"+ static let entryARawURL = "https://frozen8.example/read?series=88&chapter=11"++ static let entryACanonicalURL = "https://frozen8.example/read?chapter=11&series=88"++ static let entryBNote = "Recorded at 8.0.0, name-keyed"+ static let entryBSequence = "12"+ static let entryBChapterTitle = "Chapter 12"+ static let entryBCaptureTitle = "Read: Chapter 12 — A Frozen Eight | Frozen Eight"+ static let entryBRawURL = "https://frozen8.example/read?series=88&chapter=12"++ /// Entry C is the nil-blob row: captured through the share extension after+ /// the last V8 app launch, so nothing ever wrote its `citationsData`. Its+ /// citations are what the drop stage destroys; its note, rating, URL,+ /// identity key and Work link are what has to survive (Q6, Q25). Its stored+ /// citation columns are therefore the conservative, unassigned tuple — the+ /// only one the default `EntryCitations` spells.+ static let entryCNote = "Captured after the last V8 launch"+ static let entryCCaptureTitle = "Read: Chapter 13 — A Frozen Eight | Frozen Eight"+ static let entryCRawURL = "https://frozen8.example/read?series=88&chapter=13"++ /// The fact the seeded character carries, cited from Entry A — so the+ /// `factsData` blob is genuinely populated rather than an empty array.+ static var characterFact: CharacterFact {+ CharacterFact(+ statement: "Eight is the narrator.", quote: "I am Eight.",+ nameKey: characterNameKey, source: .entry(entryAID))+ }++ /// The two coverage fingerprints, which are the *fingerprint of the text they+ /// cover*: a pass that covered this note would have written exactly this, so+ /// the seeded pair is self-consistent (Q81 of `character-extraction`).+ static var entryACoverage: String { CharacterCoverageFingerprint.of(entryANote) }+ static var workNotesCoverage: String { CharacterCoverageFingerprint.of(genericNotes) }++ /// The pattern arm the fixture seeds, as the live V9 type sees it.+ static var patternDefinition: PatternDefinition {+ .phrase(prefix: "", separator: phraseSeparator, suffix: "", order: .chapterThenWork)+ }++ /// The whole definition surface the `definitionData` blob holds — the arm+ /// *and* both trims, which is what the ten columns beside it decompose.+ static var storedPatternDefinition: StoredPatternDefinition {+ StoredPatternDefinition(+ definition: patternDefinition, trimPrefix: trimPrefix, trimSuffix: trimSuffix)+ }++ /// The segment arm's two anchors — `SegmentRangeSpec` and+ /// `[SegmentPositionSpec]`, the composite Codables that sit in+ /// `TitlePattern.segmentWorkAnchor` / `segmentIgnoredAnchors` until V9 drops+ /// them, and inside the surviving `definitionData` blob either side of it.+ static var segmentWorkAnchor: SegmentRangeSpec {+ get throws { try SegmentRangeSpec(origin: .start, offset: 1, length: 2) }+ }++ static var segmentIgnoredAnchors: [SegmentPositionSpec] {+ get throws {+ [try SegmentPositionSpec(origin: .end, offset: 0),+ try SegmentPositionSpec(origin: .start, offset: 0)]+ }+ }++ /// The whole definition surface of the second, retained title rule.+ static var segmentStoredDefinition: StoredPatternDefinition {+ get throws {+ StoredPatternDefinition(+ definition: .segment(+ work: try segmentWorkAnchor, ignored: try segmentIgnoredAnchors),+ trimPrefix: nil, trimSuffix: nil)+ }+ }++ /// The Site's `urlIdentityRule` — a composite Codable **V9 drops**, seeded+ /// non-nil so the stage genuinely removes one.+ static var siteURLIdentityRule: URLIdentityRule {+ get throws {+ try URLIdentityRule(version: 2, component: .queryItem, queryName: "series")+ }+ }++ /// The Site's `junkSuffixRule` — the one composite Codable on `Site` that+ /// **survives** V9, so the suite asserts it field by field on the far side.+ static var siteJunkSuffixRule: JunkSuffixRule {+ get throws {+ try JunkSuffixRule(+ version: 3,+ anchors: [try SegmentPositionSpec(origin: .end, offset: 1),+ try SegmentPositionSpec(origin: .end, offset: 0)])+ }+ }++ /// The URL rule the fixture seeds, as the live V9 type sees it.+ static var urlRuleDefinition: URLRuleDefinition {+ .workAndSequence(+ work: URLFieldSelector(locator: .query(name: ExactScalarString("series"))),+ sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter"))))+ }++ private static var citedURLRule: CitedRule {+ CitedRule(id: urlRuleID, version: urlRuleVersion)+ }++ private static var citedPattern: CitedRule {+ CitedRule(id: patternID, version: patternVersion)+ }++ /// Entry A's blob: the v2 identity arm and the URL-rule work assignment,+ /// which is the same tuple its seventeen columns spell.+ static var entryACitations: EntryCitations {+ EntryCitations(+ identity: .rule(url: citedURLRule, nameTitle: nil),+ urlWork: citedURLRule,+ chapterSequence: citedURLRule,+ chapterTitle: FieldProvenance.tolerant(+ kind: .pattern, patternID: patternID, patternVersion: patternVersion),+ workAssignment: .urlRule(citedURLRule),+ workURL: citedURLRule,+ workURLAssignmentKind: .identity)+ }++ /// Entry B's blob: the v3 identity arm — the only one that carries a name+ /// contributor — and the pattern work assignment.+ static var entryBCitations: EntryCitations {+ EntryCitations(+ identity: .composed(url: citedURLRule, nameTitle: citedPattern),+ chapterSequence: citedURLRule,+ chapterTitle: FieldProvenance.tolerant(+ kind: .pattern, patternID: patternID, patternVersion: patternVersion),+ workAssignment: .pattern(citedPattern))+ }++ /// Opens a container over the frozen V8 snapshot at `storeURL`, hands its+ /// context to `seed`, saves, and releases the container so the file on disk+ /// is a closed store recorded at 8.0.0.+ static func write(at storeURL: URL, seed: (ModelContext) throws -> Void) throws {+ try FileManager.default.createDirectory(+ at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)+ let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let configuration = ModelConfiguration(+ // The same store-configuration name `openContainer` uses; a mismatch+ // here would make the reopen create a second store.+ "AsterismV3", schema: schema, url: storeURL, cloudKitDatabase: .none)+ let container = try ModelContainer(for: schema, configurations: [configuration])+ let context = ModelContext(container)+ try seed(context)+ try context.save()+ withExtendedLifetime(container) {}+ }++ /// The Work name the seeded title rule derives from either capture title —+ /// computed rather than written out, so the v3 identity key the fixture+ /// stores always replays from the rule it cites.+ static func derivedWorkName(from captureTitle: String) throws -> String {+ guard case .success(let parsed) = TitleRuleApplicator.apply(+ definition: patternDefinition, trimPrefix: trimPrefix, trimSuffix: trimSuffix,+ to: captureTitle) else {+ throw ModelInvariantError.invalidCombination(field: "V8 fixture title replay")+ }+ return parsed.workName+ }++ /// Entry A's stored `entryIdentityKey`, the v2 spelling. Exposed rather than+ /// computed inside `install` because `identityKeyVersion` — the column that+ /// used to say which codec wrote a key — is one of the ones V9 drops, so the+ /// suite reads the key back and decodes it.+ static var entryAIdentityKey: String {+ get throws {+ EntryIdentityKeyV2Codec.encode(+ try URLDerivedEntryIdentity(+ hostname: ExactScalarString(hostname),+ workIdentity: ExactScalarString(workIdentity),+ chapterSequence: ExactScalarString(entryASequence)))+ }+ }++ /// Entry B's, the v3 spelling.+ static var entryBIdentityKey: String {+ get throws {+ EntryIdentityKeyV3Codec.encode(+ try URLSequenceNameIdentity(+ hostname: ExactScalarString(hostname),+ workName: ExactScalarString(try derivedWorkName(from: entryBCaptureTitle)),+ chapterSequence: ExactScalarString(entryBSequence)))+ }+ }++ static func install(at storeURL: URL) throws {+ let v2Key = try entryAIdentityKey+ let v3Key = try entryBIdentityKey++ try write(at: storeURL) { context in+ let site = AsterismSchemaV8.Site()+ site.hostname = hostname+ site.displayName = siteDisplayName+ site.modeRaw = SiteMode.taught.rawValue+ // Both of `Site`'s composite Codables, non-nil: `urlIdentityRule`+ // goes at V9 and `junkSuffixRule` stays.+ site.urlIdentityRule = try siteURLIdentityRule+ site.junkSuffixRule = try siteJunkSuffixRule+ context.insert(site)++ // All ten definition columns of the phrase arm, trims included, and+ // the blob that supersedes them.+ let pattern = AsterismSchemaV8.TitlePattern()+ pattern.id = patternID+ pattern.version = patternVersion+ pattern.isActive = true+ pattern.createdAt = timestamp+ pattern.formRaw = PatternForm.phrase.rawValue+ pattern.segmentWorkAnchor = nil+ pattern.segmentIgnoredAnchors = nil+ pattern.phrasePrefix = ""+ pattern.phraseSeparator = phraseSeparator+ pattern.phraseSuffix = ""+ pattern.fieldOrderRaw = FieldOrder.chapterThenWork.rawValue+ pattern.trimPrefix = trimPrefix+ pattern.trimSuffix = trimSuffix+ pattern.chapterless = false+ pattern.definitionData = try JSONEncoder().encode(storedPatternDefinition)+ context.insert(pattern)+ pattern.site = site++ // The retained segment arm, and the only row that fills the two+ // composite anchor columns. Inactive: a taught Site holds exactly+ // one active title rule.+ let segmentPattern = AsterismSchemaV8.TitlePattern()+ segmentPattern.id = segmentPatternID+ segmentPattern.version = segmentPatternVersion+ segmentPattern.isActive = false+ segmentPattern.createdAt = timestamp+ segmentPattern.formRaw = PatternForm.segment.rawValue+ segmentPattern.segmentWorkAnchor = try segmentWorkAnchor+ segmentPattern.segmentIgnoredAnchors = try segmentIgnoredAnchors+ segmentPattern.chapterless = false+ segmentPattern.definitionData = try JSONEncoder().encode(segmentStoredDefinition)+ context.insert(segmentPattern)+ segmentPattern.site = site++ let rule = AsterismSchemaV8.URLRulePattern()+ rule.id = urlRuleID+ rule.version = urlRuleVersion+ rule.isCurrent = true+ rule.createdAt = timestamp+ rule.originRaw = URLRuleOrigin.readerTaught.rawValue+ // Both fields from one rule, so an entry can cite it for its work+ // identity *and* its chapter sequence and still replay equal.+ rule.definitionData = try JSONEncoder().encode(urlRuleDefinition)+ context.insert(rule)+ rule.site = site++ let type = AsterismSchemaV8.WorkTypeEntity()+ type.id = workTypeID+ type.name = workTypeName+ type.stateRaw = WorkTypeState.active.rawValue+ type.createdAt = timestamp+ type.modifiedAt = timestamp+ type.nameModifiedAt = timestamp+ type.stateModifiedAt = timestamp+ context.insert(type)++ // The six site/identity/URL columns V9 drops, the legacy `typeRaw`+ // beside them, and `Work.site` — whose `Site.works` inverse is the+ // relationship half of the drop.+ let work = AsterismSchemaV8.Work()+ work.id = workID+ work.displayTitle = workName+ work.lastParsedTitle = workName+ work.siteHostname = hostname+ work.urlIdentity = workIdentity+ work.urlIdentityStateRaw = WorkURLIdentityState.rule.rawValue+ work.urlIdentityRuleID = urlRuleID+ work.urlIdentityRuleVersion = urlRuleVersion+ work.workURLString = workURLString+ work.typeRaw = legacyTypeRaw+ work.workTypeID = nil+ work.genreTags = ["frozen", "eight"]+ work.genericNotes = genericNotes+ work.titleProvenanceRaw = TitleProvenance.manual.rawValue+ work.createdAt = timestamp+ work.modifiedAt = timestamp+ work.genericNotesExtractionFingerprint = workNotesCoverage+ context.insert(work)+ work.site = site++ // The membership V8's population pass minted from those six columns,+ // and the only home the Work's site presence has after V9. It cites+ // its identity rule by UUID alone (Req 10.4, Q28), which is why no+ // version travels with it.+ let membership = AsterismSchemaV8.WorkSiteMembership()+ membership.id = membershipID+ membership.hostname = hostname+ membership.createdAt = timestamp+ membership.urlIdentity = workIdentity+ membership.urlIdentityStateRaw = WorkURLIdentityState.rule.rawValue+ membership.urlIdentityRuleID = urlRuleID+ membership.workURLString = workURLString+ membership.workID = workID+ context.insert(membership)+ membership.work = work+ membership.site = site++ // Entry A: the v2 identity arm and URL-rule work assignment, in the+ // columns and in the blob.+ let entryA = AsterismSchemaV8.Entry()+ entryA.id = entryAID+ entryA.captureTitle = entryACaptureTitle+ entryA.captureTitleSourceRaw = CaptureTitleSource.host.rawValue+ entryA.rawURLString = entryARawURL+ entryA.canonicalURLString = entryACanonicalURL+ entryA.entryIdentityKey = v2Key+ entryA.conservativeIdentityKey = entryARawURL+ entryA.identityBasisRaw = EntryIdentityBasis.urlRule.rawValue+ entryA.identityKeyVersion = 2+ entryA.identityURLRuleID = urlRuleID+ entryA.identityURLRuleVersion = urlRuleVersion+ entryA.hostname = hostname+ entryA.note = entryANote+ entryA.ratingRaw = Rating.up.rawValue+ entryA.firstCapturedAt = timestamp+ entryA.lastSharedAt = timestamp+ entryA.modifiedAt = timestamp+ entryA.urlWorkIdentity = workIdentity+ entryA.urlWorkRuleID = urlRuleID+ entryA.urlWorkRuleVersion = urlRuleVersion+ entryA.chapterSequence = entryASequence+ entryA.chapterSequenceRuleID = urlRuleID+ entryA.chapterSequenceRuleVersion = urlRuleVersion+ entryA.chapterTitle = entryAChapterTitle+ entryA.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entryA.chapterPatternID = patternID+ entryA.chapterPatternVersion = patternVersion+ entryA.workAssignmentProvenanceRaw = FieldProvenanceKind.urlRule.rawValue+ entryA.workURLRuleID = urlRuleID+ entryA.workURLRuleVersion = urlRuleVersion+ entryA.workURLAssignmentKindRaw = URLWorkAssignmentKind.identity.rawValue+ entryA.characterExtractionFingerprint = entryACoverage+ entryA.citationsData = try JSONEncoder().encode(entryACitations)+ context.insert(entryA)+ entryA.work = work+ entryA.site = site++ // Entry B: the v3 identity arm — which is the only place+ // `identityNameTitleRule*` can be set — and pattern work assignment,+ // which is the only place `workPattern*` can be.+ let entryB = AsterismSchemaV8.Entry()+ entryB.id = entryBID+ entryB.captureTitle = entryBCaptureTitle+ entryB.captureTitleSourceRaw = CaptureTitleSource.host.rawValue+ entryB.rawURLString = entryBRawURL+ entryB.entryIdentityKey = v3Key+ entryB.conservativeIdentityKey = entryBRawURL+ entryB.identityBasisRaw = EntryIdentityBasis.urlRule.rawValue+ entryB.identityKeyVersion = 3+ entryB.identityURLRuleID = urlRuleID+ entryB.identityURLRuleVersion = urlRuleVersion+ entryB.identityNameTitleRuleID = patternID+ entryB.identityNameTitleRuleVersion = patternVersion+ entryB.hostname = hostname+ entryB.note = entryBNote+ entryB.firstCapturedAt = timestamp+ entryB.lastSharedAt = timestamp+ entryB.modifiedAt = timestamp+ entryB.chapterSequence = entryBSequence+ entryB.chapterSequenceRuleID = urlRuleID+ entryB.chapterSequenceRuleVersion = urlRuleVersion+ entryB.chapterTitle = entryBChapterTitle+ entryB.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entryB.chapterPatternID = patternID+ entryB.chapterPatternVersion = patternVersion+ entryB.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entryB.workPatternID = patternID+ entryB.workPatternVersion = patternVersion+ entryB.citationsData = try JSONEncoder().encode(entryBCitations)+ context.insert(entryB)+ entryB.work = work+ entryB.site = site++ // Entry C: **no citation blob**. A V8 extension capture wrote the+ // columns and nothing filled `citationsData` before the drop, so V9+ // reads it as the default `EntryCitations` — conservative identity,+ // nothing cited — which is why its columns spell exactly that. It+ // keeps its Work link: the assignment provenance is what the stage+ // destroys, the relationship is not (Q25).+ let entryC = AsterismSchemaV8.Entry()+ entryC.id = entryCID+ entryC.captureTitle = entryCCaptureTitle+ entryC.captureTitleSourceRaw = CaptureTitleSource.host.rawValue+ entryC.rawURLString = entryCRawURL+ entryC.entryIdentityKey = entryCRawURL+ entryC.conservativeIdentityKey = entryCRawURL+ entryC.identityBasisRaw = EntryIdentityBasis.conservative.rawValue+ entryC.identityKeyVersion = 1+ entryC.hostname = hostname+ entryC.note = entryCNote+ entryC.ratingRaw = Rating.down.rawValue+ entryC.firstCapturedAt = timestamp+ entryC.lastSharedAt = timestamp+ entryC.modifiedAt = timestamp+ entryC.chapterTitleProvenanceRaw = FieldProvenanceKind.none.rawValue+ entryC.workAssignmentProvenanceRaw = FieldProvenanceKind.none.rawValue+ entryC.citationsData = nil+ context.insert(entryC)+ entryC.work = work+ entryC.site = site++ // V8's other new table. Nothing in the conversion reads it, which is+ // the reason to assert it: a removing stage that lost a table would+ // otherwise surface as duplicate Works the reader has already told+ // the app are distinct.+ let pairIDs = WorkDistinctPair.sortedIDs(workID, distinctPairOtherWorkID)+ let pair = AsterismSchemaV8.WorkDistinctPair()+ pair.id = distinctPairID+ pair.lowerWorkID = pairIDs.lower+ pair.higherWorkID = pairIDs.higher+ pair.recordedAt = timestamp+ context.insert(pair)++ let character = AsterismSchemaV8.Character()+ character.id = characterID+ character.name = characterName+ character.nameKey = characterNameKey+ character.aliases = characterAliases+ character.note = characterNote+ character.factsData = CharacterFactCodec.encode([characterFact])+ character.createdAt = timestamp+ character.modifiedAt = timestamp+ context.insert(character)+ character.work = work++ let suppression = AsterismSchemaV8.CharacterSuppression()+ suppression.id = suppressionID+ suppression.kindRaw = CharacterSuppressionKind.candidate.rawValue+ suppression.nameKey = suppressionNameKey+ suppression.sourceKindRaw = nil+ suppression.sourceEntryID = nil+ suppression.evidence = nil+ suppression.statusRaw = CharacterSuppressionStatus.active.rawValue+ suppression.actionAt = timestamp+ context.insert(suppression)+ suppression.work = work+ }+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreTests.swiftnew file mode 100644index 0000000..ec5adcb--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreTests.swift@@ -0,0 +1,552 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// The V8 → V9 conversion, over a store genuinely **recorded at 8.0.0**.+///+/// This is the path every installed library takes on the update that ships+/// `drop-superseded-columns`: the store on disk was written by the V8 classes,+/// and `ModelContainer.init` runs the plan's one lightweight stage on the way+/// in. Every other store a test builds is born at 9.0.0, so a regression here+/// would otherwise only be visible on the owner's phone (Req 2.1).+///+/// **This stage removes**, which is what makes the suite different from the+/// V5/V6/V7 ones it replaces. Those pinned that every superseded column+/// *survived*, because `V8PopulationPass` was about to read them. There is+/// nothing to read now: the drop happens inside the container open, and what has+/// to survive it is everything on the *other* side of each supersession — the+/// memberships, the two blobs, and every reader-authored field beside them. So+/// the assertions here are the whole live library, field by field, before and+/// after.+///+/// The fixture is populated on both sides deliberately (`V8RecordedStoreFixture`+/// says so at length): a store where only the superseded half was filled would+/// pass a conversion that lost the surviving half. One Entry crosses with **no**+/// citation blob at all, which is the spec's headline risk seeded rather than+/// argued about.+@Suite("An 8.0.0-recorded store under the V9 plan", .serialized)+struct V8RecordedStoreTests {++ private typealias Fixture = V8RecordedStoreFixture++ /// A library exactly as a V8 build leaves it: the store recorded at 8.0.0,+ /// its memberships minted and its blobs filled, and the marker at `"8"`.+ private final class Root {+ let url: URL+ let configuration: LibraryConfiguration++ init() throws {+ url = FileManager.default.temporaryDirectory.appending(+ path: "V8Recorded-\(UUID())", directoryHint: .isDirectory)+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+ configuration = LibraryConfiguration(rootDirectory: url)+ try Fixture.install(at: configuration.storeURL)+ try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+ }++ deinit { try? FileManager.default.removeItem(at: url) }++ func markerText() throws -> String {+ try String(contentsOf: configuration.readinessMarkerURL, encoding: .utf8)+ .trimmingCharacters(in: .whitespacesAndNewlines)+ }++ func recordedVersions() throws -> [String] {+ try V4RecordedStoreFixture.recordedModelVersions(at: configuration.storeURL)+ }+ }++ @Test("The seeded store really is recorded at 8.0.0, on the lagging marker")+ func seedIsRecordedAtEightZeroZero() throws {+ let root = try Root()+ #expect(try root.recordedVersions() == ["8.0.0"])+ #expect(try root.markerText() == "8")+ #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)+ == .markerLagging(generation: "8"))+ withExtendedLifetime(root) {}+ }++ /// The whole of it: `openForApp` runs the stage, validates, publishes `"9"`,+ /// and every live row is still there afterwards with its value.+ @Test("openForApp converts to 9.0.0 with every live row and blob intact")+ func convertsWithEveryLiveRowIntact() async throws {+ let root = try Root()+ let (result, repository) = try await LibraryRepository.openForApp(root.configuration)+ defer { withExtendedLifetime(root) {} }++ guard case .ready(let counts) = result else {+ Issue.record("expected a ready library, got \(result)")+ await repository.shutdown()+ return+ }+ // The stage completed and the marker moved, in that order (Q9).+ #expect(try root.recordedVersions() == ["9.0.0"])+ #expect(try root.markerText() == "9")+ #expect(counts.works == 1)+ #expect(counts.entries == 3)+ #expect(counts.sites == 1)+ #expect(counts.titlePatterns == 2)+ #expect(counts.urlRulePatterns == 1)++ let facts = try await repository.withLockedContext(+ mode: .shared, operation: "reading the converted library"+ ) { context in try ConvertedLibrary(context: context) }+ await repository.shutdown()++ // MARK: the Site+ #expect(facts.siteHostnames == [Fixture.hostname])+ #expect(facts.siteDisplayName == Fixture.siteDisplayName)+ #expect(facts.siteMode == .taught)++ // The retained composite Codable, field by field. `Site.urlIdentityRule`+ // was the other one and is gone; asserting the survivor is what says the+ // stage removed one composite attribute and not the pair.+ let junk = try #require(facts.siteJunkSuffixRule)+ #expect(junk == (try Fixture.siteJunkSuffixRule))+ #expect(junk.version == 3)+ #expect(junk.anchors.map(\.origin) == [.end, .end])+ #expect(junk.anchors.map(\.offset) == [1, 0])++ // MARK: both title rules — the blob, which is now their only home+ #expect(facts.patterns.count == 2)+ let phrasePattern = try #require(facts.patterns[Fixture.patternID])+ #expect(phrasePattern.storedDefinition == Fixture.storedPatternDefinition)+ #expect(phrasePattern.version == Fixture.patternVersion)+ #expect(phrasePattern.isActive)+ #expect(phrasePattern.siteHostname == Fixture.hostname)++ // The segment arm: its two anchor columns went with the stage, and the+ // whole arm has to come back out of `definitionData`.+ let segmentPattern = try #require(facts.patterns[Fixture.segmentPatternID])+ #expect(segmentPattern.storedDefinition == (try Fixture.segmentStoredDefinition))+ #expect(segmentPattern.storedDefinition.form == .segment)+ #expect(+ segmentPattern.storedDefinition.definition+ == .segment(+ work: try Fixture.segmentWorkAnchor,+ ignored: try Fixture.segmentIgnoredAnchors))+ #expect(segmentPattern.version == Fixture.segmentPatternVersion)+ #expect(!segmentPattern.isActive)+ #expect(segmentPattern.siteHostname == Fixture.hostname)++ // MARK: the URL rule+ #expect(facts.urlRuleID == Fixture.urlRuleID)+ #expect(facts.urlRuleVersion == Fixture.urlRuleVersion)+ #expect(facts.urlRuleIsCurrent)+ #expect(facts.urlRuleDefinition == Fixture.urlRuleDefinition)+ #expect(facts.urlRuleSiteHostname == Fixture.hostname)++ // MARK: the Work — everything the six dropped columns did *not* carry+ #expect(facts.workID == Fixture.workID)+ #expect(facts.workDisplayTitle == Fixture.workName)+ #expect(facts.workLastParsedTitle == Fixture.workName)+ #expect(facts.workGenericNotes == Fixture.genericNotes)+ #expect(facts.workGenreTags == ["frozen", "eight"])+ #expect(facts.workTitleProvenance == .manual)+ #expect(facts.workCreatedAt == Fixture.timestamp)+ #expect(facts.workNotesFingerprint == Fixture.workNotesCoverage)+ // The pre-feature type column went with the schema, and a Work carrying+ // one always read as untyped (Req 10.3).+ #expect(facts.workTypeID == nil)++ // MARK: the membership — the only home a Work's site presence has+ #expect(facts.memberships.count == 1)+ let membership = try #require(facts.memberships.first)+ #expect(membership.id == Fixture.membershipID)+ #expect(membership.hostname == Fixture.hostname)+ #expect(membership.createdAt == Fixture.timestamp)+ #expect(membership.urlIdentity == Fixture.workIdentity)+ #expect(membership.urlIdentityState == .rule)+ #expect(membership.urlIdentityRuleID == Fixture.urlRuleID)+ #expect(membership.workURLString == Fixture.workURLString)+ #expect(membership.hasWork)+ #expect(membership.siteHostname == Fixture.hostname)++ // MARK: the Entries — every reader-facing field, and the citation blob+ // `entryIdentityKey` is the field the whole identity story hangs on and+ // `identityKeyVersion` was dropped from beside it, so the key is read+ // back rather than assumed: the v2 codec has to still decode it.+ let entryA = try #require(facts.entries[Fixture.entryAID])+ #expect(entryA.captureTitle == Fixture.entryACaptureTitle)+ #expect(entryA.captureTitleSource == .host)+ #expect(entryA.rawURLString == Fixture.entryARawURL)+ #expect(entryA.canonicalURLString == Fixture.entryACanonicalURL)+ #expect(entryA.entryIdentityKey == (try Fixture.entryAIdentityKey))+ #expect(try EntryIdentityKeyV2Codec.decode(entryA.entryIdentityKey).chapterSequence+ == ExactScalarString(Fixture.entryASequence))+ #expect(entryA.conservativeIdentityKey == Fixture.entryARawURL)+ #expect(entryA.identityBasis == .urlRule)+ #expect(entryA.urlWorkIdentity == Fixture.workIdentity)+ #expect(entryA.chapterSequence == Fixture.entryASequence)+ #expect(entryA.chapterTitle == Fixture.entryAChapterTitle)+ #expect(entryA.note == Fixture.entryANote)+ #expect(entryA.rating == .up)+ #expect(entryA.firstCapturedAt == Fixture.timestamp)+ #expect(entryA.lastSharedAt == Fixture.timestamp)+ #expect(entryA.modifiedAt == Fixture.timestamp)+ #expect(!entryA.intentionallyUnattached)+ #expect(entryA.workID == Fixture.workID)+ #expect(entryA.siteHostname == Fixture.hostname)+ #expect(entryA.coverageFingerprint == Fixture.entryACoverage)+ #expect(entryA.citations == Fixture.entryACitations)++ let entryB = try #require(facts.entries[Fixture.entryBID])+ #expect(entryB.captureTitle == Fixture.entryBCaptureTitle)+ #expect(entryB.rawURLString == Fixture.entryBRawURL)+ #expect(entryB.canonicalURLString == nil)+ #expect(entryB.entryIdentityKey == (try Fixture.entryBIdentityKey))+ #expect(try EntryIdentityKeyV3Codec.decode(entryB.entryIdentityKey).workName+ == ExactScalarString(try Fixture.derivedWorkName(from: Fixture.entryBCaptureTitle)))+ #expect(entryB.chapterSequence == Fixture.entryBSequence)+ #expect(entryB.chapterTitle == Fixture.entryBChapterTitle)+ #expect(entryB.note == Fixture.entryBNote)+ #expect(entryB.rating == nil)+ #expect(entryB.lastSharedAt == Fixture.timestamp)+ #expect(entryB.modifiedAt == Fixture.timestamp)+ #expect(!entryB.intentionallyUnattached)+ #expect(entryB.workID == Fixture.workID)+ #expect(entryB.citations == Fixture.entryBCitations)++ // MARK: the nil-blob Entry — this spec's headline risk, measured+ //+ // Entry C crossed the stage with no `citationsData`, so the columns its+ // citations lived in were dropped with nothing holding a copy. What the+ // risk statement promises is that *only* the provenance goes: the note,+ // the rating, both URLs, the identity key and the Work link are all+ // still here, the row reads as the default rather than throwing (Q6),+ // and the reader is told by a counter rather than by a quarantine (Q25).+ let entryC = try #require(facts.entries[Fixture.entryCID])+ #expect(!entryC.hasCitationBlob)+ #expect(entryC.citations == EntryCitations())+ #expect(entryC.citations.identity == .rawURL)+ #expect(entryC.note == Fixture.entryCNote)+ #expect(entryC.rating == .down)+ #expect(entryC.rawURLString == Fixture.entryCRawURL)+ #expect(entryC.entryIdentityKey == Fixture.entryCRawURL)+ #expect(entryC.conservativeIdentityKey == Fixture.entryCRawURL)+ #expect(entryC.identityBasis == .conservative)+ #expect(entryC.workID == Fixture.workID)+ #expect(entryC.siteHostname == Fixture.hostname)+ #expect(entryC.lastSharedAt == Fixture.timestamp)+ #expect(entryC.modifiedAt == Fixture.timestamp)+ #expect(!entryC.intentionallyUnattached)++ // Exactly one row lost its provenance, and it is the one that arrived+ // without a blob — nothing else was zeroed on the way through.+ #expect(facts.entriesWithoutCitationBlobCount == 1)++ // MARK: the distinct-pair row, which nothing in the stage reads+ #expect(facts.distinctPairs.count == 1)+ let pair = try #require(facts.distinctPairs.first)+ let pairIDs = WorkDistinctPair.sortedIDs(Fixture.workID, Fixture.distinctPairOtherWorkID)+ #expect(pair.id == Fixture.distinctPairID)+ #expect(pair.lowerWorkID == pairIDs.lower)+ #expect(pair.higherWorkID == pairIDs.higher)+ #expect(pair.recordedAt == Fixture.timestamp)++ // MARK: the work-type row, the Character and the suppression, none of+ // which the stage touches — which is exactly why they are asserted. A+ // table silently lost by a removing stage would surface on the owner's+ // phone as missing characters.+ // `openForApp` seeds the default types on every open (Q25 of+ // `configurable-work-types`), so the seeded row is the one *extra* name.+ #expect(facts.workTypeNames.contains(Fixture.workTypeName))+ #expect(+ facts.workTypeNames.count == WorkTypeSeeding.seeds.count + 1,+ "the fixture's own type row survived beside the seeded defaults")+ #expect(facts.characterID == Fixture.characterID)+ #expect(facts.characterName == Fixture.characterName)+ #expect(facts.characterNameKey == Fixture.characterNameKey)+ #expect(facts.characterAliases == Fixture.characterAliases)+ #expect(facts.characterNote == Fixture.characterNote)+ #expect(facts.characterFacts == [Fixture.characterFact])+ #expect(facts.characterWorkID == Fixture.workID)+ #expect(facts.suppressionID == Fixture.suppressionID)+ #expect(facts.suppressionKind == .candidate)+ #expect(facts.suppressionNameKey == Fixture.suppressionNameKey)+ #expect(facts.suppressionStatus == .active)+ #expect(facts.suppressionActionAt == Fixture.timestamp)+ #expect(facts.suppressionWorkID == Fixture.workID)+ }++ /// The converted graph is one the validator accepts, with no hostname+ /// quarantined. The V7 suite had to run the conversion pass first to get+ /// here; V9's stage leaves a library that is already legal.+ ///+ /// Entry C is why this matters rather than being a formality: it holds a+ /// Work with no citation saying how, which for a row whose blob *decoded* to+ /// `.none` is a partial write the validator throws on. A nil blob is the+ /// exception (Q25) — the alternative is that the first V9 open on a device+ /// carrying one extension capture quarantines that hostname and withdraws+ /// the re-assignment that would repair it.+ @Test("The converted library validates with nothing quarantined")+ func convertedLibraryValidates() async throws {+ let root = try Root()+ let (_, repository) = try await LibraryRepository.openForApp(root.configuration)+ let quarantined = await repository.diagnostics.quarantineMap()+ let tuples = await repository.diagnostics.tupleDiagnoses+ await repository.shutdown()++ #expect(quarantined.isEmpty)+ #expect(tuples.isEmpty)+ withExtendedLifetime(root) {}+ }++ /// The extension is what the marker keeps out of a destructive stage (Q3):+ /// it refuses `"8"`, and opens the same library once the app has moved it.+ @Test("The extension refuses the store until the app has converted it")+ func extensionOpensOnlyAfterTheApp() async throws {+ let root = try Root()++ await #expect(throws: LibraryRepositoryError.libraryUnavailable(+ operation: "opening library from extension",+ reason: "Open Asterism to finish updating the library")) {+ try await LibraryRepository.openForExtension(root.configuration)+ }+ #expect(try root.recordedVersions() == ["8.0.0"],+ "the refusal has to land before ModelContainer.init drops a column")++ let (_, app) = try await LibraryRepository.openForApp(root.configuration)+ await app.shutdown()++ let (result, extensionRepository) = try await LibraryRepository.openForExtension(+ root.configuration)+ await extensionRepository.shutdown()+ guard case .ready(let counts) = result else {+ Issue.record("expected the extension to open a certified library, got \(result)")+ return+ }+ #expect(counts.entries == 3)+ withExtendedLifetime(root) {}+ }++ /// A second open takes the `.ready` arm: the generation moved once.+ @Test("The second open is an ordinary ready open")+ func secondOpenIsReady() async throws {+ let root = try Root()+ let (_, first) = try await LibraryRepository.openForApp(root.configuration)+ await first.shutdown()++ #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)+ let (_, second) = try await LibraryRepository.openForApp(root.configuration)+ await second.shutdown()+ #expect(try root.markerText() == "9")+ #expect(try root.recordedVersions() == ["9.0.0"])+ withExtendedLifetime(root) {}+ }+}++// MARK: - The converted library, as `Sendable` values++/// Everything the converted store holds, read once inside the locked context.+/// Model classes are not `Sendable` and may not leave the actor, so the whole+/// comparison is done against copies.+private struct ConvertedLibrary: Sendable {+ struct EntryFacts: Sendable {+ let captureTitle: String+ let captureTitleSource: CaptureTitleSource+ let rawURLString: String+ let canonicalURLString: String?+ let entryIdentityKey: String+ let conservativeIdentityKey: String+ let identityBasis: EntryIdentityBasis+ let urlWorkIdentity: String?+ let chapterSequence: String?+ let chapterTitle: String?+ let note: String+ let rating: Rating?+ let firstCapturedAt: Date+ let lastSharedAt: Date+ let modifiedAt: Date+ let intentionallyUnattached: Bool+ let workID: UUID?+ let siteHostname: String?+ let coverageFingerprint: String?+ let hasCitationBlob: Bool+ let citations: EntryCitations+ }++ struct PatternFacts: Sendable {+ let version: Int+ let isActive: Bool+ let siteHostname: String?+ let storedDefinition: StoredPatternDefinition+ }++ struct DistinctPairFacts: Sendable, Equatable {+ let id: UUID+ let lowerWorkID: UUID+ let higherWorkID: UUID+ let recordedAt: Date+ }++ struct MembershipFacts: Sendable {+ let id: UUID+ let hostname: String+ let createdAt: Date+ let urlIdentity: String?+ let urlIdentityState: WorkURLIdentityState+ let urlIdentityRuleID: UUID?+ let workURLString: String?+ let hasWork: Bool+ let siteHostname: String?+ }++ let siteHostnames: [String]+ let siteDisplayName: String+ let siteMode: SiteMode+ /// The one composite Codable on `Site` V9 keeps. `urlIdentityRule` went with+ /// the stage and has no accessor left to read.+ let siteJunkSuffixRule: JunkSuffixRule?++ let patterns: [UUID: PatternFacts]++ let urlRuleID: UUID+ let urlRuleVersion: Int+ let urlRuleIsCurrent: Bool+ let urlRuleSiteHostname: String?+ let urlRuleDefinition: URLRuleDefinition++ let workID: UUID+ let workDisplayTitle: String+ let workLastParsedTitle: String?+ let workGenericNotes: String+ let workGenreTags: [String]+ let workTitleProvenance: TitleProvenance+ let workCreatedAt: Date+ let workNotesFingerprint: String?+ let workTypeID: UUID?++ let memberships: [MembershipFacts]+ let entries: [UUID: EntryFacts]+ /// The diagnosis counter itself, not a re-count: this is the number the+ /// reader is shown for a row that lost its provenance at the stage (Q25).+ let entriesWithoutCitationBlobCount: Int+ let distinctPairs: [DistinctPairFacts]+ let workTypeNames: [String]++ let characterID: UUID+ let characterName: String+ let characterNameKey: String+ let characterAliases: [String]+ let characterNote: String+ let characterFacts: [CharacterFact]+ let characterWorkID: UUID?++ let suppressionID: UUID+ let suppressionKind: CharacterSuppressionKind+ let suppressionNameKey: String+ let suppressionStatus: CharacterSuppressionStatus+ let suppressionActionAt: Date+ let suppressionWorkID: UUID?++ init(context: ModelContext) throws {+ let sites = try context.fetch(FetchDescriptor<Site>())+ let site = try #require(sites.first)+ siteHostnames = sites.map(\.hostname).sorted()+ siteDisplayName = site.displayName+ siteMode = site.mode+ siteJunkSuffixRule = site.junkSuffixRule++ patterns = Dictionary(+ uniqueKeysWithValues: try context.fetch(FetchDescriptor<TitlePattern>()).map {+ (+ $0.id,+ PatternFacts(+ version: $0.version, isActive: $0.isActive,+ siteHostname: $0.site?.hostname,+ storedDefinition: try $0.storedDefinition)+ )+ })++ let rule = try #require(try context.fetch(FetchDescriptor<URLRulePattern>()).first)+ urlRuleID = rule.id+ urlRuleVersion = rule.version+ urlRuleIsCurrent = rule.isCurrent+ urlRuleSiteHostname = rule.site?.hostname+ urlRuleDefinition = try rule.definition++ let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)+ workID = work.id+ workDisplayTitle = work.displayTitle+ workLastParsedTitle = work.lastParsedTitle+ workGenericNotes = work.genericNotes+ workGenreTags = work.genreTags+ workTitleProvenance = work.titleProvenance+ workCreatedAt = work.createdAt+ workNotesFingerprint = work.genericNotesExtractionFingerprint+ workTypeID = work.workTypeID++ memberships = try context.fetch(FetchDescriptor<WorkSiteMembership>())+ .sorted { $0.id.uuidString < $1.id.uuidString }+ .map {+ MembershipFacts(+ id: $0.id, hostname: $0.hostname, createdAt: $0.createdAt,+ urlIdentity: $0.urlIdentity, urlIdentityState: $0.urlIdentityState,+ urlIdentityRuleID: $0.urlIdentityRuleID, workURLString: $0.workURLString,+ hasWork: $0.work != nil, siteHostname: $0.site?.hostname)+ }++ let entryRows = try context.fetch(FetchDescriptor<Entry>())+ entriesWithoutCitationBlobCount =+ try LibraryToleranceScan.scan(context: context).entriesWithoutCitationBlobCount+ entries = try Dictionary(+ uniqueKeysWithValues: entryRows.map { entry in+ (+ entry.id,+ EntryFacts(+ captureTitle: entry.captureTitle,+ captureTitleSource: entry.captureTitleSource,+ rawURLString: entry.rawURLString,+ canonicalURLString: entry.canonicalURLString,+ entryIdentityKey: entry.entryIdentityKey,+ conservativeIdentityKey: entry.conservativeIdentityKey,+ identityBasis: entry.identityBasis,+ urlWorkIdentity: entry.urlWorkIdentity,+ chapterSequence: entry.chapterSequence,+ chapterTitle: entry.chapterTitle,+ note: entry.note,+ rating: entry.rating,+ firstCapturedAt: entry.firstCapturedAt,+ lastSharedAt: entry.lastSharedAt,+ modifiedAt: entry.modifiedAt,+ intentionallyUnattached: entry.intentionallyUnattached,+ workID: entry.work?.id,+ siteHostname: entry.site?.hostname,+ coverageFingerprint: entry.characterExtractionFingerprint,+ hasCitationBlob: entry.citationsData != nil,+ citations: try entry.citations)+ )+ })++ distinctPairs = try context.fetch(FetchDescriptor<WorkDistinctPair>())+ .sorted { $0.id.uuidString < $1.id.uuidString }+ .map {+ DistinctPairFacts(+ id: $0.id, lowerWorkID: $0.lowerWorkID, higherWorkID: $0.higherWorkID,+ recordedAt: $0.recordedAt)+ }++ workTypeNames = try context.fetch(FetchDescriptor<WorkTypeEntity>()).map(\.name).sorted()++ let character = try #require(try context.fetch(FetchDescriptor<CharacterRecord>()).first)+ characterID = character.id+ characterName = character.name+ characterNameKey = character.nameKey+ characterAliases = character.aliases+ characterNote = character.note+ characterFacts = character.facts+ characterWorkID = character.work?.id++ let suppression = try #require(+ try context.fetch(FetchDescriptor<CharacterSuppression>()).first)+ suppressionID = suppression.id+ suppressionKind = suppression.kind+ suppressionNameKey = suppression.nameKey+ suppressionStatus = suppression.status+ suppressionActionAt = suppression.actionAt+ suppressionWorkID = suppression.work?.id+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ValidatorFixtures.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ValidatorFixtures.swiftindex 39b49bf..32797a5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ValidatorFixtures.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ValidatorFixtures.swift@@ -76,13 +76,12 @@ enum ValidatorFixtures { site.urlRules = [rule] let rawURL = "https://\(hostname)/read?series=42&chapter=7"- let work = Work(displayTitle: "A Work", siteHostname: hostname, timestamp: timestamp)- work.setLegacyColumns {+ let work = Work(displayTitle: "A Work", timestamp: timestamp)+ work.primaryMembershipEdit { $0.urlIdentity = "42" $0.urlIdentityState = .rule $0.urlIdentityRuleID = rule.id }- work.urlIdentityRuleVersion = rule.version let identity = try URLDerivedEntryIdentity( hostname: ExactScalarString(hostname),@@ -93,28 +92,25 @@ enum ValidatorFixtures { rawURLString: rawURL, hostname: hostname, entryIdentityKey: EntryIdentityKeyV2Codec.encode(identity), timestamp: timestamp, work: work)- entry.identityKeyVersion = 2 entry.identityBasis = .urlRule entry.conservativeIdentityKey = rawURL entry.urlWorkIdentity = "42"- entry.urlWorkRuleID = rule.id- entry.urlWorkRuleVersion = rule.version entry.chapterSequence = "7"- entry.chapterSequenceRuleID = rule.id- entry.chapterSequenceRuleVersion = rule.version- entry.identityURLRuleID = rule.id- entry.identityURLRuleVersion = rule.version entry.chapterTitle = "Chapter 7"- entry.chapterTitleProvenance = .pattern- entry.chapterPatternID = titlePattern.id- entry.chapterPatternVersion = titlePattern.version- entry.workAssignmentProvenance = .urlRule- entry.workURLAssignmentKind = .identity- entry.workURLRuleID = rule.id- entry.workURLRuleVersion = rule.version+ let citedRule = CitedRule(id: rule.id, version: rule.version)+ try entry.setCitations(+ EntryCitations(+ identity: .rule(url: citedRule, nameTitle: nil),+ urlWork: citedRule,+ chapterSequence: citedRule,+ chapterTitle: FieldProvenance.tolerant(+ kind: .pattern, patternID: titlePattern.id,+ patternVersion: titlePattern.version),+ workAssignment: .urlRule(citedRule),+ workURL: citedRule,+ workURLAssignmentKind: .identity)) work.entries = [entry] entry.site = site- work.setLegacySite(site) let membership = membership( for: work, hostname: hostname, site: site, timestamp: timestamp) membership.urlIdentity = "42"@@ -142,7 +138,7 @@ enum ValidatorFixtures { let rawURL = "https://\(hostname)/read?chapter=7" let name = "Solo Story Title"- let work = Work(displayTitle: name, siteHostname: hostname, timestamp: timestamp)+ let work = Work(displayTitle: name, timestamp: timestamp) let identity = try URLSequenceNameIdentity( hostname: ExactScalarString(hostname),@@ -152,22 +148,18 @@ enum ValidatorFixtures { captureTitle: name, captureTitleSource: .host, rawURLString: rawURL, hostname: hostname, entryIdentityKey: EntryIdentityKeyV3Codec.encode(identity), timestamp: timestamp, work: work)- entry.identityKeyVersion = 3 entry.identityBasis = .urlRule entry.conservativeIdentityKey = rawURL entry.chapterSequence = "7"- entry.chapterSequenceRuleID = rule.id- entry.chapterSequenceRuleVersion = rule.version- entry.identityURLRuleID = rule.id- entry.identityURLRuleVersion = rule.version- entry.identityNameTitleRuleID = titlePattern.id- entry.identityNameTitleRuleVersion = titlePattern.version- entry.workAssignmentProvenance = .pattern- entry.workPatternID = titlePattern.id- entry.workPatternVersion = titlePattern.version+ let citedRule = CitedRule(id: rule.id, version: rule.version)+ let citedPattern = CitedRule(id: titlePattern.id, version: titlePattern.version)+ try entry.setCitations(+ EntryCitations(+ identity: .composed(url: citedRule, nameTitle: citedPattern),+ chapterSequence: citedRule,+ workAssignment: .pattern(citedPattern))) work.entries = [entry] entry.site = site- work.setLegacySite(site) let membership = membership( for: work, hostname: hostname, site: site, timestamp: timestamp) @@ -187,19 +179,18 @@ enum ValidatorFixtures { let rawURL = "https://\(hostname)/read" let name = "Solo Story Title"- let work = Work(displayTitle: name, siteHostname: hostname, timestamp: timestamp)+ let work = Work(displayTitle: name, timestamp: timestamp) let entry = Entry( captureTitle: name, captureTitleSource: .host, rawURLString: rawURL, hostname: hostname, entryIdentityKey: rawURL, timestamp: timestamp, work: work)- entry.identityKeyVersion = 1 entry.identityBasis = .conservative entry.conservativeIdentityKey = rawURL- entry.workAssignmentProvenance = .pattern- entry.workPatternID = titlePattern.id- entry.workPatternVersion = titlePattern.version+ entry.editCitations {+ $0.workAssignment = .pattern(+ CitedRule(id: titlePattern.id, version: titlePattern.version))+ } work.entries = [entry] entry.site = site- work.setLegacySite(site) let membership = membership( for: work, hostname: hostname, site: site, timestamp: timestamp)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDeletionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDeletionTests.swiftindex 762c7c3..35839fb 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDeletionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDeletionTests.swift@@ -44,7 +44,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("Detach writes exactly moveEntry's leave-unattached fields (7.3, 7.4, Q22)")@@ -93,7 +92,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } /// Regression, found by `WorkDetailActionsUITests` on the composed fixture:@@ -137,14 +135,9 @@ struct WorkDeletionTests { #expect(entry.work == nil) #expect(entry.intentionallyUnattached) #expect(entry.workAssignmentProvenance == .manual)- #expect(entry.workURLRuleID == nil)- #expect(entry.workURLRuleVersion == nil)- #expect(entry.workURLAssignmentKindRaw == nil)+ #expect(entry.citationValues.workURL == nil)+ #expect(entry.workURLAssignmentKind == nil) }-- // Q59's standing guard: the superseded columns still mirror the primary- // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("A detached entry stays unattached through a later re-parse (7.3, §2.6)")@@ -192,7 +185,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } // MARK: - Refusals@@ -222,7 +214,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("A torn entry group gates both dispositions on a fresh disclosure (7.2, Req 2.9)")@@ -276,7 +267,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the // primary membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } } @@ -315,7 +305,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("A work already gone commits: a deletion that finds nothing has done its job")@@ -338,7 +327,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } // MARK: - Duplicate-set neighbours (7.6)@@ -395,7 +383,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("Deleting one member of a duplicate set re-derives the workload without it (7.6)")@@ -432,7 +419,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } // MARK: - Validation@@ -459,7 +445,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("A work on a hostname carrying a recorded diagnosis still deletes (Q34)")@@ -488,7 +473,6 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } @Test("A deletion that removes the offending record clears the hostname's quarantine")@@ -518,6 +502,5 @@ struct WorkDeletionTests { // Q59's standing guard: the superseded columns still mirror the primary // membership after every write this test made.- try await fixture.repository.assertWorkColumnsMirrorMemberships() } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeGroupParticipationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeGroupParticipationTests.swiftindex 6e798c0..cfc4f35 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeGroupParticipationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeGroupParticipationTests.swift@@ -263,12 +263,12 @@ private final class MergeSeedStore { work.genericNotes = notes work.modifiedAt = MergeGroupFixture.epoch.addingTimeInterval(offset) if let urlIdentity {- work.setLegacyColumns { $0.urlIdentity = urlIdentity }- work.setLegacyIdentityStateRaw(WorkURLIdentityState.legacyUnverified.rawValue)- // The membership is where the readers take it from (Req 1.2); the- // columns beside it are the V9 mirror.- work.membershipValues.first?.urlIdentity = urlIdentity- work.membershipValues.first?.urlIdentityState = .legacyUnverified+ // The membership is the only place the readers take it from+ // (Req 1.2); the columns that mirrored it went at V9.+ work.primaryMembershipEdit {+ $0.urlIdentity = urlIdentity+ $0.urlIdentityState = .legacyUnverified+ } } inserted.append(work) return work@@ -288,7 +288,7 @@ private final class MergeSeedStore { context.insert(entry) if let work { entry.work = work- entry.workAssignmentProvenance = .manual+ entry.editCitations { $0.workAssignment = .manual } } return entry }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swiftindex 5e53ad4..f5ed1c6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift@@ -84,7 +84,11 @@ struct WorkMergePlannerTests { #expect(outcome.displayTitle == "Target") #expect(outcome.typeDisplay == target.snapshot.typeDisplay) #expect(outcome.titleProvenance == .manual)- #expect(outcome.workURL == "https://example.com/target")+ // One site, so the whole merge's URL and identity answers are that+ // site's row (`sites`, Req 4.2).+ let site = try #require(outcome.sites.first)+ #expect(outcome.sites.map(\.hostname) == ["example.com"])+ #expect(site.workURL == "https://example.com/target") #expect(outcome.genreTags == ["Drama", "shared", "drama", "New"]) #expect(outcome.movedEntryIDs == [source.snapshot.entries[0].id]) #expect(outcome.resultingEntryCount == 2)@@ -108,11 +112,11 @@ struct WorkMergePlannerTests { entryIDs: [source.snapshot.entries[0].id], identity: ExactScalarString("42") ))- #expect(outcome.identityEvidence == .complete(+ #expect(site.identityEvidence == .complete( entryIDs: [target.snapshot.entries[0].id, source.snapshot.entries[0].id], identity: ExactScalarString("42") ))- #expect(outcome.identityDisposition == .set(identity: ExactScalarString("42"), rule: rule.reference))+ #expect(site.identityDisposition == .set(identity: ExactScalarString("42"), rule: rule.reference)) #expect(outcome.issues.isEmpty) } @@ -131,7 +135,7 @@ struct WorkMergePlannerTests { WorkMergeBasis(source: source, target: target, currentRule: rule) ) - #expect(outcome.workURL == "https://example.com/source")+ #expect(outcome.sites.map(\.workURL) == ["https://example.com/source"]) #expect(outcome.auditBlock == nil) #expect(outcome.genericNotes.isEmpty) #expect(outcome.retainedFields.contains(.sourceWorkURL))@@ -171,14 +175,15 @@ struct WorkMergePlannerTests { target: work(id: 1, entries: [entry(id: 11, url: "https://example.com/?series=42")]), currentRule: rule ))- #expect(complete.identityDisposition == .set(identity: ExactScalarString("42"), rule: rule.reference))+ #expect(try onlySite(complete).identityDisposition+ == .set(identity: ExactScalarString("42"), rule: rule.reference)) let split = try WorkMergePlanner.project(WorkMergeBasis( source: work(id: 2, entries: [entry(id: 12, url: "https://example.com/?series=99")]), target: work(id: 1, entries: [entry(id: 11, url: "https://example.com/?series=42")]), currentRule: rule ))- #expect(split.identityDisposition == .clear)+ #expect(try onlySite(split).identityDisposition == .clear) #expect(split.issues == [.reviewURLIdentity]) let failed = try WorkMergePlanner.project(WorkMergeBasis(@@ -186,7 +191,7 @@ struct WorkMergePlannerTests { target: work(id: 1, entries: [entry(id: 11, url: "https://example.com/?series=42")]), currentRule: rule ))- #expect(failed.identityDisposition == .clear)+ #expect(try onlySite(failed).identityDisposition == .clear) #expect(failed.issues == [.reviewURLIdentity]) let legacy = WorkIdentitySnapshot(@@ -199,8 +204,17 @@ struct WorkMergePlannerTests { target: work(id: 1, identity: legacy), currentRule: rule ))- #expect(empty.identityEvidence == .noEntries(previousIdentity: legacy))- #expect(empty.identityDisposition == .retain(legacy))+ let emptySite = try onlySite(empty)+ #expect(emptySite.identityEvidence == .noEntries(previousIdentity: legacy))+ #expect(emptySite.identityDisposition == .retain(legacy))+ }++ /// The projections above are all single-site, so "the merge's identity+ /// answer" is that one row. The V7-shaped facade that used to spell this on+ /// `WorkMergeOutcome` went with the columns at V9.+ private func onlySite(_ outcome: WorkMergeOutcome) throws -> WorkMergeSiteOutcome {+ #expect(outcome.sites.count == 1)+ return try #require(outcome.sites.first) } private func queryRule() throws -> URLRuleBasisEntry {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkSnapshotMembershipTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkSnapshotMembershipTests.swiftindex f327c3d..499fbb8 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkSnapshotMembershipTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkSnapshotMembershipTests.swift@@ -203,9 +203,9 @@ extension LibraryRepository { let site = Site(hostname: hostname) context.insert(site) let work = Work(- id: id, displayTitle: title, siteHostname: hostname, timestamp: M5Fixture.epoch)+ id: id, displayTitle: title, timestamp: M5Fixture.epoch) context.insert(work)- work.setLegacySite(site)+ withExtendedLifetime(site) {} try context.save() } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeAssignmentTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeAssignmentTests.swiftindex bfd045a..f601f2b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeAssignmentTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeAssignmentTests.swift@@ -3,96 +3,70 @@ import Testing @testable import AsterismCore -/// The one place a stored column becomes a work's type — and since V8 there is-/// only one column (Req 10.3).+/// The one place a stored column becomes a work's type — and there is only one+/// column (Req 10.3). /// /// A work's type used to live in two: the V6 `workTypeID` and the pre-feature /// `typeRaw` that older builds read and wrote, with `typeRaw` winning so a /// pre-feature edit could be detected (`configurable-work-types` Req 6.10, Q27).-/// V8 stops reading `typeRaw` altogether. What this suite pins is the-/// consequence Req 10.3 states: **a Work typed only by a legacy string presents-/// as untyped**, which is exactly how a Work with `typeRaw == other` and no-/// identity has always presented.+/// V8 stopped reading `typeRaw` and V9 dropped it, so a Work typed only by a+/// legacy string is not a shape the store can hold any more — it read as+/// untyped, and untyped is what is left. @Suite("Work type assignment derivation") struct WorkTypeAssignmentTests { private static let typeID = UUID(uuidString: "0E7A0000-0000-4000-8000-00000000000A")! - private static func work(typeRaw: String, workTypeID: UUID?) -> Work {- let work = Work(displayTitle: "A Work", siteHostname: "example.test", timestamp: .now)- work.typeRaw = typeRaw+ private static func work(workTypeID: UUID?) -> Work {+ let work = Work(displayTitle: "A Work", timestamp: .now) work.workTypeID = workTypeID return work } - // MARK: - Req 10.3: `typeRaw` is not read-- /// Every pre-feature spelling, recognised or not, with no identity beside- /// it: untyped. The column is still *there* — V8 only stops reading it- /// (Decision 3) — so the test writes it and asserts it makes no difference.- @Test(- "A Work with no work-type identity is untyped whatever typeRaw holds",- arguments: ["other", "novel", "toon", "article", "webtoon", "Novel", "", "garbage"])- func untypedWithoutAnIdentity(typeRaw: String) {- #expect(WorkTypeAssignment.assignment(of: Self.work(typeRaw: typeRaw, workTypeID: nil))- == .none,- "a Work typed only by a legacy string is untyped from V8 on (Q16)")- }+ // MARK: - Req 10.3: the identity is the whole derivation - /// And with an identity: configured, whatever the column holds. The Q27- /// precedence — a non-`other` `typeRaw` demoting a configured assignment —- /// is gone with the column read that implemented it.- @Test(- "A Work with a work-type identity is configured whatever typeRaw holds",- arguments: ["other", "novel", "toon", "article", "webtoon", "Novel", "", "garbage"])- func configuredWithAnIdentity(typeRaw: String) {- #expect(- WorkTypeAssignment.assignment(of: Self.work(typeRaw: typeRaw, workTypeID: Self.typeID))- == .configured(Self.typeID),- "the identity is the whole derivation; the compatibility column is unread")+ /// No identity: untyped, and there is no second column left to argue with+ /// it. The parameterised sweep over every pre-feature `typeRaw` spelling+ /// that used to stand here went with the column at V9, as did the test that+ /// a legacy-typed Work presents as *the same value* an untyped one does.+ @Test("A Work with no work-type identity is untyped")+ func untypedWithoutAnIdentity() {+ #expect(WorkTypeAssignment.assignment(of: Self.work(workTypeID: nil)) == .none)+ #expect(WorkTypeDirectory.empty.display(of: .none) == .untyped) } - /// Req 10.3 in as many words: the presentation of a legacy-typed Work after- /// migration is *the same value* as the untyped presentation the column- /// already had, not a lookalike.- @Test("A legacy-typed Work presents exactly as today's untyped Work")- func legacyPresentsAsUntyped() {- let legacy = Self.work(typeRaw: "novel", workTypeID: nil)- let untyped = Self.work(typeRaw: WorkType.other.rawValue, workTypeID: nil)- let types = WorkTypeDirectory.empty-- #expect(WorkTypeAssignment.assignment(of: legacy)- == WorkTypeAssignment.assignment(of: untyped))- #expect(types.display(of: WorkTypeAssignment.assignment(of: legacy))- == types.display(of: WorkTypeAssignment.assignment(of: untyped)))- #expect(types.display(of: WorkTypeAssignment.assignment(of: legacy)) == .untyped)+ /// And with an identity: configured. The Q27 precedence — a non-`other`+ /// `typeRaw` demoting a configured assignment — went with the column read+ /// that implemented it, and then with the column.+ @Test("A Work with a work-type identity is configured")+ func configuredWithAnIdentity() {+ #expect(+ WorkTypeAssignment.assignment(of: Self.work(workTypeID: Self.typeID))+ == .configured(Self.typeID)) } @Test("A work at the model defaults is untyped") func defaultsAreUntyped() {- let work = Work(displayTitle: "A Work", siteHostname: "example.test", timestamp: .now)+ let work = Work(displayTitle: "A Work", timestamp: .now) #expect(WorkTypeAssignment.assignment(of: work) == .none) } // MARK: - The write path - /// Req 10.3's other half: the writer stops touching `typeRaw`. It is a- /// retained column with no writer until V9 drops it (Decision 3), so a value- /// a pre-feature build left there survives untouched — unread, and therefore- /// harmless.- @Test("Assigning a type writes the identity and leaves typeRaw alone")- func writerLeavesTypeRawAlone() {- let work = Self.work(typeRaw: "novel", workTypeID: nil)+ /// Req 10.3's other half: the writer touches the identity and nothing else.+ /// It was pinned against `typeRaw` surviving the write untouched until V9+ /// dropped the column.+ @Test("Assigning a type writes the identity, value-guarded")+ func writerWritesTheIdentityAlone() {+ let work = Self.work(workTypeID: nil) #expect(WorkTypeWriter.apply(.configured(Self.typeID), to: work)) #expect(work.workTypeID == Self.typeID)- #expect(work.typeRaw == "novel", "the retained column has no writer any more") #expect(!WorkTypeWriter.apply(.configured(Self.typeID), to: work), "the write is value-guarded on the identity alone") #expect(WorkTypeWriter.apply(.none, to: work)) #expect(work.workTypeID == nil)- #expect(work.typeRaw == "novel") #expect(WorkTypeAssignment.assignment(of: work) == .none) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swiftindex 2e93d78..17b6bb5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift@@ -31,9 +31,9 @@ struct WorkTypeConvergenceTests { directory = FileManager.default.temporaryDirectory .appending(path: "WorkTypeConvergence-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [ ModelConfiguration( "AsterismV3", schema: schema,@@ -214,26 +214,11 @@ struct WorkTypeConvergenceTests { // MARK: - The carrier gate (Req 8.4) - /// Req 10.3: a carrier typed only by the retained `typeRaw` column is- /// **untyped**, so there is nothing to propagate — and nothing rewrites the- /// column either (Decision 3). This used to pin the pre-feature propagation- /// behaviour that came with the `.legacy` case.- @Test("A carrier typed only by typeRaw propagates nothing and rewrites nothing")- func legacyCarrierPropagatesNothing() throws {- let store = try DuplicateStore()- let survivor = try Self.seedDuplicateWorks(store) { carrier in- carrier.typeRaw = "novel"- }-- try store.reconcile()+ // A carrier typed only by the pre-feature `typeRaw` column used to be pinned+ // here as propagating nothing (Req 10.3). V9 dropped the column, so a Work+ // has no type but its work-type identity and the shape cannot be seeded. - #expect(- try Self.typeColumns(store, id: survivor)- == WorkTypeColumns(typeRaw: "other", workTypeID: nil),- "an untyped carrier propagates nothing, and no writer touches the retained column")- }-- @Test("An active configured carrier type propagates, leaving the compatibility column untyped")+ @Test("An active configured carrier type propagates") func activeConfiguredCarrierPropagates() throws { let store = try DuplicateStore() let typeID = UUID()@@ -244,35 +229,31 @@ struct WorkTypeConvergenceTests { try store.reconcile() - #expect(- try Self.typeColumns(store, id: survivor)- == WorkTypeColumns(typeRaw: "other", workTypeID: typeID))+ #expect(try Self.typeIdentity(store, id: survivor) == typeID) } - /// The three the gate stops. Silent reconciliation must not hand a work a+ /// The two the gate stops. Silent reconciliation must not hand a work a /// type the reader cannot see in the list and never chose.- @Test("A removed, unresolved or unrecognised carrier type does not propagate (8.4)")+ ///+ /// It was three: an *unrecognised* carrier meant one typed by the+ /// pre-feature `typeRaw` spelling, which V9 dropped along with the column.+ @Test("A removed or unresolved carrier type does not propagate (8.4)") func gatedCarrierTypesDoNotPropagate() throws { let removedID = UUID()- for shape in ["removed", "unresolved", "unrecognised"] {+ for shape in ["removed", "unresolved"] { let store = try DuplicateStore() if shape == "removed" { store.seed.insert( WorkTypeEntity(id: removedID, name: "webtoon", state: .removed)) } let survivor = try Self.seedDuplicateWorks(store) { carrier in- switch shape {- case "removed": carrier.workTypeID = removedID- case "unresolved": carrier.workTypeID = UUID()- default: carrier.typeRaw = "graphic novel"- }+ carrier.workTypeID = shape == "removed" ? removedID : UUID() } try store.reconcile() #expect(- try Self.typeColumns(store, id: survivor)- == WorkTypeColumns(typeRaw: "other", workTypeID: nil),+ try Self.typeIdentity(store, id: survivor) == nil, "a \(shape) carrier type must not propagate") } }@@ -320,8 +301,8 @@ struct WorkTypeConvergenceTests { #expect(outcome.workTypes.mergedIdentities == 1) #expect(- try await fixture.repository.workTypeColumns(of: survivor)- == [WorkTypeColumns(typeRaw: "other", workTypeID: cited)])+ try await fixture.repository.workTypeIdentities(of: survivor)+ == [cited]) } // MARK: - Helpers@@ -339,12 +320,11 @@ struct WorkTypeConvergenceTests { return survivor.id } - private static func typeColumns(_ store: DuplicateStore, id: UUID) throws -> WorkTypeColumns {+ private static func typeIdentity(_ store: DuplicateStore, id: UUID) throws -> UUID? { try store.read { context in let rows = try context.fetch( FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))- let row = try #require(rows.first)- return WorkTypeColumns(typeRaw: row.typeRaw, workTypeID: row.workTypeID)+ return try #require(rows.first).workTypeID } } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeDisplayTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeDisplayTests.swiftindex 8d1ef73..c0d23dc 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeDisplayTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeDisplayTests.swift@@ -94,28 +94,10 @@ struct WorkTypeDisplayTests { // MARK: - The snapshot mapper (Req 8.3) - /// Req 10.3: a raw type value is not read at all any more, so a work- /// carrying one reads as **untyped** — the same presentation a work with- /// `typeRaw == other` and no identity has always had. It still does not- /// refuse, which is what Decision 5 of `library-integrity-tolerance` was- /// about.- @Test("A work carrying an unrecognised raw type reads as untyped (8.3, 10.3)")- func unrecognisedRawTypeReadsAsUntyped() async throws {- let fixture = try await M5Fixture()- let workID = UUID()- try await fixture.repository.seedM5Rows(- sites: [M5SeedSite(hostname: "example.com")],- works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")])- try await fixture.repository.forceWorkTypeColumns(- of: workID, WorkTypeColumns(typeRaw: "graphic novel", workTypeID: nil))-- let work = try await fixture.repository.work(id: workID)- #expect(work.typeDisplay == .untyped)- #expect(- try await fixture.repository.workTypeColumns(of: workID)- == [WorkTypeColumns(typeRaw: "graphic novel", workTypeID: nil)],- "the column is retained, unread, and untouched (Decision 3)")- }+ // A work carrying an unrecognised *raw* type used to be pinned here as+ // reading untyped (Req 10.3). V9 dropped `typeRaw`, so the only type a Work+ // can carry is a work-type identity and the shape cannot be seeded; the+ // no-identity case below is what is left of it. /// Req 8.6's whole sentence: not corrupt, not cleared, and healed by an /// arrival rather than by a relaunch — the repository is the same actor@@ -260,21 +242,19 @@ struct WorkTypeDisplayTests { ).workTypeLabel == nil) } - /// Req 8.2's other half, unchanged: a legacy-typed work keeps labelling with- /// its stored value, which is not a list entry at all (Decision 7).- /// Req 10.3: a work typed only by the retained `typeRaw` column is untyped,- /// so the export carries no label for it — the same output an untyped work- /// has always produced.- @Test("A work typed only by typeRaw exports no type label (8.2, 10.3)")- func legacyTypedWorkExportsNoLabel() async throws {+ /// Req 8.2's other half: a work with no work-type identity carries no label+ /// in the export. It was seeded through the pre-feature `typeRaw` column+ /// until V9 dropped it; the output is the one an untyped work has always+ /// produced, which is what the requirement is about.+ @Test("A work with no work-type identity exports no type label (8.2, 10.3)")+ func untypedWorkExportsNoLabel() async throws { let fixture = try await M5Fixture() let workID = UUID() let entryID = UUID() try await fixture.repository.seedM5Rows( sites: [M5SeedSite(hostname: "example.com")], works: [- M5SeedWork(- id: workID, displayTitle: "A Serial", hostname: "example.com", type: .toon)+ M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com") ], entries: [ M5SeedEntry(
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swiftindex 068a9fb..8fdf8a1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift@@ -30,9 +30,9 @@ struct WorkTypeOrderingTests { directory = FileManager.default.temporaryDirectory .appending(path: "WorkTypeOrdering-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [ ModelConfiguration( "AsterismV3", schema: schema,@@ -44,16 +44,12 @@ struct WorkTypeOrderingTests { deinit { try? FileManager.default.removeItem(at: directory) } @discardableResult- func addWork(- title: String = "A Serial", typeRaw: String = WorkType.other.rawValue,- workTypeID: UUID? = nil- ) -> Work {+ func addWork(title: String = "A Serial", workTypeID: UUID? = nil) -> Work { let work = Work(- displayTitle: title, siteHostname: "example.com",+ displayTitle: title, timestamp: Date(timeIntervalSince1970: 1_800_000_000)) work.lastParsedTitle = title work.titleProvenance = .parsed- work.typeRaw = typeRaw work.workTypeID = workTypeID context.insert(work) return work@@ -94,20 +90,16 @@ struct WorkTypeOrderingTests { // MARK: - Typed beats untyped (Req 8.5) - /// Req 10.3: "assigned" means a work-type identity, and nothing else. A row- /// carrying only the retained `typeRaw` column is untyped, so it is bare —- /// which is the presentation such a work has after migration.+ /// Req 10.3: "assigned" means a work-type identity, and nothing else. The+ /// three pre-feature `typeRaw` spellings this used to sweep were all bare+ /// for the same reason a work with no identity is, and went with the column+ /// at V9. @Test(- "An assigned work-type identity is authored content; a bare typeRaw is not",- arguments: [- (WorkType.other.rawValue, UUID?.some(UUID()), false),- (WorkType.other.rawValue, UUID?.none, true),- ("novel", UUID?.none, true),- ("graphic novel", UUID?.none, true),- ])- func assignedTypesAreAuthored(typeRaw: String, workTypeID: UUID?, expectedBare: Bool) throws {+ "An assigned work-type identity is authored content; no identity is bare",+ arguments: [(UUID?.some(UUID()), false), (UUID?.none, true)])+ func assignedTypesAreAuthored(workTypeID: UUID?, expectedBare: Bool) throws { let store = try OrderingStore()- let work = store.addWork(typeRaw: typeRaw, workTypeID: workTypeID)+ let work = store.addWork(workTypeID: workTypeID) try store.context.save() // An empty directory: an unresolved configured id is still an authored@@ -130,18 +122,20 @@ struct WorkTypeOrderingTests { #expect(!GroupOrdering.authoredContent(of: work, types: types).isBare) } - /// The compatibility column reads both of these as `other` on a pre-feature- /// build. Here they are two types, which is the second sentence of Req 8.5.- @Test("A legacy value and a configured type spelled the same are distinct")- func legacyAndConfiguredWithOneSpellingAreDistinct() throws {+ /// A configured type is authored content and no type at all is not — the+ /// second sentence of Req 8.5. It was spelt as a *legacy-typed* work against+ /// a configured one of the same name until V9 dropped `typeRaw`; the legacy+ /// side always derived to the same nothing this one does.+ @Test("An untyped work and a configured one are distinct authored content")+ func untypedAndConfiguredAreDistinct() throws { let store = try OrderingStore()- let legacy = store.addWork(typeRaw: "novel")+ let untyped = store.addWork() let configured = store.addWork(workTypeID: Self.novel.id) try store.context.save() let types = WorkTypeDirectory(rows: [.init(id: Self.novel.id, name: "novel")]) #expect(- GroupOrdering.authoredContent(of: legacy, types: types)+ GroupOrdering.authoredContent(of: untyped, types: types) != GroupOrdering.authoredContent(of: configured, types: types)) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swiftindex 0236a3c..78d76b6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift@@ -30,12 +30,12 @@ struct WorkTypePlumbingTests { directory = FileManager.default.temporaryDirectory .appending(path: "WorkTypePlumbing-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV8MigrationPlan.self,+ for: schema, migrationPlan: AsterismV9MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeRepositoryTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeRepositoryTestSupport.swiftindex fe2f0d5..41251b9 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeRepositoryTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeRepositoryTestSupport.swift@@ -42,13 +42,6 @@ struct SeedWorkType: Sendable { } } -/// A work's two type columns, read raw — the snapshot deliberately presents an-/// assignment, and the write table is a statement about the columns.-struct WorkTypeColumns: Sendable, Equatable {- var typeRaw: String- var workTypeID: UUID?-}- extension LibraryRepository { func seedWorkTypes(_ seeds: [SeedWorkType]) async throws {@@ -86,15 +79,18 @@ extension LibraryRepository { } } - /// The two type columns of every row of a Work identity, in representative- /// order.- func workTypeColumns(of id: UUID) async throws -> [WorkTypeColumns] {+ /// The work-type citation of every row of a Work identity, in+ /// representative order — read raw, because the snapshot deliberately+ /// presents an *assignment* and the write table is a statement about the+ /// column. It was a pair until V9 dropped the pre-feature `typeRaw` beside+ /// it; `workTypeID` is the whole of a Work's type now.+ func workTypeIdentities(of id: UUID) async throws -> [UUID?] { try await withLockedContext(- mode: .shared, operation: "reading work type columns"+ mode: .shared, operation: "reading work type identities" ) { context in GroupOrdering.sortedWorkRows( try context.fetch(FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))- ).map { WorkTypeColumns(typeRaw: $0.typeRaw, workTypeID: $0.workTypeID) }+ ).map(\.workTypeID) } } @@ -109,17 +105,16 @@ extension LibraryRepository { return try #require(keys.first) } - /// Writes the two columns of every row of a Work identity directly, for the- /// states a write path will not produce (a pre-feature build's later edit).- func forceWorkTypeColumns(of id: UUID, _ columns: WorkTypeColumns) async throws {+ /// Writes the work-type citation of every row of a Work identity directly,+ /// for the states a write path will not produce.+ func forceWorkTypeIdentity(of id: UUID, _ workTypeID: UUID?) async throws { try await withLockedContext(- mode: .exclusive, operation: "forcing work type columns"+ mode: .exclusive, operation: "forcing the work type identity" ) { context in for row in try context.fetch( FetchDescriptor<Work>(predicate: #Predicate { $0.id == id })) {- row.typeRaw = columns.typeRaw- row.workTypeID = columns.workTypeID+ row.workTypeID = workTypeID } try context.save() }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeResolutionSurfaceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeResolutionSurfaceTests.swiftindex 632c939..59a6d33 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeResolutionSurfaceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeResolutionSurfaceTests.swift@@ -84,16 +84,16 @@ struct WorkTypeResolutionSurfaceTests { return } #expect(- try await fixture.repository.workTypeColumns(of: survivorID)- == [WorkTypeColumns(typeRaw: "other", workTypeID: Self.webtoon.id)])+ try await fixture.repository.workTypeIdentities(of: survivorID)+ == [Self.webtoon.id]) } - /// Req 10.3: a variant whose only type was a pre-feature `typeRaw` is- /// **untyped**, so resolving onto it untypes the survivor — and leaves the- /// retained column exactly as it found it, because nothing writes it any- /// more (Decision 3).- @Test("Resolving onto a variant typed only by typeRaw untypes the survivor")- func resolutionOntoALegacyVariantUntypesTheSurvivor() async throws {+ /// Resolving onto an untyped variant untypes the survivor. The variant was+ /// seeded through the pre-feature `typeRaw` column until V9 dropped it — a+ /// Work carrying one always read as untyped (Req 10.3), which is the state+ /// the seed below writes directly.+ @Test("Resolving onto an untyped variant untypes the survivor")+ func resolutionOntoAnUntypedVariantUntypesTheSurvivor() async throws { let fixture = try await M5Fixture() let survivorID = UUID() try await fixture.repository.seedM5Rows(@@ -105,7 +105,7 @@ struct WorkTypeResolutionSurfaceTests { lastParsedTitle: "Serial", genericNotes: "first", createdAt: Date(timeIntervalSince1970: 1_800_000_000)), M5SeedWork(- id: UUID(), displayTitle: "Serial", hostname: "example.com", type: .toon,+ id: UUID(), displayTitle: "Serial", hostname: "example.com", titleProvenance: .parsed, lastParsedTitle: "Serial", genericNotes: "second", createdAt: Date(timeIntervalSince1970: 1_800_000_100)), ])@@ -121,8 +121,8 @@ struct WorkTypeResolutionSurfaceTests { _ = try await fixture.repository.commitDuplicateResolution(contract, choosing: chosen.id) #expect(- try await fixture.repository.workTypeColumns(of: survivorID)- == [WorkTypeColumns(typeRaw: "other", workTypeID: nil)])+ try await fixture.repository.workTypeIdentities(of: survivorID)+ == [nil]) } // MARK: - The fold
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeSettingsAPITests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeSettingsAPITests.swiftindex ee4b794..462bdcc 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeSettingsAPITests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeSettingsAPITests.swift@@ -163,8 +163,8 @@ struct WorkTypeSettingsAPITests { try await fixture.repository.removeWorkType(id: Self.webtoon.id) #expect(- try await fixture.repository.workTypeColumns(of: workID)- == [WorkTypeColumns(typeRaw: "other", workTypeID: Self.webtoon.id)])+ try await fixture.repository.workTypeIdentities(of: workID)+ == [Self.webtoon.id]) let display = try await fixture.repository.work(id: workID).typeDisplay #expect(display.kind == .removed) #expect(display.name == "webtoon")@@ -233,14 +233,17 @@ struct WorkTypeSettingsAPITests { /// Decision 7: a legacy `novel` and the seeded type spelled "novel" are /// unrelated, and the removal prompt must not count one as the other.- @Test("A legacy-typed work with the same spelling does not count")- func legacyTypedWorksDoNotCount() async throws {+ ///+ /// The uncounted work was a *legacy-typed* one — typed by the pre-feature+ /// `typeRaw` spelling — until V9 dropped that column. What is left is an+ /// untyped work, which is what such a Work always read as (Req 10.3).+ @Test("An untyped work with the same spelling does not count")+ func untypedWorksDoNotCount() async throws { let fixture = try await M5Fixture() try await fixture.repository.seedM5Rows( sites: [M5SeedSite(hostname: "example.com")], works: [- M5SeedWork(- id: UUID(), displayTitle: "Legacy", hostname: "example.com", type: .novel),+ M5SeedWork(id: UUID(), displayTitle: "Legacy", hostname: "example.com"), M5SeedWork( id: UUID(), displayTitle: "Configured", hostname: "example.com", workTypeID: Self.novel.id),
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeWritePathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeWritePathTests.swiftindex f833299..4a4dba1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeWritePathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeWritePathTests.swift@@ -30,8 +30,8 @@ struct WorkTypeWritePathTests { try await Self.update(fixture, workID: workID, assignment: .configured(Self.novel.id)) #expect(- try await fixture.repository.workTypeColumns(of: workID)- == [WorkTypeColumns(typeRaw: "other", workTypeID: Self.novel.id)])+ try await fixture.repository.workTypeIdentities(of: workID)+ == [Self.novel.id]) } @Test("An untype clears the id and writes the untyped compatibility value")@@ -43,42 +43,25 @@ struct WorkTypeWritePathTests { try await Self.update(fixture, workID: workID, assignment: .none) #expect(- try await fixture.repository.workTypeColumns(of: workID)- == [WorkTypeColumns(typeRaw: "other", workTypeID: nil)])+ try await fixture.repository.workTypeIdentities(of: workID)+ == [nil]) } - /// Req 10.3: a work whose `typeRaw` disagrees with its identity now reads- /// as the identity, and saving that carried assignment back leaves the- /// retained column untouched.- @Test("A disagreeing typeRaw does not demote the identity, and is not rewritten")+ /// Saving a carried assignment back writes the identity it carried and+ /// nothing else. It used to be paired with a test that a *disagreeing*+ /// `typeRaw` neither demoted the identity nor got rewritten; V9 dropped that+ /// column, so there is nothing left to disagree.+ @Test("A carried assignment saves back as the identity it carried") func carriedTypeFollowsTheIdentity() async throws { let fixture = try await M5Fixture() let workID = try await Self.seedWork(fixture)- try await fixture.repository.forceWorkTypeColumns(- of: workID, WorkTypeColumns(typeRaw: "toon", workTypeID: Self.novel.id))+ try await fixture.repository.forceWorkTypeIdentity(of: workID, Self.novel.id) let carried = try await fixture.repository.work(id: workID).typeDisplay.assignment #expect(carried == .configured(Self.novel.id)) try await Self.update(fixture, workID: workID, assignment: carried) - #expect(- try await fixture.repository.workTypeColumns(of: workID)- == [WorkTypeColumns(typeRaw: "toon", workTypeID: Self.novel.id)])- }-- @Test("An unread raw value survives a save that does not change it (7.1, 8.3)")- func unrecognisedCarriedValueSurvivesASave() async throws {- let fixture = try await M5Fixture()- let workID = try await Self.seedWork(fixture)- try await fixture.repository.forceWorkTypeColumns(- of: workID, WorkTypeColumns(typeRaw: "graphic novel", workTypeID: nil))-- let carried = try await fixture.repository.work(id: workID).typeDisplay.assignment- try await Self.update(fixture, workID: workID, assignment: carried)-- #expect(- try await fixture.repository.workTypeColumns(of: workID)- == [WorkTypeColumns(typeRaw: "graphic novel", workTypeID: nil)])+ #expect(try await fixture.repository.workTypeIdentities(of: workID) == [Self.novel.id]) } // MARK: - Fan-out (Req 2.7)@@ -96,12 +79,8 @@ struct WorkTypeWritePathTests { try await Self.update(fixture, workID: workID, assignment: .configured(Self.novel.id)) - let columns = try await fixture.repository.workTypeColumns(of: workID)- #expect(columns.count == 2)- #expect(- columns.allSatisfy {- $0.typeRaw == "other" && $0.workTypeID == Self.novel.id- })+ let identities = try await fixture.repository.workTypeIdentities(of: workID)+ #expect(identities == [Self.novel.id, Self.novel.id]) } /// Decision 10's fan-out, on the list side: every local row of the identity
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swiftindex 86ce3a5..b4b6ecf 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift@@ -91,8 +91,8 @@ struct WriteSiteRelationshipTests { // MARK: - create-Work - @Test("Creating a Work sets work.siteHostname and work.site, pointing at the Site it just created")- func createWorkSetsBothHalvesForANewSite() async throws {+ @Test("Creating a Work mints a membership on the Site it just created")+ func createWorkMintsAMembershipForANewSite() async throws { let dir = try TempDir("WriteSiteWork") let (cfg, repository) = try await openLibrary(dir) @@ -102,14 +102,14 @@ struct WriteSiteRelationshipTests { 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.legacyColumns.hostname == "worksite.example")- #expect(work.legacySite === site, "the new Work points at the Site it caused to exist")+ #expect(work.primaryMembership?.hostname == "worksite.example")+ #expect(work.primaryMembership?.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 {+ @Test("Creating a Work on an existing hostname mints a membership on the existing Site row")+ func createWorkMintsAMembershipForAnExistingSite() async throws { let dir = try TempDir("WriteSiteWork") let (cfg, repository) = try await openLibrary(dir) @@ -121,7 +121,7 @@ struct WriteSiteRelationshipTests { let sites = try context.fetch(FetchDescriptor<Site>()) #expect(sites.count == 1) let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)- #expect(work.legacySite === sites[0])+ #expect(work.primaryMembership?.site === sites[0]) } withExtendedLifetime(dir) {} }@@ -142,8 +142,8 @@ struct WriteSiteRelationshipTests { 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.legacyColumns.hostname == "taught.example")- #expect(work.legacySite === site)+ #expect(work.primaryMembership?.hostname == "taught.example")+ #expect(work.primaryMembership?.site === site) } } withExtendedLifetime(dir) {}@@ -181,7 +181,7 @@ struct WriteSiteRelationshipTests { .first { $0.id == createdWorkID }) #expect(created.displayTitle == "Nonfiction", "the capture reused a Work instead of taking the .create arm")- #expect(created.legacySite === site)+ #expect(created.primaryMembership?.site === site) } withExtendedLifetime(dir) {} }@@ -208,7 +208,7 @@ struct WriteSiteRelationshipTests { 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.legacySite === site, "the re-parse created a Work with no Site relationship")+ #expect(created.primaryMembership?.site === site, "the re-parse created a Work with no Site relationship") } withExtendedLifetime(dir) {} }@@ -256,7 +256,7 @@ struct WriteSiteRelationshipTests { let moved = try #require(try after.fetch(FetchDescriptor<Entry>()) .first { $0.id == captured.id }) let work = try #require(moved.work)- #expect(work.legacySite === moved.site,+ #expect(work.primaryMembership?.site === moved.site, "the new Work went to the current winner rather than to its Entry's row") withExtendedLifetime(dir) {} }@@ -268,7 +268,7 @@ struct WriteSiteRelationshipTests { /// format change, no marker republication, no second pass. @Test("materializeArchive wires both relationships from its sitesByHostname map") func materializeWiresBothRelationships() throws {- let schema = Schema(versionedSchema: AsterismSchemaV8.self)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self) let container = try ModelContainer( for: schema, configurations: [ModelConfiguration(@@ -282,7 +282,7 @@ struct WriteSiteRelationshipTests { 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.legacySite === site)+ #expect(work.primaryMembership?.site === site) withExtendedLifetime(container) {} } @@ -297,7 +297,7 @@ struct WriteSiteRelationshipTests { let dir = try TempDir("WriteSiteImportFill") let cfg = configuration(dir) let (_, repository) = try await LibraryRepository.openForApp(cfg)- #expect(try markerContent(cfg) == "8", "mark-at-birth certifies an empty store at \"7\"")+ #expect(try markerContent(cfg) == "9", "mark-at-birth certifies an empty store at \"7\"") let plan = try importPlan() let result = try await repository.confirmImport(plan: plan)@@ -305,7 +305,7 @@ struct WriteSiteRelationshipTests { Issue.record("expected committed, got \(result)") return }- #expect(try markerContent(cfg) == "8", "the import republishes nothing")+ #expect(try markerContent(cfg) == "9", "the import republishes nothing") try expectEveryRelationshipPopulated(cfg) withExtendedLifetime(dir) {} }@@ -323,7 +323,7 @@ struct WriteSiteRelationshipTests { Issue.record("expected committed, got \(result)") return }- #expect(try markerContent(cfg) == "8")+ #expect(try markerContent(cfg) == "9") try expectEveryRelationshipPopulated(cfg) withExtendedLifetime(dir) {} }@@ -368,13 +368,13 @@ struct WriteSiteRelationshipTests { 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.legacySite)+ let writtenWorkSite = try #require(work.primaryMembership?.site) try SiteRelationshipPopulationPass.run(context: context) #expect(entry.site === writtenEntrySite, "the pass must not move an Entry the write path already pinned")- #expect(work.legacySite === writtenWorkSite)+ #expect(work.primaryMembership?.site === writtenWorkSite) withExtendedLifetime(dir) {} } @@ -436,7 +436,7 @@ struct WriteSiteRelationshipTests { entry.site = site context.insert(entry) try context.save()- try Data("7\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)+ try Data("8\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic) withExtendedLifetime(container) {} } @@ -454,7 +454,7 @@ struct WriteSiteRelationshipTests { sourceLocation: sourceLocation) } for work in works {- #expect(work.legacySite?.hostname == work.legacyColumns.hostname,+ #expect(work.primaryMembership?.site?.hostname == work.primaryMembership?.hostname, "\(work.displayTitle) imported with no Site relationship", sourceLocation: sourceLocation) }@@ -507,7 +507,7 @@ struct FixtureRelationshipTests { } } for work in try context.fetch(FetchDescriptor<Work>()) {- #expect(work.legacySite?.hostname == work.legacyColumns.hostname)+ #expect(work.primaryMembership?.site?.hostname == work.primaryMembership?.hostname) } } @@ -530,14 +530,14 @@ struct FixtureRelationshipTests { defer { withExtendedLifetime((container, dir)) {} } let context = ModelContext(container) let works = try context.fetch(FetchDescriptor<Work>())- .filter { $0.legacyColumns.hostname == "mergeable.test" }+ .filter { $0.primaryMembership?.hostname == "mergeable.test" } #expect(works.count == 2) for work in works { let membership = try #require( work.membership(for: "mergeable.test"), "Work.create is the single door and always mints one")- #expect(membership.urlIdentity == work.legacyColumns.urlIdentity)- #expect(membership.urlIdentityState == work.legacyColumns.urlIdentityState)+ #expect(membership.urlIdentity == work.primaryMembership?.urlIdentity)+ #expect(membership.urlIdentityState == work.primaryMembership?.urlIdentityState) #expect(membership.urlIdentityState == .legacyUnverified) } }
diff --git a/docs/agent-notes/composed-teaching-ui.md b/docs/agent-notes/composed-teaching-ui.mdindex 533f5ce..e9da054 100644--- a/docs/agent-notes/composed-teaching-ui.md+++ b/docs/agent-notes/composed-teaching-ui.md@@ -14,10 +14,11 @@ launches, and since T-1969 that single call creates the store, marks it ready an 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. `openContainer` 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.+`public` (still used by tests). The versioned marker writers+(`publishV4Readiness`, `publishV5Readiness`) are gone: certification publishes+through the unversioned `publishReadiness`, which always writes the current+generation — `"9"` since `drop-superseded-columns` — and a test that needs an+older digit writes the bytes directly. `ComposedSurfaceUITests` passes on the simulator (`testEntryDetailTeachOpensComposedSurface`, `testRecentPillOpensComposedSurface`, …).
diff --git a/docs/agent-notes/schema-migration.md b/docs/agent-notes/schema-migration.mdindex f27365c..e1bccca 100644--- a/docs/agent-notes/schema-migration.md+++ b/docs/agent-notes/schema-migration.md@@ -1,93 +1,96 @@ # Schema migration -Schema **V8** is live (since `specs/multi-site-works/`), with **V5**, **V6** and-**V7** frozen beside it as the `from` sides of three lightweight stages. The app-opens `openForApp` and the share extension `openForExtension` — one opener per-role, both over the same schema and the same file layout-(`retire-migration-chain`'s Req 4). Read the "Current state" section; everything-under "History" is background for the *next* schema bump and describes states-that no longer exist.+Schema **V9** is live (since `specs/drop-superseded-columns/`), with **V8**+frozen beside it as the `from` side of one lightweight stage. The app opens+`openForApp` and the share extension `openForExtension` — one opener per role,+both over the same schema and the same file layout (`retire-migration-chain`'s+Req 4). Read the "Current state" section; everything under "History" is+background for the *next* schema bump and describes states that no longer exist. ## Current state - **Every `@Model` is nested; there are zero top-level `@Model` types.** The live- classes live in `extension AsterismSchemaV8 { @Model final class Entry … }`+ classes live in `extension AsterismSchemaV9 { @Model final class Entry … }` (`Models.swift`) and are reached by top-level typealiases- (`typealias Entry = AsterismSchemaV8.Entry`). `AsterismSchemaV5.swift`,- `AsterismSchemaV6.swift` and `AsterismSchemaV7.swift` hold the frozen- snapshots — stored columns only, `public init() {}`, no accessors. The nesting- is what makes those snapshots legal; keep it.-- **`AsterismV8MigrationPlan` = `[V5, V6, V7, V8]`, three lightweight stages.**- V6 added `Work.workTypeID` and the `WorkTypeEntity` table; V7 added the- `Character` and `CharacterSuppression` tables and the two coverage-fingerprint- columns; V8 adds the `WorkSiteMembership` and `WorkDistinctPair` tables and the- `Entry.citationsData` / `TitlePattern.definitionData` blobs. **None removes- anything**, so `ModelContainer.init` runs all three conversions. The V5 stage- is kept deliberately (Q80 of `character-extraction`): retiring it would carry- `retire-migration-chain` Decision 6's population precondition and buys nothing,- and V5-seeded fixtures keep opening. `V5RecordedStoreTests` measures the chain- end to end; `V7RecordedStoreTests` measures the newest stage over a store- seeded through the frozen V7 snapshot.-- **V8 only adds, and a data pass finishes the job** (`multi-site-works`- Decision 3). The lightweight stage runs inside `ModelContainer.init`, so a- stage that *dropped* the superseded columns would destroy the source before- anything could read it. So they stay — `Work`'s six site/identity/URL columns- and `typeRaw`, `Entry.identityKeyVersion` and its seventeen citation columns,- `TitlePattern`'s ten definition columns, `Site.urlIdentityRule` — with **one- reader**, `LegacyColumns` in `Models.swift`. V9 (T-2280, routed from T-2272) drops them.- `ModelContractTests` pins both halves: they are still in- `Schema(...).entities`, and the set of files naming one only ever shrinks.-- Two details the "one reader, no writer" shorthand gets wrong. `Work`'s six are- **`fileprivate`**, not `internal` (Q59): `LegacyColumns.update(_:_:)` and- `LegacyColumns.setSite(_:on:)` are the only write doors and- `LegacyColumns.membership(from:)` the only read door, from anywhere. A- `@testable` suite is shut out of the columns exactly as the app is, and- reaches them through `Work.legacyColumns` / `Work.setLegacyColumns` in- `MembershipTestSupport.swift`, which go through the same two doors. Three of- the four sets still have writers, because- Decision 4 keeps the columns mirrored until V9 rather than stale:- `LegacyColumns.refreshMembership(on:memberships:)` mirrors the Work's primary- membership back into the six, `LegacyColumns.refreshCitations(on:)` does the- same for the Entry citation columns, and `Work.create` /- `Work.createCarrying` write both homes at birth. **Only `TitlePattern`'s ten- have no writer at all.**+ (`typealias Entry = AsterismSchemaV9.Entry`). `AsterismSchemaV8.swift` holds+ the one frozen snapshot — stored columns and `@Relationship` macros only,+ `public init() {}`, no accessors. The nesting is what makes that snapshot+ legal; keep it.+- **`AsterismV9MigrationPlan` = `[V8, V9]`, one lightweight stage, and it only+ *removes*.** V9 drops the ~35 columns V8 kept unread — `Work`'s six+ site/identity/URL columns and `typeRaw`, `Entry.identityKeyVersion` and its+ seventeen citation/provenance columns, `TitlePattern`'s ten definition columns,+ `Site.urlIdentityRule` — plus the `Site.works` ↔ `Work.site` inverse pair, which+ go together because one is declared as the other's inverse. **This is the first+ stage in the project's history that removes anything**, and two consequences+ follow it around: the drop happens inside `ModelContainer.init`, so nothing can+ read a column after the stage (Q4 of `drop-superseded-columns`), and a scratch+ container over the *frozen* snapshot now aborts the process rather than+ silently dropping a column — see the trap below.+ `V8RecordedStoreTests` measures the stage over a store seeded through the+ frozen V8 snapshot; `V4RecordedStoreTests` is the below-floor refusal suite.+ `ModelContractTests` pins that none of the dropped names is in+ `Schema(...).entities`.+ `AsterismSchemaV5/V6/V7` and their recorded-store suites are **deleted** (Q2):+ every device is at marker `"8"`, so a stage below V8 is a path nothing can+ reach.+- **No production data pass, and no `LegacyColumns`.** V8's columns were+ mirrored, not stale, until this stage took them; `LegacyColumns`,+ `V8PopulationPass` and `MembershipTestSupport`'s column doors are all gone.+ What replaced each mirror:+ - A Work's site presence is its `WorkSiteMembership`, read through+ `Work.primaryMembership` — including inside `GroupOrdering.sortedWorkRows`,+ which faults one membership per Work where six columns sat on the row+ (Q18; `specs/drop-superseded-columns/verification-run.md` §4.2 measures it).+ - An Entry's citations are `Entry.citationsData`, written by every writer+ directly through `setCitations` / `editCitations` (Q5, Q14) and seeded with+ the default value by `Entry.init` (Q13). **A nil blob means one thing**: a+ row a V8 build wrote that this build has not rewritten. It reads as the+ `.rawURL` default and is *reported*, never quarantined (Q6, Q25, Q32) —+ `entriesWithoutCitationBlobCount` is the counter.+ - A TitlePattern's definition is `TitlePattern.definitionData`, with no column+ fallback; a nil blob is `unreadableTitlePattern` and orders under its own+ constant-prefixed key (Q11). - **Declaring a stage stops the implicit conversion of stores the plan does not- name.** Under the previous `[V5]`/`stages: []` plan, a `4.0.0` store opened- directly through `openContainer` fell through to Core Data's **inferred- lightweight migration** (Q23 of `retire-migration-chain`) and came out at- `5.0.0`. With a real stage declared it fails instead, with the documented- `NSCocoaErrorDomain` 134504 "Cannot use staged migration with an unknown model- version" — the failure that spec's notes predicted for a plan whose declared- schemas disagree with the coordinator's model version. Nothing shipped depends- on the implicit conversion (the classifier refuses a below-V5 store before any- container exists; the recovery is the 4/4 backup archive), but **every test- seeding `v4-recorded-4.0.0.sqlite` and then opening it had to be rewritten**.- The convertible fixture is now `V5RecordedStoreFixture`, seeded in-process- through the frozen snapshot; `v4-recorded-4.0.0.sqlite` survives only as the- one input that positively reads *below* V5 for the classifier suites, and the- 432-Entry scale `.sqlite` was deleted because nothing can open it.- **Expect the same at V7**: freezing V6 and declaring a V6 → V7 stage will make- every 5.0.0-seeded fixture unopenable in exactly this way, and the successor to- `V5RecordedStoreFixture` should be written by seeding through the *then*-frozen- V6 snapshot.+ name.** Under the old `[V5]`/`stages: []` plan, a `4.0.0` store opened directly+ through `openContainer` fell through to Core Data's **inferred lightweight+ migration** (Q23 of `retire-migration-chain`) and came out at `5.0.0`. With a+ real stage declared it fails instead, with the documented `NSCocoaErrorDomain`+ 134504 "Cannot use staged migration with an unknown model version". Nothing+ shipped depends on the implicit conversion (the classifier refuses a store+ below the plan's floor before any container exists; the recovery is the backup+ archive), but **every test seeding a store older than the plan's floor and then+ opening it has to be rewritten at every bump.** The convertible fixture is now+ `V8RecordedStoreFixture`, seeded in-process through the frozen V8 snapshot;+ `v4-recorded-4.0.0.sqlite` survives only as the one input that positively reads+ *below* the floor for the classifier suites.+ **Expect the same at V10**: freezing V9 and declaring a V9 → V10 stage makes+ every V8-seeded fixture unopenable in exactly this way, and the successor to+ `V8RecordedStoreFixture` should be written by seeding through the *then*-frozen+ V9 snapshot. - The P1 probe's other measurements still stand (`specs/retire-migration-chain/probe-result.md`, **PASS**): a store that had arrived at `5.0.0` by traversing the real chain reopens cleanly, both with `cloudKitDatabase: .none` and with a `.private` scratch container. The caveat on the mirrored variant stands — it ran without a CloudKit entitlement, so the container constructed but never synced.-- **One data pass runs in production: `V8PopulationPass`.** It mints a- `WorkSiteMembership` per Work from the retained columns and fills the two- blobs, chunked, with a save per dirty chunk and an existence guard per phase —- so an interrupted run is completed rather than doubled by the next one. It is- called from the bootstrap's `.markerLagging` arm and from `reconcileAfterSync`,- and it certifies itself with `assertComplete` because the validator *tolerates*- every state it repairs (Q35).+- **No data pass runs in production.** `V8PopulationPass` was the last one and it+ is deleted with the columns it converted from. Nothing replaces it in the test+ target either: `V8Shape`, the bridge phase 1 stood up for the V5/V6/V7+ fixtures (Q21), went with those fixtures, and `FrozenLibraryPathTests`+ pins its absence so a source file cannot quietly become its new home.++ The reconcile that runs on every launch and every sync arrival is not a data+ pass: `reconcileAfterSync` runs the Site phase, the duplicate phase behind+ `duplicatePhaseRuns(tier:)`, and `MembershipReconciler`. The reconciler's+ **heal** is gated on `worksWithoutMembershipCount > 0`, on both tiers (Q10,+ Q20) — its dedupe phases are not, which is the 30 ms+ `reconcile-noop-coherent` measures. `SiteRelationshipPopulationPass` is a different thing and is still test-only.- It populates `Entry.site` / `Work.site` from the- hostname strings, and its last shipped caller was the marker-lagging branch of+ It pins `Entry.site` and `WorkSiteMembership.site` to the `SiteResolutionOrder`+ winner for a hostname — its `Work.site` loop went with that column (Q27) — and+ its last shipped caller was the marker-lagging branch of the app bootstrap, deleted with the `"4"` generation (`data-model-cleanups` Decision 2). It survives as **fixture and test support**, compiled only under `#if DEBUG || ASTERISM_PERFORMANCE_TESTING` like the fixtures that call it@@ -99,29 +102,38 @@ that no longer exist. The V3 → V4 sidecar and completion pass (`V4Migration.buildSidecar` / `runCompletionPass`) are deleted; the sidecar *filename* survives because the classifier reads its presence to refuse an open over a vanished store (Q19).-- **The readiness marker holds `"8"`, and the app opens two digits.**- `extensionOpenableMarkerVersion` is `"8"` — the only one the extension opens+- **The readiness marker holds `"9"`, and the app opens two digits.**+ `extensionOpenableMarkerVersion` is `"9"` — the only one the extension opens and the only one `publishReadiness` writes — while- `appOpenableMarkerVersions` is `["7", "8"]`. An *empty* store is marked ready+ `appOpenableMarkerVersions` is `["8", "9"]`. An *empty* store is marked ready at birth (Q26). A store carrying any other digit is refused, with the digit named in the message, and the recovery is the backup archive. - **The lagging arm is back**, for the first time since `data-model-cleanups`- Decision 2 retired `"4"`, `"5"` and `"6"`. `BootstrapState.markerLagging`- classifies a `"7"` store, and `act(on:)` runs: open (which converts) →- `V8PopulationPass.run` → `MembershipReconciler.run` →- `V8PopulationPass.assertComplete` → `publishReadiness` (writes `"8"`) →- `validateAndClearResidualEvidence`. The marker goes **after** the work it- certifies, so a throw anywhere above leaves `"7"` on disk and the next open- re-enters the arm. Validation runs after the marker and opens with diagnoses- rather than refusing, as the `.ready` arm does.+ **V9 substitutes rather than adds**: `"7"` is gone and `"8"` took its place.+ The table below says that is only defensible after re-verifying the whole+ population has passed the old digit, and Q2 of `drop-superseded-columns`+ records that verification (one user, every device at `"8"` on 2026-08-27).+ A `"9"` digit exists at all for a stage with no data pass because+ `openContainer` passes the migration plan for **both** roles, so the marker+ check is the only thing keeping a *dropping* stage out of the share+ extension (Q3).++ `BootstrapState.markerLagging` classifies an `"8"` store, and `act(on:)` runs:+ open (which drops the columns) → `validateStore` → `publishReadiness` (writes+ `"9"`) → `clearResidualEvidence`. **No data pass and no reconciler** (Q9):+ V8's arm ran both because V8 *added* tables and blobs something had to fill,+ and V9 only takes columns away inside `ModelContainer.init`. What certifies the+ conversion is the store validating, so validation is the gate and the marker+ goes after it — a throw leaves `"8"` on disk and the next open re-enters+ the arm over an already-converted store, which is safe because dropping columns+ that are already gone is a no-op. - The extension's refusal **forks** again, as it did before Decision 2 collapsed- it: `"7"` gets "Open Asterism to finish updating the library", anything else- gets the shipped "has not initialized" wording.+ The extension's refusal **forks**: `"8"` gets "Open Asterism to finish updating+ the library", anything else gets the shipped "has not initialized" wording. The writer is `publishReadiness`, deliberately unversioned: it always writes- the current generation, and the digit has moved three times. A nonempty+ the current generation, and the digit has moved five times (4 → 5 → 6 → 7 →+ 8 → 9), as the section below records. A nonempty unmarked store fails the open naming the state; an empty one is marked and opened — where "empty" means `LibraryRecordCounts.holdsNoReaderRecords`, which excludes the seeded work-type rows the app writes itself.@@ -174,30 +186,28 @@ that no longer exist. unopenable for no user value (Q7, Q13). `FrozenLibraryPathTests` fails if one moves. -## Adding a schema version (V8 and later)+## Adding a schema version (V9 and later) -V6 → V7 is the freshest worked example: `AsterismSchemaV6.swift` (the snapshot-frozen by `character-extraction`), `AsterismSchemaV7.swift` (live schema plus the-plan), and the suites that measure the conversion (`V5RecordedStoreTests` walks-the whole chain) and the refusal of anything older (`V4RecordedStoreTests`). What-a new version has to touch:+V8 → V9 is the freshest worked example, and the only one that has ever+**removed** anything: `AsterismSchemaV8.swift` (the snapshot frozen by+`drop-superseded-columns`), `AsterismSchemaV9.swift` (live schema plus the plan),+the suite that measures the conversion (`V8RecordedStoreTests` over+`V8RecordedStoreFixture`) and the one that refuses anything older+(`V4RecordedStoreTests`). What a new version has to touch: | Step | Where | |---|---|-| Declare the snapshot | Freeze the current live schema as `AsterismSchemaV8` proper — stored columns only, `public init() {}` — and add `AsterismSchemaV9` with the new models; every entity nested, zero top-level `@Model` |-| Add the stage | `AsterismV8MigrationPlan`'s successor: `.lightweight(fromVersion: V8, toVersion: V9)`, or a data pass run by the bootstrap if the change is not purely structural. Declaring it makes every store older than the plan's oldest schema **fail closed** — see the current-state bullet, and rewrite the fixtures that seed one |-| Extend the accepted markers | `appOpenableMarkerVersions` and `extensionOpenableMarkerVersion` (`"8"`, what `publishReadiness` writes) in `LibraryRepository+Bootstrap.swift`. **Both roles.** **Add** the new generation to the app's set rather than substituting, or every device that has not launched the new build yet fails closed. Substituting is only defensible after re-verifying the whole population has passed the old digit |+| Declare the snapshot | Freeze the current live schema as `AsterismSchemaV9` proper — stored columns and `@Relationship` macros only, `public init() {}`, no accessors — and add `AsterismSchemaV10` with the new models; every entity nested, zero top-level `@Model` |+| Add the stage | `AsterismV9MigrationPlan`'s successor: `.lightweight(fromVersion: V9, toVersion: V10)`, or a data pass run by the bootstrap if the change is not purely structural. Declaring it makes every store older than the plan's oldest schema **fail closed** — see the current-state bullet, and rewrite the fixtures that seed one |+| Extend the accepted markers | `appOpenableMarkerVersions` and `extensionOpenableMarkerVersion` (`"9"`, what `publishReadiness` writes) in `LibraryRepository+Bootstrap.swift`. **Both roles.** **Add** the new generation to the app's set rather than substituting, or every device that has not launched the new build yet fails closed. Substituting is only defensible after re-verifying the whole population has passed the old digit — `drop-superseded-columns` Q2 is what that verification looks like written down | | Classify the new state | `BootstrapState` (`LibraryRepository+BootstrapState.swift`) is an ordered match the compiler checks for exhaustiveness; a new marker generation needs a case there and an action beside it, not a guard inside the open |-| Add the upgrade path | A marker-lagging branch that runs the data pass, validates, and publishes the new marker *after* the pass commits — never before. **`multi-site-works` is the live worked example**: `BootstrapState.markerLagging` plus the `"7"` arm in `act(on:)`, with `MarkerGenerationEightTests` pinning the sequence, the failure that must leave the marker put, and both halves of the extension's fork |-| Keep the extension out | The extension opens only the current marker version. It must never migrate: it holds a shared lock, and two invocations can run concurrently |-| Extend the archive, if the schema is reader data | A new table the reader owns needs an archive generation too — `multi-site-works` is the live worked example, 6/7 → 7/8 with `BackupV7Exporter`/`BackupV7Codec` replacing the V6 pair outright — or a backup silently stops round-tripping it |+| Add the upgrade path | A marker-lagging branch that runs the data pass, validates, and publishes the new marker *after* the work it certifies — never before. **`drop-superseded-columns` is the live worked example**: `BootstrapState.markerLagging` plus the `"8"` arm in `act(on:)`, with `MarkerGenerationNineTests` pinning the sequence, the failure that must leave the marker put, and both halves of the extension's fork. A stage with no data pass still needs the digit, and validation is what certifies it |+| Keep the extension out | The extension opens only the current marker version. It must never migrate: it holds a shared lock, and two invocations can run concurrently. This matters most for a stage that **removes** — a concurrent share-sheet open mid-conversion is destructive rather than merely early |+| Extend the archive, if the schema is reader data | A new table the reader owns needs an archive generation too — `multi-site-works` is the live worked example, 6/7 → 7/8 with `BackupV7Exporter`/`BackupV7Codec` replacing the V6 pair outright — or a backup silently stops round-tripping it. A stage that only *removes* changes no wire shape: V9 re-recorded no golden, and `BackupGoldenExportTests` imports the pre-V9 archive into a V9 library to prove it | -`multi-site-works` reconstructed the marker-lagging arm from the `T-2271`-retirement commit, which was the accepted cost of Decision 2. It is live again-now, so the next bump copies it rather than digging it out of history. `specs/relational-references/` is the full worked spec for a relational bump. -**One trap cost an hour there.** A scratch `ModelContainer` built over a+**One trap cost an hour, twice.** A scratch `ModelContainer` built over a *frozen* snapshot pollutes SwiftData's global entity registry for the rest of the process: after `LibraryRepository+BackupImportGates` opened an in-memory container over `AsterismSchemaV7` (the frozen one), saves through the **live**@@ -205,6 +215,37 @@ container silently dropped the columns V8 had added — every other column persisted, so it read as "one property will not save" rather than as a schema mix-up. Any scratch container must be built over the *live* schema. +**Re-check that call site on every snapshot freeze.** It bit twice: the+`AsterismSchemaV7` spelling above, and then again at V9, where the same line+still named `AsterismSchemaV8` — live when it was written, a snapshot after the+freeze. The second time was worse than a dropped column, because V9 *removes*:+the whole test process aborted with `NSUnknownKeyException` on `Site.works`+(Q29 of `drop-superseded-columns`). Grep for `Schema(versionedSchema:` after+every freeze and confirm each hit names the new live schema.++### Recorded-store fixtures and the registry++`V8RecordedStoreFixture` seeds a store *through* the frozen snapshot, which is+the only way to get a genuinely previous-version store without committing a+binary — and it is the same registry hazard, deliberately taken. What makes it+safe is **ordering, not the schema**:++- Its `write(at:)` creates the snapshot container, seeds, saves and **releases+ it before returning**. The live container is opened only afterwards, so a+ snapshot container and a live one are never *in use* at the same time. That+ interleaving is the failure condition — Q29's call site built its scratch+ container while a live one was already open.+- `make test-core` passes `--no-parallel`, which extends the same guarantee+ across suites: nothing else is running a live container while this one holds+ the registration. Dropping the flag would abort the process rather than fail a+ test (`docs/agent-notes/testing.md`).++Underneath that, V9's stored shape is a strict subset of V8's — the stage only+removes — so a live key a stale V8 registration could not answer does not exist.+Do not rely on that at the next bump: a version that *adds* loses the subset+relation, and the ordering above becomes the only thing holding it up. Write the+next recorded-store fixture the same way and say so in its doc comment.+ **Two things are harder now than they were for V3 → V5.** CloudKit mirroring is on for *both* configurations, so a migration is no longer a@@ -219,24 +260,27 @@ precondition in `specs/retire-migration-chain/` Decision 6, not a formality. ## History — lessons for the next schema bump -### The marker digit has moved three times, and the old digits were kept until they were provably unreachable+### The marker digit has moved five times, and the old digits were kept until they were provably unreachable `"4"` (the relationship pass, `retire-migration-chain`) → `"5"`-(`configurable-work-types`) → `"6"` (`character-extraction`) → `"7"`, which is-what `publishReadiness` writes today. Each bump superseded a statement that had-read as permanent: the note said "the readiness marker holds `"5"`", then `"6"`,-and each time the *old* digit stayed in `appOpenableMarkerVersions` rather than-being replaced. That is the lesson, not the digits: a device that has not-launched the new build yet is on the old marker, and the set is what keeps it-openable.--`data-model-cleanups` then removed all three — **not** by deciding the rule was-wrong, but by establishing the population had passed them (one user, every device-on `"7"`). The order matters for the next bump: add the digit, ship it, and only-retire the predecessor once every device is known to be past it.--`V6` was likewise "the live schema" and the plan was `[V5, V6]`; both statements-were true and both moved on schedule.+(`configurable-work-types`) → `"6"` (`character-extraction`) → `"7"`+(`relational-references`) → `"8"` (`multi-site-works`) → `"9"`+(`drop-superseded-columns`), which is what `publishReadiness` writes today. Each+bump superseded a statement that had read as permanent: the note said "the+readiness marker holds `"5"`", then `"6"`, then `"7"`, then `"8"`, and each time+the *old* digit stayed in `appOpenableMarkerVersions` rather than being replaced.+That is the lesson, not the digits: a device that has not launched the new build+yet is on the old marker, and the set is what keeps it openable.++The set has been *shrunk* twice, and both times on the same grounds rather than+on a change of mind about the rule: `data-model-cleanups` removed `"4"`–`"6"`+and `drop-superseded-columns` removed `"7"`, each by establishing that the+population had passed them (one user, every device confirmed on the successor).+The order matters for the next bump: add the digit, ship it, and only retire the+predecessor once every device is known to be past it.++`V6` was likewise "the live schema" and the plan was `[V5, V6]`; so were V7 and+V8. Every one of those statements was true and every one moved on schedule. ### Nesting every entity is what makes an in-module snapshot possible @@ -256,7 +300,7 @@ snapshots are inert. zero top-level `@Model` types, and make the top-level names typealiases. The now-deleted `V4MigrationBootstrapTests` seeded genuine frozen-`AsterismSchemaV3` stores and the bootstrap migrated them in-process with no collision;-`V5RecordedStoreFixture` does the same through the frozen V5 snapshot today. That+`V8RecordedStoreFixture` does the same through the frozen V8 snapshot today. That in-process seeding is the second reason to keep the nesting: it is how a conversion test gets a store at the previous version without committing a binary fixture.
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 00bc46e..84ff30e 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -163,16 +163,19 @@ the missing key. Serial runs never overlap the containers, so they are unaffected. The message above is the shape the crash took when the mismatched key was-`Site.entries` and V3/V4 were the frozen snapshots. Both are gone —-`retire-migration-chain` deleted V3 and V4, the live schema is now V7, and the-frozen snapshots are `AsterismSchemaV5` and `AsterismSchemaV6`, which *do*-declare `Site.entries`.-The situation itself is not gone: `V5RecordedStoreFixture` opens containers over-`AsterismSchemaV5` in the same process as every suite using the live V7 classes-(`V5RecordedStoreTests`, `V5CertificationPathTests`,-`SiteRelationshipPopulationPassTests`, `StoreMetadataTests`,-`MarkerContractTests`), and V5 declares neither `Work.workTypeID` nor-`WorkTypeEntity`, so those are what a mixed registration would strand today.+`Site.entries` and V3/V4 were the frozen snapshots. Those are long gone; the live+schema is now **V9** and the one frozen snapshot is `AsterismSchemaV8`.++**The hazard got sharper at V9, because V9 is the first schema that *removes*.**+While every snapshot only ever *added*, a stale registration cost you a column+that would not save. Now the live entity is the *narrower* one, so a stale V8+registration strands a key the live classes no longer declare — `Site.works`+was the one that aborted a whole test process (Q29 of+`drop-superseded-columns`). `V8RecordedStoreFixture` opens containers over+`AsterismSchemaV8` in the same process as every suite using the live V9 classes+(`V8RecordedStoreTests`, `CertificationPathTests`, `StoreMetadataTests`,+`MarkerContractTests`), which is why its `write(at:)` releases the snapshot+container before returning — see `docs/agent-notes/schema-migration.md`. Consequences: @@ -188,8 +191,9 @@ Consequences: ~~The target is **knowingly red**, so `RUNS=3` runs once, fails and exits.~~ **Corrected 2026-08-09, by measurement** (`specs/retire-migration-chain/verification-run.md`): the target **exits 0**. A-`RUNS=1` pass took ~20 minutes then and takes **~25 minutes** since-`multi-site-works` added the membership suite (1,494 s measured 2026-08-26).+`RUNS=1` pass took ~20 minutes then, **~25 minutes** after `multi-site-works`+added the membership suite (1,494 s measured 2026-08-26), and **~21 minutes**+since `drop-superseded-columns` (1,093 s of test time measured 2026-08-28). It reported **four or five known issues** — Req 10.1's settling pass (`duplicate-reconciliation` Decision 27), Req 5.5's three diagnosis re-derivations (`library-integrity-tolerance` Decision 11), and,@@ -213,15 +217,21 @@ status is: for i in 1 2 3; do make test-performance-m4 RUNS=1 > /tmp/m4-run$i.log 2>&1 || true; done ``` -**Read the exit status, not the count of `recorded a known issue` lines.** **Nine-is the steady state since `multi-site-works`** (it was four, or five on a noisy-run); the five that joined are listed in-`specs/multi-site-works/verification-run.md` §4 and re-measured in §7, all with a-regression ceiling asserted outside the known-issue block. Task 22's review-halved Req 5.4's three arms and gated the V8 conversion passes off the arrival-tier (Decision 5 of that spec), and the count stayed at nine: the three-reconcile-shaped breaches are what a **full**-tier `reconcileAfterSync` pays, and-that tier is deliberately unconditional. Zero would mean the filter or the opt-in+**Read the exit status, not the count of `recorded a known issue` lines.**+**Eight is the steady state since `drop-superseded-columns`** — four before+`multi-site-works` (or five on a noisy run), nine after it, eight now. The eight+are Req 10.1's settling pass, Req 5.4's three capture-projection arms, Req 5.5's+three diagnosis re-derivations, and the full-tier no-op reconcile. Every one has+a regression ceiling asserted **outside** the known-issue block, so a run that+drifts further still fails.++The one that retired is Req 10.1's *observation* pass: V9 deleted+`V8PopulationPass` and gated `MembershipReconciler.heal` on the diagnosis, the+full-tier no-op reconcile fell 36× (1.07 s → 0.030 s), and the observation pass+followed it back inside its 2 s budget. See+`specs/drop-superseded-columns/verification-run.md`, and+`specs/multi-site-works/verification-run.md` §4 and §7 for the previous shape.+Zero would mean the filter or the opt-in gate stopped the suites from running at all, which is the failure mode the Makefile's no-xcbeautify comment exists for.
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 04431f6..691260f 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -29,7 +29,7 @@ | [Share Sheet Last Note](#share-sheet-last-note) | 2026-08-24 | Done | Smolspec (T-1917). A read-only "Catch up" section under the note editor on both capture sheets whenever the share resolves to a work that already exists: the work's own notes, then the nearest noted chapter before the one being shared (placed on the Chapter view's `ChapterPlacement` scale, the edited entry excluded on re-share) as `{title} · {date}` plus the full note text. Rides the T-1916 characters read, widened into one `ShareWorkContext` (Decision 2); no link into the app — the extension cannot launch its container (Q1). No schema change. | | [Multi-Site Works](#multi-site-works) | 2026-08-25 | Done — all tasks implemented and reviewed; `make test-core`, `make test-quick` and `make build` green. The `"7"`→`"8"` migration was confirmed on the real library 2026-08-27. Two open Before-Release items in `prerequisites.md`: the `test-performance-chunks` sweep has not been re-run, and `WorkDetailView`'s three-membership site line has never been eyeballed at the accessibility text sizes (TODO in `siteIdentityRow`). Req 8.6 is not fully met (Q89 amended — three full-tier labels plus the three `diagnosis-refresh` arms stay out of band, all known issues inside regression ceilings) | T-2230. A Work holds a set of site memberships (`WorkSiteMembership`: hostname, URL identity, rule UUID, Work URL) so the same story read on two sites is one Work: cross-site merge with a searchable picker, membership-aware duplicate scan with a dismissable "Not the same work" record, one interleaved chapter spine. Schema V8 is additive with a marker-gated population pass; the superseded columns survive unread until V9 (Decision 3). Rides the TitlePattern definition blob, the Entry citation blob and the `typeRaw` retirement; archive format 7/8 replaces 6/7 outright (Decision 2). Prerequisite `data-model-cleanups` merged. | | [Post-V8 Convergence](#post-v8-convergence) | 2026-08-27 | Done — research; no code | Research outcome of T-2272. Routes the four post-V8 convergence items into three sequenced feature tickets — T-2280 (schema V9: column drop, V5+V6 stage retirement, facade removal, closes the three full-tier performance known issues), T-2281 (cite rules by UUID only, no schema stage, archive 8/9; supersedes T-2055), T-2282 (read-time regime for same-UUID groups; supersedes duplicate-reconciliation Decision 4) — and answers the import gate diff in place: the SwiftData gate stays, six check families have no codec equivalent (Q1). |-| [Drop Superseded Columns](#drop-superseded-columns) | 2026-08-27 | Planned | Smolspec (T-2280), spec A of Post-V8 Convergence. Schema V9 drops the ~36 columns V8 retained unread (Work's site/identity/URL columns and `typeRaw`, `Site.works`/`Site.urlIdentityRule`, `Entry.identityKeyVersion` and the citation/provenance columns, TitlePattern's definition columns), deletes `LegacyColumns`, `V8PopulationPass` and the migration chain below V8 (plan `[V8, V9]`, marker `"9"`), inverts the citation write path onto the blob, gates `MembershipReconciler.heal` on the diagnosis, and re-measures the two full-tier known issues. Archive format unchanged. |+| [Drop Superseded Columns](#drop-superseded-columns) | 2026-08-27 | Done — the V9 device install and the pre-V9 archive export remain the owner's (`specs/drop-superseded-columns/prerequisites.md`) | Smolspec (T-2280), spec A of Post-V8 Convergence. Schema V9 drops the 38 names V8 retained unread — 36 columns plus the `Work.site`/`Site.works` relationship pair (Work's site/identity/URL columns and `typeRaw`, `Site.works`/`Site.urlIdentityRule`, `Entry.identityKeyVersion` and the citation/provenance columns, TitlePattern's definition columns), deletes `LegacyColumns`, `V8PopulationPass` and the migration chain below V8 (plan `[V8, V9]`, marker `"9"`), inverts the citation write path onto the blob, gates `MembershipReconciler.heal` on the diagnosis, and re-measures the two full-tier known issues. Archive format unchanged. | --- @@ -506,3 +506,5 @@ Smolspec (T-2280), spec A of the Post-V8 Convergence research. Schema V9 drops t - [tasks.md](drop-superseded-columns/tasks.md) - [decision_log.md](drop-superseded-columns/decision_log.md) - [prerequisites.md](drop-superseded-columns/prerequisites.md)+- [verification-run.md](drop-superseded-columns/verification-run.md)+- [implementation.md](drop-superseded-columns/implementation.md)
diff --git a/specs/drop-superseded-columns/decision_log.md b/specs/drop-superseded-columns/decision_log.mdindex f4f45fa..8739c0d 100644--- a/specs/drop-superseded-columns/decision_log.md+++ b/specs/drop-superseded-columns/decision_log.md@@ -18,3 +18,28 @@ Transit: T-2280. Parent research: `specs/post-v8-convergence/`. | Q10 | 2026-08-27 | `MembershipReconciler.heal` is gated on `worksWithoutMembershipCount > 0` rather than deleted | Heal (Req 8.1 of multi-site-works) repairs an Entry whose Work has no membership on the Entry's hostname — a state only a V7 writer produced, but a Work whose membership never arrives is still worth one repair. The gate keeps the repair and removes the whole-Entry-table walk (0.505 s at 5,000 Entries) from every no-op full-tier reconcile | | Q11 | 2026-08-27 | A nil `definitionData` orders under a constant-prefixed key, not one derived from columns | The previous `columns:` key told two broken rows apart by what the columns held; with the columns gone, two nil-blob rows hold the same nothing and one key is the truthful order. The prefix stays distinct from the `raw:` family so a nil row and an undecodable row never compare converged | | Q12 | 2026-08-27 | The performance requirement is "re-measure and state the remaining cost", not "back in band" | Half of `reconcile-noop-coherent`'s 1.09 s was the heal walk, not the citation pass; Q10 removes it from the no-op case, but a measurement decides whether the known issue retires |+| Q13 | 2026-08-28 | `Entry.init` seeds the citation blob with the default value | Req 4 asks that every code path creating an Entry writes its blob at creation. Doing it in the initializer makes that true by construction rather than at a dozen construction sites, and it keeps "a nil blob" meaning exactly one thing: a row a V8 build wrote that this one has not rewritten |+| Q14 | 2026-08-28 | The derived accessors (`chapterTitleProvenance`, `workAssignmentProvenance`, `workURLAssignmentKind`) become read-only over the blob, and writers go through `setCitations` / a new `editCitations` | Their getters have to move to the blob or every reader goes stale the moment the columns stop being written. Their *setters* cannot follow: `EntryCitations` types each arm, so a kind written without its cited rule is a state it deliberately cannot spell (Q39 of multi-site-works). `editCitations` is the read-modify-write door for a writer that owns some arms and must leave the rest alone |+| Q15 | 2026-08-28 | `LibraryRepository.snapshot` still fails closed on citation bytes that will not decode | It is the same rule the retired `FieldProvenance` combination check enforced, restated for the blob: enum tolerance is about a spelling a newer build wrote, a malformed row is a fault in the row itself, and the mapper must not invent a value for one. Q6's "an ordinary read must not throw" is about a *nil* blob, which reads as the default |+| Q16 | 2026-08-28 | `MembershipTestSupport`'s column doors are renamed `v7Columns` / `setV7Columns` rather than deleted outright | The mirror readers they existed for are gone — those suites read `primaryMembership` now — but the recorded-store fixtures still have to *seed* a pre-V8 Work and assert the conversion preserved it, and `V8PopulationPass` is still the reader on the other side. The rename states the one purpose left; both doors go with the columns |+| Q17 | 2026-08-28 | `Work.init` keeps writing `siteHostname`; only the mirror writers go | The column is an initializer parameter of a model whose stored shape is unchanged in this phase, and the V7-shaped fixtures write it deliberately. `Work.create`/`createCarrying` no longer write the site, identity or URL columns, which is what "write memberships only" was about |+| Q18 | 2026-08-28 | `GroupOrdering` reads a Work's **primary membership** per row, accepting the Q84 fan-out | The six columns it read were a mirror of exactly that row, and the mirror has no writer left. One relationship fault per Work inside `sortedWorkRows` is the cost, on a path every write takes; `MultiSiteReadPathTests`' pin records it and `make test-performance-m4` measures it |+| Q19 | 2026-08-28 | The Req 2.15 graph baseline is re-recorded, as format 4 | It was last re-recorded by `multi-site-works` for the same reason: the seeded library genuinely changed shape. The whole diff is this phase's change and nothing else — the citations moved out of Entry's seventeen columns into the blob, and `Work.site` (with the `Site.works` inverse) is empty because `Work.create` mints a membership and no more |+| Q20 | 2026-08-28 | The heal gate is Q10's counter alone, on **both** tiers, with no unconditional first-pass arm | The tier gate existed because `V8PopulationPass`'s own value guard made the full tier's work unconditional; with the pass gone there is one question left and one answer to it. The consequence is that a Work whose membership never arrives is *counted* by one refresh and healed by the next reconcile, not by the pass that first sees it — which is what "only when the diagnosis counts one" means |+| Q21 | 2026-08-28 | The V8 conversion survives in the test target as `V8Shape`, not in the package | `V8PopulationPass` has nothing to convert *from* on a live library, so it is deleted — but the suites that seed a store through a frozen V5/V6/V7 snapshot hold a genuine pre-V8 library and several are about what a V8 build left behind. `V8Shape` is that conversion and no more: no chunking, no save strategy, no self-certification. It goes with those fixtures and `LegacyColumns` at task 5 |+| Q22 | 2026-08-28 | `M4MembershipScalePerformanceTests`' population-pass budget is deleted rather than re-pointed | It measured 3.2 s of a pass that no longer ships, and the container open beside it measured a stage that no longer runs. There is no production cost left for either number to describe; the heal budget beside them stays, re-labelled for the gate it is now behind |+| Q23 | 2026-08-28 | `editCitations` leaves a nil blob nil when the edit changes nothing | The `\|\| citationsData == nil` arm made every no-op edit convert a nil-blob row. `SiteReconciler.rewriteCitations` runs over every Entry on a consolidation and rewrites nothing in the default value, so the first reconcile after the upgrade would have zeroed `entriesWithoutCitationBlobCount` — the spec's only mitigation for its headline risk (a row that lost its provenance at the drop stage). A nil blob converts when a writer has something real to write, and not before |+| Q24 | 2026-08-28 | `editCitations` **repairs** bytes that will not decode, starting from the default, rather than skipping the write | This is the behaviour of the deleted `LegacyColumns.refreshCitations` ("replaces bytes that will not decode rather than left diagnosed for ever"), restated for the blob. Skipping the write does not preserve anything — the bytes are unreadable — while the caller's *surrounding* write lands anyway: `applyManualAssignment`, `+Articles`, `+ComposedTeaching`, `DuplicateReconciler` would each save an Entry holding a Work with `.none` provenance, which is the tuple `LibraryValidator` throws on. The row loses citations nothing could read and stops being unreadable |+| Q25 | 2026-08-28 | A nil citation blob is a **report**, not a quarantine: the validator does not throw `unreadableCitations` for one | Req 19 and task 1 both say "reports the row"; the phase-1 guard quarantined the hostname instead. Refusing every write on a site recovers nothing — the provenance went at the drop stage and no write brings it back — and it takes away the reader's own repair (a re-teach, a manual assignment, an archive import), which is the only thing that does. `entriesWithoutCitationBlobCount` and the basis↔citation mismatch report are where the reader is told. Undecodable bytes still throw: those are a fault in the row, not a known consequence of the upgrade |+| Q26 | 2026-08-28 | Backup export does **not** refuse a library holding nil-blob Entries | Refusing recovers nothing: the loss happened at the nil, the rest of the row (note, rating, URL, identity key, Work link) is intact and worth exporting, and an archive of a partially-degraded library is strictly better than no archive on the one device most likely to need one. The counter and the diagnosis are where a nil blob is surfaced; `BackupArchiveProjection` is left as it is |+| Q27 | 2026-08-28 | `SiteRelationshipPopulationPass` **survives**, minus its `Work.site` loop, rather than being deleted with its test | The smolspec lists it under Deletions, but it has 22 live callers: two dozen seeders write straight through `saveStrategy.save` and then need `Entry.site` and `WorkSiteMembership.site` pinned to the `SiteResolutionOrder` winner. Only the third of it that filled `Work.site` had no subject left. Deleting the other two thirds would inline the same row selection at every caller — the duplication the pass's own doc comment argues against, and the one `ToleratedStateFixture` depends on for its duplicate-Site-row kind. Its dedicated suite *is* deleted: every case there was about the V5-recorded conversion or the `Work.site` column, and `WriteSiteRelationshipTests` already pins the winner-selection property |+| Q28 | 2026-08-28 | The `Work.typeRaw` test surface collapses into the work-type identity rather than being kept alive some other way | Six suites seeded a pre-feature type spelling to assert it was unread: `carriedTypeFollowsTheIdentity`'s disagreeing half, `unrecognisedCarriedValueSurvivesASave`, `unrecognisedRawTypeReadsAsUntyped`, `legacyCarrierPropagatesNothing`, the `unrecognised` arm of the carrier gate and the parameterised `typeRaw` sweeps. Every one of them derived to *untyped*, which is the state a Work with no `workTypeID` is in, so what is left after the column goes is the assertion that already held. `WorkTypeColumns` becomes `workTypeIdentities(of:) -> [UUID?]`: a struct of one field named "columns" would be a worse name than the thing it wraps |+| Q29 | 2026-08-28 | `LibraryRepository+BackupImportGates`' scratch container is re-pointed at the live schema, and that is a **correctness** fix, not a rename | It built its in-memory validation container over `AsterismSchemaV8`, which was the live schema and is now the frozen snapshot. `schema-migration.md` records what that costs: a scratch container over a snapshot wins SwiftData's name-keyed entity registry for the rest of the process. While snapshots only *added*, the symptom was saves silently dropping a column; with V9 **removing**, it aborts the whole test process with `NSUnknownKeyException` on `Site.works`. Every future snapshot freeze has to re-check this call site |+| Q30 | 2026-08-28 | The Req 2.15 graph baseline is re-recorded a second time in phase 2, format 4 → 5 | Q19's reasoning, applied again to the half of the change Q19 could not cover. Phase 1 moved the citations out of Entry's columns into the blob while the columns were still in the store, so the dump still had a field for each; phase 2 drops them, and a field that no longer exists cannot be recorded as empty. The whole diff is the dropped columns and the `Site.works` array `inverse-site` no longer lists |+| Q31 | 2026-08-28 | The V8 → V9 stage is exercised **host-only**, with `cloudKitDatabase: .none`; the gated device install stays the only CloudKit probe | Accepted on two facts. The stage is pure removal — no new entity, no new relationship, no renamed attribute — and the mirror schema is derived from the same model, so there is nothing for a `.private` container to publish that the local one does not already describe. And the probe a `.private` scratch container would give is not the one that matters: `retire-migration-chain`'s P1 result records that such a container constructs without an entitlement and never syncs, so it would prove the container builds and nothing about the mirror. What is actually at risk — a V8 device that keeps writing dropped fields while the other has migrated — is a two-device state no host test can hold, and `prerequisites.md` orders the install to close it |+| Q32 | 2026-08-28 | A **nil** citation blob does not make an Entry's surviving Work link a tuple failure | Found by task 6's review: the smolspec's headline risk says a nil-blob row keeps "its note, rating, URL, identity key or Work link", and the validator's own comment says the row "is intact apart from its provenance" — but `validateAssignment`'s `.none` arm required `work == nil`, so the first V9 open on a device holding one extension capture would have quarantined that hostname. That is exactly the outcome Q25 refuses, and for the same reason: the assignment provenance is what the drop stage took, no write on the site brings it back, and the quarantine withdraws the manual re-assignment that is the only repair. The relaxation is narrow — bytes that *decode* to `.none` beside a Work are still a partial write and still throw, because a writer that recorded the assignment recorded how |+| Q33 | 2026-08-28 | A regression floor drawn under a breach is **deleted or tightened** when the breach closes, not left where it stands | Task 9's instruction that "every ceiling outside the blocks stays" is the house rule against *raising* a bound to make a run pass. It does not ask for a floor 66× above the number it guards. `reconcile-noop-coherent` fell from 1.06–1.12 s to 0.0302 s, so its 2 s floor comes down to 100 ms — ~3.3× the median-of-20, which is deliberately **looser** than the 1.4–2× its neighbours carry, because those bounds sit over second-scale measurements and at the 30 ms scale a scheduling hiccup is a proportionally larger share of the sample; `duplicate-observation-pass` is back inside its 2 s budget, so the 4 s floor under that breach goes entirely — a floor looser than the budget asserted beside it asserts nothing. Every budget and every ceiling over a still-breached path is untouched |+| Q34 | 2026-08-28 | `reconcile-noop-arrival`'s `× 4` ratio against the full tier is deleted, not re-scaled | It pinned the gap Decision 5 of `multi-site-works` opened by gating the two whole-library conversion passes off the arrival tier: 0.030 s against 1.07 s. V9 deletes both passes, so the two tiers now measure the same thing — 0.0301 s against 0.0302 s in the run that found this — and the ratio fails on correct code. Re-scaling it to some smaller factor would be inventing a property rather than pinning one; what the tiers still choose between is the duplicate phase, and `duplicate-arrival-pass-gated` is the label that measures that. The shared absolute ceiling still guards both |+| Q35 | 2026-08-28 | `BootstrapState.belowV5` keeps its name although the plan's floor is now V8 | The case is the refusal for a store beneath the floor, and the floor moved to V8 with the `[V8, V9]` plan — but the *reading* that reaches it has not moved: `StoreMetadata` positively identifies only a below-V5 store, deliberately, because `NSStoreModelVersionIdentifiers` is advisory and refusing on anything unrecognised would lock the owner's only library. A V5–V7 store is refused a row later, by its retired marker digit, with the same recovery. Renaming to `belowFloor` would ripple through `BootstrapClassifierTests`, `BootstrapStateCoverageTests` and `BootstrapActionTests` for a case whose *input* is still exactly "below V5"; the gap is recorded in the case doc instead |+| Q36 | 2026-08-28 | The golden 7/8 archive fixture was **not** re-recorded, although the smolspec's format requirement anticipated it | The requirement's parenthetical assumed the golden fixtures would need re-recording "once the golden fixtures are re-recorded". They did not: V9 removes columns from the *store*, and `BackupV7Codec` reads and writes the archive's own record types, which this phase does not touch. Re-recording a fixture whose format is provably unchanged would have destroyed the one artefact that proves it — a byte sequence a V8 build produced. What covers the requirement instead is `BackupGoldenExportTests`, which re-exports the V8-recorded archive on a V9 build and compares bytes; `BackupGroupRoundTripTests` exercises the round trip over freshly built graphs and never held the golden bytes |+| Q37 | 2026-08-28 | Two suites are renamed rather than left carrying a retired digit: `MarkerGenerationEightTests` → `MarkerGenerationNineTests`, `V5CertificationPathTests` → `CertificationPathTests` | The first is versioned by what it certifies and the generation moved, so the name follows the digit as it did at V8. The second is **de**-versioned instead: it is about the certification sequence — classify, act, publish, clear — which is the same on every arm and outlived three schema versions already, so pinning it to a digit is what made it need renaming in the first place |
diff --git a/specs/drop-superseded-columns/implementation.md b/specs/drop-superseded-columns/implementation.mdnew file mode 100644index 0000000..685ead4--- /dev/null+++ b/specs/drop-superseded-columns/implementation.md@@ -0,0 +1,367 @@+# Implementation: Drop Superseded Columns (Schema V9)++Sixteen commits on `T-2280/drop-superseded-columns`, 150 files, +4,462 / −7,244,+in three phases: blob-only write paths, the schema drop, deletions and+verification.++## Beginner Level++### What Changed / What This Does++The app keeps its library in a local database (SwiftData, Apple's wrapper over+Core Data), whose *tables* have *columns* like a spreadsheet has named fields.++Earlier features moved three facts out of columns into better homes: a Work's+site presence became a set of *membership* rows (so one story read on two sites+is one Work); an Entry's seventeen citation columns — which parsing rules+produced its title and at which version — became one JSON blob; a title+pattern's ten definition columns became another blob.++The previous release (V8) *added* those homes but **kept the old columns**,+because a migration that dropped them would have destroyed the originals before+the one-time conversion pass could copy them. This branch is the second half: the+columns are gone, the code mirroring data into them is gone, and the conversion+pass is gone with them. Nothing the reader sees changes.++### Why It Matters++Two copies of one fact is a standing tax: every writer updates both, every reader+must know which is authoritative, and the code keeping them in step ran on every+launch and every iCloud sync — 1.07 s per pass over a 5,000-chapter library. That+pass now costs **0.030 s** (36× cheaper), and ten other measured operations got+14–27% faster because each row is seventeen columns lighter to fetch.++The catch: the removal is **irreversible in place**, and an older build installed+afterwards cannot open what it finds. `prerequisites.md` makes the owner export a+backup archive *before* upgrading — restored onto a freshly-installed old build,+that archive is the only rollback.++### Key Concepts++- **Schema version** — a numbered snapshot of the database's shape. V8 is frozen+ as a historical record; V9 is live (`AsterismSchemaV9.swift`).+- **Migration stage** — the recipe for converting between two versions. Here, one+ `.lightweight` stage run inside `ModelContainer.init`; *lightweight* means+ SwiftData infers the change from the two shapes.+- **Readiness marker** — a one-digit file beside the database naming the+ generation the library is at. The app accepts `"8"` (and upgrades it) or `"9"`;+ the share extension accepts `"9"` only. That digit is the only thing stopping a+ share-sheet capture from running a destructive conversion behind your back.+- **Blob** — a value serialised to JSON in one column, instead of one column per+ field.+- **Known issue** — a performance test that measures a breach, records it and+ still passes, keeping the honest number visible instead of raising the budget.++---++## Intermediate Level++### Changes Overview++| Area | Change |+|---|---|+| Schema | `AsterismSchemaV8.swift` frozen as stored-columns-only; live models move into `AsterismSchemaV9`; `AsterismSchemaV5/V6/V7.swift` deleted |+| Plan | `AsterismV9MigrationPlan = [V8, V9]`, one `.lightweight` stage — the first in the project's history that **removes** anything (36 attributes + the `Work.site` ↔ `Site.works` inverse pair — 38 names) |+| Markers | `extensionOpenableMarkerVersion = "9"`, `appOpenableMarkerVersions = ["8", "9"]` — a *substitution*; `"7"` retired |+| Write paths | Every citation writer builds an `EntryCitations` and calls `setCitations`/`editCitations`; `LegacyColumns` (≈390 lines of mirror code) deleted |+| Reconcile | `V8PopulationPass` deleted; `MembershipReconciler.heal` gated on `worksWithoutMembershipCount > 0`, on both tiers |+| Reads | `GroupOrdering` faults one `Work.primaryMembership` per row where six columns sat |+| Tests | `V5/V6/V7RecordedStoreFixture` + suites, `V8PopulationPassTests`, `MembershipColumnMirrorTests`, `SiteRelationshipPopulationPassTests`, `MigratedStoreCitationRewriteTests` deleted; `V8RecordedStoreFixture`/`Tests` (1,119 lines) and `CitationSurgery.swift` added |+| Docs | `schema-migration.md` rewritten to V9; `CLAUDE.md` re-counted; `verification-run.md` added |++### Implementation Approach++The order is forced by one fact: **the stage runs inside `ModelContainer.init`,+so no code can read a column after the conversion.** Everything had to stop+reading and writing the columns in a build that still had them; only then could a+second build drop them.++- **Phase 1 — invert the write paths, columns still present.** Writers used to+ set the columns and `LegacyColumns.refreshCitations` derived the blob from+ them. Now each writer constructs the citation and writes the blob directly+ (`LibraryRepository.swift:1322-1420`, `+ComposedTeaching`, `+Articles`,+ `+ReparseCapture`, `+DuplicateResolution`, `DuplicateReconciler`,+ `+ConfirmImport`, `SiteReconciler.rewriteCitations`). `Entry.init` seeds the+ default blob so nil means exactly one thing (Q13), and the derived provenance+ properties become read-only views with `editCitations` as the one write door+ (Q14).+- **Phase 2 — freeze and drop.** `AsterismSchemaV8` keeps stored columns and+ `@Relationship` macros only; live classes move under+ `extension AsterismSchemaV9`, top-level names stay typealiases. `Site.works`+ goes with `Work.site` because it is declared as its inverse.+- **Phase 3 — deletions and measurement.** The V7-shaped test facade, the dead+ `validateStoreStrictly` gate, then two `make test-performance-m4` runs.++The bootstrap arm shrank to the smallest thing that can certify a destructive+stage: open (which converts) → `validateStore` → `publishReadiness("9")` →+`clearResidualEvidence`. No data pass, no reconciler (Q9). Validation is the+certificate; a throw leaves `"8"` on disk and the next open re-enters the arm over+an already-converted store, which is safe because dropping columns that are+already gone is a no-op.++### Trade-offs++- **A read fan-out bought a write simplification.**+ `GroupOrdering.sortedWorkRows` now faults one to-one relationship per Work+ where six columns sat, on a path every write takes (Q18). Measured:+ `backup-projection-duplicate-free` rose **14%** (1.301 s → 1.485/1.484 s over+ two runs). No budget covers it; the smolspec said in advance this would be+ reported, not bounded.+- **A one-way door for one user.** Q1 accepted a smolspec for a destructive+ schema change on two facts: a pre-V9 archive restores onto a kept V8 build, and+ the population is one person. Both must keep holding.+- **Provenance loss is possible and accepted.** An Entry captured through the+ share sheet after the last V8 launch has a nil blob when V9 opens, and the drop+ takes its provenance. Q4 rejected a pre-conversion pass — it needs a second+ container over the frozen snapshot, which poisons SwiftData's entity registry.+- **`"7"` substituted rather than added**, defensible only because every device+ was confirmed at `"8"` on 2026-08-27 (Q2); and+ **`SiteRelationshipPopulationPass` survived the deletion list** with 22 live+ callers, losing only its `Work.site` loop and its suite (Q27).++---++## Expert Level++### Technical Deep Dive++**The registry hazard, and the bug it caused.** SwiftData's entity registry is+process-global and keyed by entity *name*, so a scratch `ModelContainer` over a+frozen snapshot wins that registration for the rest of the process.+`LibraryRepository+BackupImportGates.swift:22-40` built its in-memory validation+container over `AsterismSchemaV8` — live when written, a snapshot after the+freeze. While snapshots only *added*, the symptom was saves silently dropping a+column; with V9 **removing**, the whole test process aborts with+`NSUnknownKeyException` on `Site.works` (Q29).++`V8RecordedStoreFixture` takes the same hazard deliberately and survives by+**ordering**: `write(at:)` creates the snapshot container, seeds, saves and+releases it *before returning*, so snapshot and live containers are never in use+at once; `--no-parallel` extends that across suites. V9's shape being a strict+subset of V8's is belt-and-braces — the next version that *adds* loses that.++**`editCitations` is a three-way state machine, not a setter**+(`Models.swift:238-278`). *Decodable*: apply, value-guard, write on change.+*Undecodable*: apply over the default and **always** write, so the row stops+being unreadable (Q24) — skipping it would land the caller's surrounding write+with `.none` provenance, which the validator refuses. *Nil*: write only if+something changed (Q23). That last arm is load-bearing:+`SiteReconciler.rewriteCitations` walks every Entry on a consolidation, so an+`|| citationsData == nil` clause would have zeroed+`entriesWithoutCitationBlobCount` on the first reconcile after the upgrade.++**The validator's exceptions are for absence, never corruption.** A nil blob is a+report, not a quarantine (Q25): `LibraryValidator.swift:802-820` throws+`unreadableCitations` for bytes that will not decode and stays silent about bytes+that are absent. Task 6's review found the second half — `validateAssignment`'s+`.none` arm required `work == nil`, so the first V9 open on a device holding one+extension capture would have quarantined that hostname. Now+`work == nil || citationsAreAbsent` (`:1085-1115`, Q32).++**Heal gating collapsed to one question.** `multi-site-works` kept the conversion+passes unconditional on the full tier because the population pass's *value* guard+saw staleness no counter could count. With the pass deleted that reason+evaporates, so `worksWithoutMembership` gates the heal on **both** tiers (Q20,+`LibraryRepository.swift:182-196, 274-286`). Deliberate consequence: a Work whose+membership never arrives is counted by one refresh and healed by the *next*+reconcile. Dedupe stays unconditional — that is the residual 30 ms.++**The frozen snapshot is frozen by reference, not only by file**+(`AsterismSchemaV8.swift:34-62`): its stored shape includes the top-level value+types it names and every enum raw value baked into a default. `WorkType` is the+sharp one — V9 dropped `Work.typeRaw`, so the snapshot's default is that enum's+**only** live referent, and it looks unused to every tool.++### Architecture Impact++- One home per fact, enforced by types rather than discipline. `EntryCitations`+ types each arm, so "a provenance kind without its cited rule" is unspellable+ (Q14) — which is why the derived accessors keep their getters and lose their+ setters.+- Every bootstrap arm now publishes its marker after the work it certifies, and+ `clearResidualEvidence` was split out of `validateAndClearResidualEvidence` so+ the lagging arm runs it after the publish (`+Bootstrap.swift:494-520`).+- The export projection got *stricter*: three raw enum guards retired because a+ kind this build has no case for is now a blob that will not decode+ (`BackupArchiveProjection.swift:193-220`).+- Test seams changed shape: `MembershipTestSupport`'s column doors are gone, and+ `CitationSurgery.swift` centralises the deliberate-corruption helpers.++### Potential Issues++1. **CloudKit is unexercised.** Q31 accepts host-only verification: the stage is+ pure removal, and a `.private` scratch container without an entitlement+ constructs but never syncs. The real risk — a V8 device still writing dropped+ fields while the other has migrated — is a two-device state no host test can+ hold; `prerequisites.md` closes it procedurally.+2. **The rollback is a reinstall** — installing V8 over V9 refuses the library on+ both the digit and the plan.+3. **A post-V9 archive launders the loss.** `Entry.init` seeds the default blob+ and export does not refuse nil-blob rows (Q26), so a post-V9 export → import+ round trip hands them *default* citations and zeroes the counter. Read+ `entriesWithoutCitationBlobCount` **before** any post-V9 export.+4. **`reconcile-noop-coherent` is still 3.0× its ceiling** (0.0296–0.0302 s+ against 10 ms), now entirely membership dedupe fetching 1,000 rows per pass.+ Recorded as a T-2280 comment; no ticket.+5. **Two stale doc comments survive** — see Completeness Assessment.+6. **The next bump has a known trap**: declaring a V9 → V10 stage makes every+ V8-seeded fixture unopenable (134504) — `schema-migration.md:189-207`.++---++## Completeness Assessment++**Fully implemented**, with one recorded divergence and two stale comments.++1. **Marker-`"8"` library opens intact; `"9"` published only after validation** —+ Fully. `V8RecordedStoreTests` (552 lines) takes a genuinely 8.0.0-recorded+ store through the stage and asserts every Work, membership, Entry field, blob,+ TitlePattern, URL rule, Site, Character, suppression and `WorkDistinctPair`+ survives; `MarkerGenerationNineTests:94-195` pins the sequence and that a+ validation failure leaves `"8"` in place.+2. **Extension opens `"9"` only** — Fully; both halves of the fork at+ `MarkerGenerationNineTests:229-270`.+3. **V9 contains none of the listed columns** — Fully.+ `ModelContractTests.droppedColumns` (`:228-300`) pins their absence from+ `Schema(...).entities`, and a second test pins that the only file naming them+ is the frozen snapshot.+4. **Every Entry creation writes its blob; nil reads as the default and is+ reported without throwing** — Fully (Q13 seed, `citationValues` /+ `editCitations`, validator report).+5. **Plan `[V8, V9]` and the listed deletions** — Fully **except one documented+ divergence**: `SiteRelationshipPopulationPass` survives minus its `Work.site`+ loop (**Q27**, 22 live callers; smolspec amended in-branch), its 773-line+ suite deleted. `AsterismSchemaV5/V6/V7`, `V8PopulationPass`, the `"7"` arm,+ `LegacyColumns`, `validateStoreStrictly` and the Q101 facade are gone by grep.+6. **7/8 archive unchanged; a V8-exported archive imports into V9** — Fully, and+ more strongly than asked:+ `BackupGoldenExportTests.recordedArchiveRestoresIntoAV9Library` imports the+ pre-V9 golden bytes into a real store, validates strict and re-exports+ **byte-identical**. No golden re-recorded.+7. **Heal gated on the diagnosis, dedupe unconditional** — Fully, via+ `MembershipReconciler.run(…, heals:)`; on both tiers per **Q20** (a deliberate+ divergence from `multi-site-works` Decision 5's tier shape).+8. **`make test-core`/`test-quick` clean; `test-performance-m4` exits 0, both+ blocks re-measured** — Fully per `verification-run.md` (two runs, exit 0, 8+ known issues; observation pass retired at 1.008–1.025 s;+ `reconcile-noop-coherent` kept at 0.0296–0.0302 s, comment naming the residual+ cost). **No test was run in this review**: that claim rests on the task list.+9. **`entriesWithoutCitationBlobCount` kept as a report** — Fully in behaviour+ (`LibraryRepository.swift:182-196` calls both blob counters "reports, not+ gates").++**Previously flagged, now resolved:** two stale doc comments (`LibraryDiagnostics.swift` on the+counters, `LibraryValidator.swift` on the unrecognised spelling) named `V8PopulationPass` and+`LegacyColumns` after both were deleted; the pre-push review fix (`f3e2565`) retargeted them.++**Not verifiable here, by design**: the device install, the two-device update+window and the only CloudKit probe this change gets (Q31,+`verification-run.md` §6) — all owner steps under `CLAUDE.md`'s device rule.++## Important Changes++1. **`Models.swift` — `Entry.init` seeds the blob; `editCitations` is the single+ read-modify-write door** (`:177-185`, `:238-278`, `:202-230`). This is what+ makes "nil blob" a diagnosis rather than an ambiguity; get its nil arm wrong+ and the spec's only mitigation for its headline risk disappears. Takeaway: a+ nullable column carrying diagnostic meaning must not be materialised by a+ no-op write. Rationale: **Q13, Q14, Q23, Q24**.+2. **`+Bootstrap.swift` — the `"8"` arm is open → validate → publish → clear**+ (`:186-215`, `:494-520`, `:537-560`). With no pass to self-certify, validation+ *is* the certificate and marker ordering is the whole safety argument;+ `clearResidualEvidence` was split out so a failed publish keeps the evidence.+ Takeaway: publish the marker after the work it certifies, and make re-entry a+ no-op. Rationale: **Q3, Q9** + inline.+3. **`LibraryRepository.swift` + `MembershipReconciler.swift` — conversion passes+ deleted, heal gated on the diagnosis on both tiers** (`:182-196, 274-286, 344,+ 365-382, 523-530`; `MembershipReconciler.swift:73-99, 139-149`). The 36× fall+ (1.07 s → 0.0296 s), and it changes *when* a membership-less Work is repaired.+ Takeaway: when the reason for an unconditional pass is deleted, delete the+ tier distinction with it. Rationale: **Q10, Q20**; `verification-run.md` §2.1.+4. **`+BackupImportGates.swift:22-40` — scratch container re-pointed at the live+ schema.** A correctness fix disguised as a rename: over the frozen snapshot it+ aborts the whole test process with `NSUnknownKeyException` on `Site.works`.+ Takeaway: grep `Schema(versionedSchema:` after every freeze. Rationale:+ **Q29**; `schema-migration.md:210-224` records that it has bitten twice.+5. **`LibraryValidator.swift` — a nil blob never quarantines a hostname, even+ beside a surviving Work link** (`:802-820`, `:1085-1115`). Without the second+ relaxation the first V9 open on a device holding one share-sheet capture would+ have withdrawn the manual re-assignment that is the row's only repair.+ Takeaway: "absent because a known migration took it" and "corrupt" are the+ same value and a different fact. Rationale: **Q25, Q32** (found by review).+6. **`AsterismSchemaV8.swift` + `V8RecordedStoreFixture.swift` — the frozen+ snapshot and a store genuinely recorded at 8.0.0 seeded through it.** The only+ host-side evidence the stage preserves live data; populated on *both* sides of+ every supersession, with three Entries (the seventeen citation columns cannot+ legally coexist on one row) including a deliberately nil-blob Entry C.+ Takeaway: seed the previous version in-process through its frozen snapshot and+ release that container first. Rationale: **Q21**, `schema-migration.md:226-247`.+7. **`GroupOrdering.swift:494-560, 687-705` — one `primaryMembership` fault per+ Work, shared between both readers.** The unbudgeted read-path regression, and+ the one measurement that got worse. `primaryMembership` uses `min` rather than+ sorting (`Models.swift:367-375`), and the row is passed into `authoredContent`+ so `representativeComponents` pays once. Takeaway: hoist a replacement+ relationship fault to the outermost caller. Rationale: **Q18**; §4.2.++## Learnings++- **Freeze-by-reference is a real hazard.** `AsterismSchemaV8.swift:34-62`+ enumerates what a frozen schema silently depends on — top-level value types it+ names, and every enum raw value baked into a default. Worth copying into any+ project with in-module schema snapshots.+- **A value-guarded RMW door with explicit repair semantics.**+ `Entry.editCitations` is a reusable shape for "read a blob, mutate one arm,+ write back" where corrupt input is repaired and absent input stays absent.+- **Tombstone comments where code was deleted** —+ `MembershipReconciler.swift:142-149`, `LibraryRepository.swift:523-530`,+ `SiteRelationshipPopulationPass.swift:64-75`, `GroupOrdering.swift:546-556`.+ Cheap, and it stops the question being reopened.+- **Test-only surgery as a named module.** `CitationSurgery.swift` replaces+ twenty column pokes with named helpers (`citingNameTitleVersion(_:)`,+ `citingNoNameTitle`, `citingPatternID(_:)`) — the move when a typed value+ object removes a test's ability to spell an illegal state directly.+- **A floor drawn under a breach is deleted or tightened when the breach closes**+ (**Q33**) — the symmetric half of "never raise a ceiling to make a run pass".+- **Two runs, not one.** `verification-run.md` §4.2 caught a phantom 18%+ regression (`recent-publication-duplicate-free`, 1.029 s in run A, 0.886 s in+ run B — run A's own minimum) and records it rather than correcting it quietly.++## Decision Rationale++| Choice | Reason | Source |+|---|---|---|+| Smolspec despite a destructive stage | Pre-V9 archive restores on a kept V8 build; population is one user | Q1 |+| Plan `[V8, V9]`; V5/V6/V7 retire together | Every device confirmed at marker `"8"` — the retire-migration-chain Decision 6 precondition for every stage below V8 | Q2 |+| Publish `"9"` although V9 has no data pass | `openContainer` passes the plan for both roles; only the marker keeps a destructive stage out of the extension | Q3 |+| No pre-conversion pass for nil blobs | Needs a second container over the frozen snapshot — rejected by multi-site-works Decision 3, and it poisons the entity registry | Q4 |+| `refreshCitations` deleted, not kept blob-only | It exists to derive the blob from columns that no longer exist | Q5 |+| Nil blob reads as the `.rawURL` default and is reported | Throwing on an ordinary read makes one exposed row fail a screen | Q6 |+| `duplicate-settling-pass` stays a known issue | Predates V8, unattributed (T-2093); V9 only recovers the mirror's headroom | Q7 |+| The `"8"` arm runs no reconciler | The launch reconcile runs moments later on the same store | Q9 |+| Heal gated rather than deleted | A Work whose membership never arrives is still worth one repair | Q10 |+| Nil `definitionData` orders under a constant key | Two nil rows hold the same nothing; prefix kept distinct from `raw:` | Q11 |+| "Re-measure and state the cost", not "back in band" | Half the 1.09 s was the heal walk; a measurement decides whether the block retires | Q12 |+| Provenance accessors read-only over the blob | Typed arms make "a kind without its cited rule" unspellable | Q14 |+| `snapshot` still fails closed on undecodable bytes | Enum tolerance is for a newer build's spelling; a malformed row is a fault in the row | Q15 |+| `Work.init` keeps `siteHostname` through phase 1 | The stored shape is unchanged there, and the V7-shaped fixtures write it deliberately | Q17 |+| Graph baseline re-recorded twice (format 4 → 5) | Phase 1 emptied the fields; phase 2 removed them, and a field that no longer exists cannot be recorded as empty | Q19, Q30 |+| `V8Shape` lives in the test target, then goes | Nothing to convert from on a live library, but the V5/V6/V7 fixtures needed the conversion until they were deleted | Q21 |+| Population-pass budget deleted, not re-pointed | No production cost is left for the number to describe | Q22 |+| Backup export does not refuse nil-blob rows | Refusing recovers nothing; a partially-degraded archive beats no archive | Q26 |+| `Work.typeRaw` test surface collapses into work-type identity | All six suites derived to *untyped*, which is what a Work with no `workTypeID` already is | Q28 |+| Host-only verification of the stage | Pure removal, and an unentitled `.private` container proves only that it constructs | Q31 |+| `BootstrapState.belowV5` keeps its name | The *reading* that reaches it is still "below V5"; `StoreMetadata` positively identifies nothing else | Q35 |+| `reconcile-noop-arrival`'s `× 4` ratio deleted, not re-scaled | Both tiers now measure the same thing; re-scaling would invent a property rather than pin one | Q34 |+| Golden archives not re-recorded | A stage that only removes changes no wire shape | smolspec Req 6; `schema-migration.md:206` |+| `clearResidualEvidence` split out | A publish failing on a full disk must leave the recovery evidence in place | inline, `+Bootstrap.swift:508-518` |+| Residuals get T-2280 comments, no ticket | Gating the dedupe phases is a design decision this spec does not take | `verification-run.md` §2.1, §4.2 |++**Open questions for the author**++1. The two residuals (30 ms unconditional dedupe; +14% export projection) are+ T-2280 follow-up comments and `post-v8-convergence` research candidates with+ no ticket. Deliberate ahead of T-2281/T-2282, or should one be opened before+ merge?+2. `WorkType`'s raw spelling is now referenced only by the frozen snapshot's+ default. `ModelContractTests` pins *names in files*; is a direct pin on that+ raw value wanted, or is the doc comment sufficient?
diff --git a/specs/drop-superseded-columns/prerequisites.md b/specs/drop-superseded-columns/prerequisites.mdindex e24e680..07dad81 100644--- a/specs/drop-superseded-columns/prerequisites.md+++ b/specs/drop-superseded-columns/prerequisites.md@@ -4,14 +4,15 @@ These steps are the owner's. Every device install here is gated by `CLAUDE.md`'s ## Before the first V9 install, in this order - [ ] On each device, open the V8 build of both apps (`Personal`, `Development`) once and let it settle. The launch reconcile fills the citation blob of every Entry captured through the share sheet since the previous launch; anything still nil-blob when V9 opens loses its citation provenance.-- [ ] Export a 7/8 archive from both apps on every device. The V9 build drops the columns on first open; the archive on a kept V8 build is the only rollback.-- [ ] Keep a V8 build installable (the pre-feature commit).+- [ ] Export a 7/8 archive from both apps on every device. The V9 build drops the columns on first open, and that is irreversible in place: the store is left carrying marker `"9"` and a V9 shape, which the V8 build refuses on both counts (its `appOpenableMarkerVersions` is `["7", "8"]`, so `"9"` falls to the unrecognised row and is refused naming the digit, and its plan is `[V5, V6, V7, V8]`, which has no stage that reaches a V9 store). **Installing V8 over V9 rolls nothing back** — it fails to open the library it finds. Rolling back means deleting the app on the device (or removing its App Group store), installing V8 so it creates a fresh library at `"8"`, and importing the **pre-V9** archive into that. So the archive taken here is the rollback, and it has to be taken before the V9 install.+- [ ] Keep a V8 build installable (the pre-feature commit) — it is the target of that restore, not a downgrade path over an existing V9 store. - [ ] Do not use the share sheet on any device between the export and the V9 install. - [ ] Confirm each device's library marker reads `"8"` (the app refuses anything else, naming it). ## Install - [ ] Approved device check: install `Personal` over the real library on one phone, confirm the first open completes the `"8"` → `"9"` migration and the library reads intact, then update the second device **before either device opens the library again** (multi-site-works Q14: a V8 device that keeps syncing after another has migrated writes columns V9 ignores and nil-blob Entries V9 reports).-- [ ] After both devices are on V9, check the library diagnosis for Entries without a citation blob; a non-zero count is the upgrade window's residue and the archive is the repair.+- [ ] After both devices are on V9, check the library diagnosis for Entries without a citation blob (`entriesWithoutCitationBlobCount`). **Read the counter before taking any post-V9 archive.** `Entry.init` seeds the blob with the default value (Q13) and export does not refuse a nil-blob row (Q26), so a post-V9 export → import round trip hands every nil-blob row the *default* citations and zeroes the counter — the loss is then invisible and unrepairable. The archive that repairs one of these rows is therefore the **pre-V9** 7/8 archive specifically, taken above.+- [ ] A non-zero count is the upgrade window's residue. Such a row is a report, not a quarantine: it keeps its note, rating, URL, identity key and its **Work link**, and the validator does not fail the assignment or quarantine the hostname for it (Q32). What it lost is the provenance — which rules parsed it, what the identity was based on. Two repairs: import the pre-V9 archive, or re-assign the Entry manually (a re-teach or a manual Work assignment rewrites the blob at the write site). ## Before Release-- [ ] `make test-performance-m4` (host-only, safe, ~25 min) run and its numbers recorded in `verification-run.md`, with the fate of the two `withKnownIssue` blocks stated.+- [x] `make test-performance-m4` (host-only, safe, ~21 min) run and its numbers recorded in [verification-run.md](verification-run.md), with the fate of the two `withKnownIssue` blocks stated: `reconcile-noop-coherent` stays a known issue at ~0.030 s (Q33), `duplicate-observation-pass` is retired at 1.01 s.
diff --git a/specs/drop-superseded-columns/smolspec.md b/specs/drop-superseded-columns/smolspec.mdindex 234d3ee..1e607fc 100644--- a/specs/drop-superseded-columns/smolspec.md+++ b/specs/drop-superseded-columns/smolspec.md@@ -10,9 +10,9 @@ Schema V8 (`specs/multi-site-works/`) moved Work identity into `WorkSiteMembersh - The system MUST open a marker-`"8"` library on the V9 build with every Work, membership, Entry (note, rating, raw URL, identity key, Work link), citation blob, TitlePattern definition, URL rule, Site, Character and suppression intact, and MUST publish marker `"9"` only after the store validates. - The share extension MUST open marker `"9"` only, refusing `"8"` with the existing "Open Asterism to finish updating the library" message. This is what keeps the V8→V9 stage out of the extension, since `openContainer` passes the migration plan for both roles.-- The V9 schema MUST contain none of: `Work.siteHostname`, `Work.site`, `Work.urlIdentity`, `Work.urlIdentityStateRaw`, `Work.urlIdentityRuleID`, `Work.urlIdentityRuleVersion`, `Work.workURLString`, `Work.typeRaw`, `Site.works`, `Site.urlIdentityRule`, `Entry.identityKeyVersion`, Entry's seventeen citation/provenance columns (`ModelContractTests.retainedColumns` lists them), TitlePattern's ten definition columns.+- The V9 schema MUST contain none of: `Work.siteHostname`, `Work.site`, `Work.urlIdentity`, `Work.urlIdentityStateRaw`, `Work.urlIdentityRuleID`, `Work.urlIdentityRuleVersion`, `Work.workURLString`, `Work.typeRaw`, `Site.works`, `Site.urlIdentityRule`, `Entry.identityKeyVersion`, Entry's seventeen citation/provenance columns, TitlePattern's ten definition columns. (`ModelContractTests.droppedColumns` is the authoritative list — 38 names: 36 attributes plus the `Work.site` / `Site.works` relationship pair. It was written as `retainedColumns` when the pin asserted the opposite claim.) - Every code path that creates an Entry MUST write its citation blob at creation, and every citation writer MUST write the blob directly. An Entry whose blob is nil MUST read as `EntryCitations` with identity `.rawURL` and nothing cited, and the validator MUST report the row (as it reports `unreadableCitations`); an ordinary read MUST NOT throw on a nil blob.-- The migration plan MUST be `[V8, V9]`, one lightweight stage. `AsterismSchemaV5/V6/V7`, `V8PopulationPass`, the `"7"` bootstrap arm, `LegacyColumns`, `SiteRelationshipPopulationPass`, `validateStoreStrictly`, and multi-site-works Q101's test-only facade MUST be deleted.+- The migration plan MUST be `[V8, V9]`, one lightweight stage. `AsterismSchemaV5/V6/V7`, `V8PopulationPass`, the `"7"` bootstrap arm, `LegacyColumns`, `SiteRelationshipPopulationPass` (overruled by Q27 — it survives minus its `Work.site` loop; only its dedicated suite is deleted), `validateStoreStrictly`, and multi-site-works Q101's test-only facade MUST be deleted. - The 7/8 backup archive format MUST be unchanged; a 7/8 archive exported by a V8 build MUST import into a V9 library (the existing `BackupGroupRoundTripTests` / `BackupImportTransactionTests` cover this once the golden fixtures are re-recorded). - `MembershipReconciler`'s heal phase MUST run only when the diagnosis counts a Work without a membership; the dedupe phases MUST keep running unconditionally. - `make test-core` and `make test-quick` MUST pass with no new warnings. `make test-performance-m4` MUST exit 0, MUST re-measure `reconcile-noop-coherent` and `duplicate-observation-pass`, and SHOULD retire both `withKnownIssue` blocks; if either stays out of band, its comment MUST name the remaining cost. `duplicate-settling-pass` stays a known issue (decision_log.md Q7).@@ -27,7 +27,7 @@ Follow the "Adding a schema version (V8 and later)" table in `docs/agent-notes/s - **Citation write path.** Today writers set the Entry columns and `LegacyColumns.refreshCitations` (`Models.swift:345-357`) encodes the blob from them. Invert: each writer builds an `EntryCitations` and calls `Entry.setCitations` (`:657`). Writers: `LibraryRepository.swift:1368,1457`; `+ComposedTeaching.swift:435,681-697,745,812` — the `:435` comparison reads the identity case's version from `citations.identity` (`EntryCitations.swift:52`); `+Articles.swift:146,152`; `+ReparseCapture.swift:336`; `+DuplicateResolution.swift:553`; `DuplicateReconciler.swift:1260`; `SiteReconciler.rewriteCitations` (`:352-357`) becomes `setCitations(citations.rewritingVersions(rewrites))` (`EntryCitations.swift:176`) so version rewrites keep landing in the blob; `+ConfirmImport.swift:560` (`applyCitations`). Capture (`LibraryRepository.swift:940-967`) and `ArchiveRecordBuilders.makeEntry` (`:135-150`) write the blob at creation. `Entry.citations` (`Models.swift:648`) returns the `.rawURL` default for nil bytes; `LibraryValidator.swift:811` reports it beside `unreadableCitations`. - **Membership and definition mirrors.** Delete `LegacyColumns` (`Models.swift:95-481`), every `refreshMembership` call (`+ConfirmImport.swift:469`, `+DuplicateResolution.swift:660`, `+WorkMerge.swift:117,439`, `+BackupImport.swift:94`, `Models.swift:1548`, `+Sites.swift:144`, `DuplicateReconciler.swift:787,1332`, `MembershipReconciler.swift:158`, `ToleratedStateFixture.swift:166`), `GroupOrdering.swift:506,669`, and the `TitlePattern.storedDefinition` fallback (`Models.swift:965`) — a nil `definitionData` becomes `unreadableTitlePattern`, and `GroupOrdering.swift:534`'s ordering key for it becomes a constant with its own prefix, distinct from the `raw:` family, so a nil row and an undecodable row never compare converged (decision_log.md Q11). Delete `MembershipTestSupport.legacyColumns`/`setLegacyColumns` (`:186-194`); their readers (`SiteRelationshipPopulationPassTests` ×9, `URLOptionalSequenceIntegrationTests` ×6, `LibraryGraphBaselineTests` ×4, `GroupProjectionBasisTests` ×3, `ComposedTeachingRepositoryTests` ×3, `ComposedCaptureTests` ×2, and one each in `DuplicateResolutionTests`, `WorkMergeGroupParticipationTests`, `LibraryToleranceScanTests`, `EntryDetailAndMergeToleranceTests`, `CitedPatternResolutionTests`, `ValidatorFixtures`, `M5RepositoryTestSupport`, `MembershipReconcilerTests`) set memberships and citations directly. - **Reconcile.** Delete `V8PopulationPass.swift`, `pendingConversionPhases` and `populationPhases(tier:)` (`LibraryRepository.swift:185-196,280-287,551-553`) and the call at `:386`. `worksWithoutMembershipCount` (`LibraryDiagnostics.swift:346`) becomes the heal gate: `MembershipReconciler.run(heals: count > 0)`; `membershipHealRuns(tier:)` (`:565-567`) goes. `BackupArchiveProjection.swift:217-220`'s raw-column Req 3.6 guards read the blob.-- **Deletions.** `SiteRelationshipPopulationPass.swift` and its test; `validateStoreStrictly` (`+Bootstrap.swift:522-533`); `WorkMergeOutcome.workURL/identityEvidence/identityDisposition` (`ProjectionContract.swift:760-766`) and `WorkVariantUnionOutcome.workURL` (`WorkVariantUnion.swift:123-125`) with their 19 test readers (`WorkMergePlannerTests` ×9, `UnreadableURLRuleTests` ×4, `DuplicateResolutionTests` ×3, `MembershipColumnMirrorTests` ×2, `CrossSiteMergeTests` ×1) rewritten against `sites`.+- **Deletions.** `SiteRelationshipPopulationPass.swift` and its test (overruled by Q27 — only the test goes; the pass survives minus its `Work.site` loop); `validateStoreStrictly` (`+Bootstrap.swift:522-533`); `WorkMergeOutcome.workURL/identityEvidence/identityDisposition` (`ProjectionContract.swift:760-766`) and `WorkVariantUnionOutcome.workURL` (`WorkVariantUnion.swift:123-125`) with their 19 test readers (`WorkMergePlannerTests` ×9, `UnreadableURLRuleTests` ×4, `DuplicateResolutionTests` ×3, `MembershipColumnMirrorTests` ×2, `CrossSiteMergeTests` ×1) rewritten against `sites`. - **Tests.** `ModelContractTests`: `retainedColumns`, `retainedColumnsAreStillInTheSchema` and `retiredColumnsAreNamedNowhereElse` go, replaced by one pin that none of the dropped names is in `Schema(...).entities`. `MarkerGenerationEightTests` becomes the `"8"`→`"9"` suite: open with no pass, publish only after validate, a validation failure leaves `"8"` in place, both halves of the extension fork. `V8RecordedStoreFixture` — seeded through the frozen V8 snapshot the way `V7RecordedStoreFixture.swift:110-115` seeds through V7, populated on both sides of `Site.works`/`Work.site` and with every superseded column filled — replaces `V5/V6/V7RecordedStoreFixture`; its test asserts every live row and blob survives the stage. `V4RecordedStoreTests` stays as the below-floor refusal suite with its comment retargeted. `V5RecordedStoreTests`, `V6RecordedStoreTests`, `V7RecordedStoreTests`, `V8PopulationPassTests`, `MigratedStoreCitationRewriteTests`, `MembershipColumnMirrorTests` go. `V5CertificationPathTests`, `StoreMetadataTests`, `FrozenLibraryPathTests` (`:294-298`: `AsterismSchemaV8/V9`, `AsterismV9MigrationPlan`) re-seed and re-list. Performance: the two `withKnownIssue` blocks are `M4ScalePerformanceTests.swift:342-344` and `M4DuplicateScalePerformanceTests.swift:231-233`; every ceiling outside them stays. - **Docs.** `docs/agent-notes/schema-migration.md` "Current state" rewritten to V9 (plan, marker, "one reader" paragraph gone); `CLAUDE.md`'s `test-performance-m4` paragraph re-counted; `specs/multi-site-works/prerequisites.md` untouched. - **Out of scope.** Citing rules by UUID (T-2281); the read-time regime (T-2282); the archive format; the `duplicate-settling-pass` breach (T-2093); the rule `version` columns; any CloudKit schema change; new diagnosis kinds beyond the counters that exist.@@ -36,8 +36,9 @@ Follow the "Adding a schema version (V8 and later)" table in `docs/agent-notes/s - Risk: an Entry still nil-blob at upgrade loses its citation provenance (which rules parsed it, identity basis) — not its note, rating, URL, identity key or Work link — because the drop runs inside `ModelContainer.init`. Exposure is share-extension captures made after the last V8 app launch; the launch pass fills every other blob. | Mitigation: `prerequisites.md` orders the upgrade; the diagnosis counter and the basis↔citation mismatch report make a survivor visible; the 7/8 archive restores it. - Risk: dropping mirrored attributes under live CloudKit mirroring is unexercised. The CloudKit schema keeps the fields (development environment; server fields are never deleted) and a V8 device that keeps syncing writes them, plus nil-blob Entries from its extension. | Mitigation: the device install is the probe, the kept V8 build plus archives the rollback, and both devices update before either opens the library again (prerequisites.md, multi-site-works Q14).-- Risk: a lightweight stage removing an inverse pair (`Site.works` ↔ `Work.site`) alongside 35 attributes. | Mitigation: `V8RecordedStoreFixture` migrates a store populated on both sides in `make test-core` before any device sees the build.+- Risk: a lightweight stage removing an inverse pair (`Site.works` ↔ `Work.site`) alongside 36 attributes. | Mitigation: `V8RecordedStoreFixture` migrates a store populated on both sides in `make test-core` before any device sees the build. - Risk: `reconcile-noop-coherent` may not return to its 2 ms band even with the citation pass gone, since the heal walk was half the cost. | Mitigation: the heal gate; the requirement demands a re-measurement, not a number.+- Risk: `GroupOrdering.sortedWorkRows` now faults a relationship per Work (`Work.primaryMembership`), where the six columns it replaced were on the row already — and `sortedWorkRows` is on a path every write takes (Q18). The read is `min` rather than a sort and is taken once per Work per key, and a caller that also asks `authoredContent(of:types:)` faults an already-materialised array; but **no budget covers it**: `M4ScalePerformanceTests` measures capture, reconcile and consolidation, none of which asserts a ceiling on the ordering itself. | Mitigation: none beyond measurement — task 9's `make test-performance-m4` run reports the cost in the verification run rather than budgeting it, and a regression would show as drift in the capture-projection and reconcile arms that already have ceilings. - Assumption: every device is at marker `"8"` (owner, 2026-08-27); re-confirmed per device at install. ## Escalation Note
diff --git a/specs/drop-superseded-columns/tasks.md b/specs/drop-superseded-columns/tasks.mdindex 1471bae..2afcfe6 100644--- a/specs/drop-superseded-columns/tasks.md+++ b/specs/drop-superseded-columns/tasks.md@@ -8,7 +8,7 @@ references: ## Blob-only write paths (columns still present) -- [ ] 1. Every Entry citation is written to the blob directly and a nil blob reads as a reported default <!-- id:11q3h7q -->+- [x] 1. Every Entry citation is written to the blob directly and a nil blob reads as a reported default <!-- id:11q3h7q --> - Per smolspec.md 'Citation write path'. - Every writer listed there builds an EntryCitations and calls Entry.setCitations; - LegacyColumns.refreshCitations has no callers;@@ -18,7 +18,7 @@ references: - Verify: a new test captures an Entry and reads a non-nil blob; a nil-blob Entry reads without throwing and appears in the diagnosis; - MigratedStoreCitationRewriteTests is deleted (its subject, rewriteVersions, has no caller) and every citation/teaching suite passes under make test-core. -- [ ] 2. Membership and definition writers stop mirroring into the superseded columns <!-- id:11q3h7r -->+- [x] 2. Membership and definition writers stop mirroring into the superseded columns <!-- id:11q3h7r --> - Per smolspec.md 'Membership and definition mirrors'. - Every refreshMembership call is gone (Work.create/createCarrying write memberships only); - GroupOrdering reads memberships and blobs only, and a TitlePattern with nil definitionData orders under the constant key of decision_log.md Q11 and validates as unreadableTitlePattern;@@ -28,13 +28,13 @@ references: - GroupOrderingTests covers the nil-definition key; make test-core passes with no new warnings. - Blocked-by: 11q3h7q (Every Entry citation is written to the blob directly and a nil blob reads as a reported default) -- [ ] 3. The app opens marker "8" and "9", publishes "9" after validation, and the extension opens "9" only <!-- id:11q3h7s -->+- [x] 3. The app opens marker "8" and "9", publishes "9" after validation, and the extension opens "9" only <!-- id:11q3h7s --> - Per smolspec.md 'Markers' and decision_log.md Q3/Q9. extensionOpenableMarkerVersion is "9", appOpenableMarkerVersions is ["8","9"]; the "7" arm is replaced by an "8" arm that opens, validates, then publishes "9" with no data pass and no reconciler; a validation failure leaves "8" in place; validateMarkerContentForExtension refuses "8" with the existing 'Open Asterism to finish updating the library' message and anything else with the other. - Verify: MarkerGenerationEightTests becomes the "8"->"9" suite pinning that sequence, the failure that leaves the marker put, and both halves of the extension fork; - MarkerContractTests, V5CertificationPathTests and StoreMetadataTests pass re-seeded on a V7- or V8-shaped store. - Blocked-by: 11q3h7r (Membership and definition writers stop mirroring into the superseded columns) -- [ ] 4. Post-sync reconcile runs no conversion pass and heals memberships only when the diagnosis counts a Work without one <!-- id:11q3h7t -->+- [x] 4. Post-sync reconcile runs no conversion pass and heals memberships only when the diagnosis counts a Work without one <!-- id:11q3h7t --> - Per smolspec.md 'Reconcile' and decision_log.md Q10. - V8PopulationPass.swift, pendingConversionPhases, populationPhases(tier:) and membershipHealRuns(tier:) are deleted; reconcileAfterSync calls MembershipReconciler.run(heals: worksWithoutMembershipCount > 0); the dedupe phases still run on every tier; entriesWithoutCitationBlobCount stays as a report. - Verify: V8PopulationPassTests is deleted;@@ -45,7 +45,7 @@ references: ## Schema V9 -- [ ] 5. Schema V9 omits every superseded column and the migration plan is [V8, V9] <!-- id:11q3h7u -->+- [x] 5. Schema V9 omits every superseded column and the migration plan is [V8, V9] <!-- id:11q3h7u --> - Per smolspec.md 'Schema'. - AsterismSchemaV8.swift is a frozen snapshot with the dead columns, Site.works/Work.site and its @Relationship macros, public init() {}, no accessors; the live models are AsterismSchemaV9 with none of the columns listed in the requirements and no Site.works; typealiases retargeted; - AsterismV9MigrationPlan = [V8, V9], one .lightweight stage;@@ -55,7 +55,7 @@ references: - V4RecordedStoreTests still refuses a below-floor store with its comment retargeted; make test-core builds and passes. - Blocked-by: 11q3h7t (Post-sync reconcile runs no conversion pass and heals memberships only when the diagnosis counts a Work without one) -- [ ] 6. A populated V8 store migrates to V9 with every live row and blob intact <!-- id:11q3h7v -->+- [x] 6. A populated V8 store migrates to V9 with every live row and blob intact <!-- id:11q3h7v --> - Per smolspec.md 'Tests' and Risks. - V8RecordedStoreFixture seeds a store through the frozen V8 snapshot the way V7RecordedStoreFixture did through V7, populated on both sides of Site.works/Work.site and with every superseded column filled, plus memberships, citation and definition blobs, Characters and suppressions. - Verify: a V8RecordedStoreTests suite opens it through openForApp on marker "8", asserts the stage completes, every Work, membership, Entry field, blob, TitlePattern, URL rule, Site, Character and suppression survives, marker "9" is published, and the extension then opens it; the test runs under make test-core.@@ -63,19 +63,19 @@ references: ## Deletions and verification -- [ ] 7. The V7-shaped test facade and the dead strict gate are gone <!-- id:11q3h7w -->+- [x] 7. The V7-shaped test facade and the dead strict gate are gone <!-- id:11q3h7w --> - Per smolspec.md 'Deletions'. - WorkMergeOutcome.workURL/identityEvidence/identityDisposition and WorkVariantUnionOutcome.workURL are deleted and their 19 test readers assert against sites; validateStoreStrictly is deleted. - Verify: WorkMergePlannerTests, UnreadableURLRuleTests, DuplicateResolutionTests, CrossSiteMergeTests pass with the same assertions expressed per site; grep finds no reference to the four accessors or validateStoreStrictly. - Blocked-by: 11q3h7u (Schema V9 omits every superseded column and the migration plan is [V8, V9]) -- [ ] 8. A 7/8 archive exported by a V8 build imports into a V9 library and the archive suites are green <!-- id:11q3h7x -->+- [x] 8. A 7/8 archive exported by a V8 build imports into a V9 library and the archive suites are green <!-- id:11q3h7x --> - Per smolspec.md requirement on the 7/8 archive. - The wire format is byte-for-byte unchanged; golden fixtures are re-recorded only where the projection's read path changed, not the format. - Verify: BackupGoldenExportTests, BackupGroupRoundTripTests, BackupImportTransactionTests and BackupV7ArchiveTests pass; one test imports a 7/8 archive fixture recorded before this spec into a V9 library and validates clean. - Blocked-by: 11q3h7v (A populated V8 store migrates to V9 with every live row and blob intact) -- [ ] 9. The full-tier performance breaches are re-measured and their known-issue blocks retired or re-banded <!-- id:11q3h7y -->+- [x] 9. The full-tier performance breaches are re-measured and their known-issue blocks retired or re-banded <!-- id:11q3h7y --> - Per smolspec.md performance requirement and decision_log.md Q7/Q12. - Run make test-performance-m4 (host-only, safe) at least twice. - If reconcile-noop-coherent and duplicate-observation-pass are within their ceilings, remove the two withKnownIssue blocks (M4ScalePerformanceTests.swift:342-344, M4DuplicateScalePerformanceTests.swift:231-233) and their comments; otherwise re-band each with a comment naming the remaining cost.@@ -84,7 +84,7 @@ references: - Verify: the target exits 0 and the new band is asserted. - Blocked-by: 11q3h7v (A populated V8 store migrates to V9 with every live row and blob intact), 11q3h7w (The V7-shaped test facade and the dead strict gate are gone), 11q3h7x (A 7/8 archive exported by a V8 build imports into a V9 library and the archive suites are green) -- [ ] 10. Agent notes, CLAUDE.md and the specs overview describe V9 <!-- id:11q3h7z -->+- [x] 10. Agent notes, CLAUDE.md and the specs overview describe V9 <!-- id:11q3h7z --> - Per smolspec.md 'Docs'. docs/agent-notes/schema-migration.md 'Current state' describes plan [V8, V9], marker "9", no LegacyColumns and no population pass, with the history section noting what V9 retired; - CLAUDE.md's test-performance-m4 paragraph re-counts the known issues; specs/OVERVIEW.md gets the drop-superseded-columns row and section. - Verify: no doc still says the columns are retained or that V8PopulationPass runs; make verify-identity passes.
diff --git a/specs/drop-superseded-columns/verification-run.md b/specs/drop-superseded-columns/verification-run.mdnew file mode 100644index 0000000..33a5e18--- /dev/null+++ b/specs/drop-superseded-columns/verification-run.md@@ -0,0 +1,275 @@+# Verification Run: Drop Superseded Columns++Task 9's evidence, recorded here rather than in `tasks.md`, which `rune` owns.++**Date**: 2026-08-28+**Host**: the project machine, macOS 26, Apple Silicon. Host-only throughout — no+device target was run, and none may be: `make test-performance-m4-recent`,+`make install`, `make run` and `xcrun devicectl` all touch the owner's phone and+are the owner's step (`CLAUDE.md`).++**One run is not a baseline, and two are barely more.** `CLAUDE.md` says so and+the suites' own history says so louder: three consecutive release runs of+unchanged code once measured 0.7805 s, 1.2789 s and 0.7389 s on the same path.+Two runs were taken here — run A found the two assertions V9 invalidated, run B+confirmed the numbers with the code fixed — and where this document re-bands a+measurement it says the band rests on two runs of one machine and nothing else.+Nothing here is comparable to a device.++The comparison column throughout is+[`../multi-site-works/verification-run.md`](../multi-site-works/verification-run.md)+§7.5 (run 4, 2026-08-26), which is the last full recording.++---++## 1. What was run++| | Run A | Run B |+|---|---|---|+| Command | `make test-performance-m4` | `make test-performance-m4` |+| **Result** | **exit 2**, 27 tests in 5 suites, 10 issues (8 known) | **exit 0**, 27 tests in 5 suites, **8 known issues** |+| Test time | 1,098.5 s | see [§5](#5-run-b-the-confirming-run) |+| Configuration | `RUNS=1`, `CONTROLLED` unset (median asserted, p95 reported) | same |++Run A's two non-known issues are the subject of [§3](#3-two-assertions-v9-invalidated).+Both are assertions about a *shape* V9 removed, not measurements that drifted;+they were fixed in the suites and run B is the confirmation.++**The known-issue count falls from nine to eight.** The one that retires is+Req 10.1's observation pass. Eight remain: Req 10.1's settling pass, Req 5.4's+three capture-projection arms, Req 5.5's three diagnosis re-derivations, and the+full-tier no-op reconcile — which is 36× cheaper and still 3× its ceiling.++## 2. The two known-issue blocks task 9 was to re-measure++### 2.1 `reconcile-noop-coherent` — **kept, re-banded**++| | Median | Band | Bound |+|---|---|---|---|+| Before `multi-site-works` | 1.82–2.00 **ms** | three runs | 10 ms ceiling |+| `multi-site-works` (§7.5) | 1.073–1.091 s | two runs | breached ~108× |+| **This spec** | **0.0296–0.0302 s** | two runs, n=20 each, spread ≤ 1.12× | **breached 3.0×** |++**36× faster, and still out of band.** Both whole-library passes+`multi-site-works` put in front of `reconcileAfterSync` are gone:+`V8PopulationPass` is deleted with the columns it converted from, and+`MembershipReconciler.heal` runs only where the diagnosis counts a Work with no+membership (Q10), which a converged library never does.++What is left is ~30 ms, and the runs say what it is not: `reconcile-noop-arrival`+measured 0.0301 s and 0.0303 s and `duplicate-arrival-pass-gated` 0.0314 s and+0.0301 s, so every tier and every gate now costs the same thing. That thing is+the floor of a pass — the lock, a fresh `ModelContext`, `reconcileWorkLists`, the+Site phase with nothing to consolidate — plus `MembershipReconciler`'s **dedupe**+phases, which Q10 deliberately left unconditional and which fetch the 1,000+membership rows on every pass. The 10 ms ceiling was drawn over a library that+had no membership table.++So the block is **kept and its comment rewritten** to name that cost, per the+requirement's "if either stays out of band, its comment must name the remaining+cost". Closing it means gating the dedupe phases the way the heal is gated, which+is a design decision this spec does not take. **Where it goes:** the 30 ms+unconditional-dedupe cost is recorded as a follow-up comment on **T-2280**, and+it is a candidate research item for `specs/post-v8-convergence`. No new ticket+is opened for it here.++The floor outside the block comes down from 2 s to **100 ms** — see+[Q33](decision_log.md).++### 2.2 `duplicate-observation-pass` — **retired**++| | Median | Band | Bound |+|---|---|---|---|+| Before `multi-site-works` | 0.93–0.95 s | three runs | 2 s budget, met |+| `multi-site-works` (§7.5) | 2.687–2.727 s | two runs | breached 1.37× |+| **This spec** | **1.008–1.025 s** | two runs, n=10 each, spread ≤ 1.06× | **inside 2 s** |++`multi-site-works` §7.2 attributed this breach entirely to `reconcile-noop-coherent`+— this test times one full-tier `reconcileAfterSync`, so it paid that cost once+per timed pass — and said it would close when that one did. It did, by 1.7 s+against the 1.66 s the attribution predicted. **The attribution was right**,+which is worth recording: the same reasoning is what leaves 2.1's remaining 30 ms+attributed to the dedupe phases rather than guessed at.++`withKnownIssue` is deleted and the 2 s budget is asserted plainly again. The 4 s+regression floor under it goes with it — a floor looser than the budget beside it+asserts nothing ([Q33](decision_log.md)).++### 2.3 `duplicate-settling-pass` — kept, unchanged, per Q7++**7.347–7.411 s** (two runs, n=10 each) against a 2 s budget, down from+9.271–9.307 s. That is back at the 7.264–7.365 s band the 11 s ceiling was+drawn over, which is+what Q7 predicted: V9 recovers the headroom the mirror consumed and does nothing+about the breach itself, which predates V8 and is unattributed (T-2093). Budget,+ceiling and comment text are unchanged; only the history note is extended.++## 3. Two assertions V9 invalidated++Run A's two real failures, both of them assertions about a shape the change+removed rather than measurements that drifted.++* **`M4ScalePerformanceTests:372`, `arrival.median * 4 <= measured.median`.**+ It pinned the gap Decision 5 of `multi-site-works` opened by gating the two+ conversion passes off the arrival tier — 0.030 s against 1.07 s. With both+ passes deleted the tiers measure the same thing (0.0301 s against 0.0302 s) and+ the ratio fails on correct code. **Deleted, not re-scaled**+ ([Q34](decision_log.md)).+* **`M4DuplicateScalePerformanceTests:231`, "Known issue was not recorded".**+ §2.2's block, firing on a budget that is now met. **Deleted**, which is exactly+ what a `withKnownIssue` reports when the issue it names has closed.++Neither ceiling asserted outside a block was raised. Two were lowered and one was+deleted, all under [Q33](decision_log.md), because a floor drawn under a breach+that has closed refuses nothing.++## 4. Every other label++Run A's medians against `multi-site-works` §7.5 (run 4). Nothing here left a+budget or a ceiling.++| Measurement | This run | `multi-site-works` run 4 | Δ | Bound | Verdict |+|---|---|---|---|---|---|+| `open-coherent` | 0.743 s | 0.871 s | **−15%** | 1 s budget | in budget |+| `open-duplicateSiteRows` | 0.747 s | ~0.871 s | −14% | 1 s budget, ≤ 1.25× ratio | in budget, ratio 1.005× |+| `open-siteMissing` | 0.336 s | 0.453 s | **−26%** | 1 s budget | in budget |+| `open-duplicateIdentity` | 0.747 s | ~0.871 s | −14% | 1 s budget | in budget |+| `extension-open-and-validate` | 0.749 s | 0.866 s | **−14%** | 1 s budget | in budget |+| `store-level-validation` | 0.742 s | 0.873 s | **−15%** | 1 s budget | in budget |+| `record-counts-duplicate-free` | 0.232 s | 0.319 s | **−27%** | 3 s ceiling | in ceiling |+| `membership-reconcile-noop` | 0.380 s | 0.500 s | **−24%** | 800 ms ceiling | in ceiling |+| `membership-heal-full` | 1.704 s | 2.018 s | **−16%** | 5 s ceiling | in ceiling |+| `diagnosis-refresh-foreground` | 0.276 s | 0.369 s | **−25%** | 250 ms budget (known issue), 400 ms ceiling | breached 1.11×, in ceiling |+| `diagnosis-refresh-after-write` | 0.276 s | 0.370 s | −25% | as above | as above |+| `diagnosis-refresh-duplicateSiteRows` | 0.276 s | 0.370 s | −25% | as above | as above |+| `reconcile-worst-case-consolidation` | 40.076 s | 41.230 s | −2.8% | 55 s ceiling | in ceiling |+| `capture-projection-duplicateSiteRows` | 0.169 s | 0.181 s | −7% | 100 ms budget (known issue), 250 ms ceiling | breached 1.69×, in ceiling |+| `capture-projection-siteMissing` | 0.160 s | 0.171 s | −6% | as above | breached 1.60× |+| `capture-projection-duplicateIdentity` | 0.167 s | 0.180 s | −7% | as above | breached 1.67× |+| `capture-rule-application*` (4 arms) | ~0.07 ms | ~0.07 ms | — | 100 ms budget | in budget |+| `complete-preview-expanded` | 0.079 s | unchanged | — | 1 s budget | in budget |+| `complete-preview-collapsed` | 0.030 s | unchanged | — | 1 s budget | in budget |+| `edit-ack-*` (2 arms) | 6–15 µs | unchanged | — | 100 ms budget | in budget |+| `merge-destinations` | 1.371 s | 1.333 s | +2.8% | 3 s class ceiling | in ceiling |+| `works-snapshot-duplicate-free` | 1.704 s | 1.690 s | +0.8% | 3 s ceiling | in ceiling |+| `recent-coherent` | 0.885 s | 0.883 s | +0.2% | 2 s budget | in budget |+| `recent-duplicateSiteRows` | 0.886 s | 0.882 s | +0.5% | 2 s budget, ≤ 1.25× ratio | in budget, ratio 1.001× |+| `recent-publication-duplicate-free` | 1.029 s | 0.873 s | +18% | 2 s budget | in budget — **and it was noise**, see 4.2 |+| **`backup-projection-duplicate-free`** | **1.485 s** | 1.301 s | **+14%** | reported only | see 4.2 |++### 4.1 The broad fall: an Entry is 17 columns lighter++Ten labels fell 14–27%, and every one of them either opens the store, validates+it, or walks the whole `Entry` table. That is the shape of the change: V9 takes+35 columns off `Work`, `Entry` and `TitlePattern` — seventeen of them on the+largest table in the library — so every row is narrower to fetch and every+whole-table walk moves less. `open-siteMissing` (−26%), `record-counts` (−27%)+and the three `diagnosis-refresh` arms (−25%) are the clearest readings of it.++The three `diagnosis-refresh` arms are worth calling out separately: they are+1.11× their 250 ms budget now, against 1.48× before. Still breached, still known+issues, and the closest they have been to their budget since+`library-integrity-tolerance` recorded the breach.++### 4.2 The one that rose, and Q18 — and one that only looked like it++**This is the section the second run earned.** Run A had two labels up:+`backup-projection-duplicate-free` at +14% and `recent-publication-duplicate-free`+at +18%. Run B reproduces the first exactly (1.484 s against 1.485 s) and+**does not reproduce the second at all** (0.886 s, +1.5% on `multi-site-works`).++`recent-publication-duplicate-free` was the widest-spread label in run A —+1.21×, with a minimum of 0.882 s against its own 1.029 s median, on a path whose+neighbours ran at 1.02–1.06×. In run B it measures 0.886 s at a 1.03× spread,+which is its own run-A *minimum*. One run would have recorded an 18% regression+that is not there. This is exactly what `CLAUDE.md`'s warning is about and it is+recorded rather than quietly corrected.++So one label rose: **the export projection, +14% across both runs**+(1.485 s / 1.484 s against 1.301 s), at a 1.01–1.02× spread in each. **Q18 is+the candidate cause**: `GroupOrdering.sortedWorkRows` now reads each Work's+**primary membership** — one to-one relationship fault per Work — where six+columns on the row itself stood before. The smolspec's Risks section says in as+many words that no budget covers that fan-out and that task 9 reports it rather+than budgets it. This is the report.++The caution that survives run B: **the label is not a clean probe of+`sortedWorkRows`.** The export projection buckets both whole tables and builds a+digest per row, and `works-snapshot-duplicate-free`, which reads the same graph+with the same fan-out, moved −0.7% across the two runs while+`merge-destinations` moved +1.1%. Whatever the 14% is, it is not a uniform tax+on whole-library reads, and it is not visible on the two other labels that+should carry it if it were the fan-out alone.++The projection is on no budget at all — Q116 of `multi-site-works` reports it+rather than bounding it — so no bound is added here. **Where it goes:** the +14%+rise is recorded, with the 30 ms of [2.1](#21-reconcile-noop-coherent--kept-re-banded),+as a follow-up comment on **T-2280**, and it is a candidate research item for+`specs/post-v8-convergence`. No new ticket is opened for it here. If a later run+puts it or a budgeted read outside its class, `sortedWorkRows` is the first+place to look.++## 5. Run B, the confirming run++| | |+|---|---|+| Command | `make test-performance-m4` |+| **Result** | **Pass — exit 0**, 27 tests in 5 suites, **8 known issues** |+| Test time | 1,092.6 s (run A: 1,098.5 s) |+| Configuration | `RUNS=1`, `CONTROLLED` unset |++Every label, both runs. The two agree to within 3% on all but one, and that one+is [4.2](#42-the-one-that-rose-and-q18--and-one-that-only-looked-like-it).++| Measurement | Run A | Run B | Δ | Bound | Verdict |+|---|---|---|---|---|---|+| `reconcile-noop-coherent` | 0.0302 s | 0.0296 s | 2.0% | 10 ms ceiling (known issue), 100 ms floor | breached 3.0×, in floor |+| `reconcile-noop-arrival` | 0.0301 s | 0.0303 s | 0.6% | 100 ms floor | in floor |+| `duplicate-arrival-pass-gated` | 0.0314 s | 0.0301 s | 4.2% | 3 s class ceiling | in ceiling |+| `duplicate-observation-pass` | 1.025 s | 1.008 s | 1.7% | 2 s budget | **in budget, known issue retired** |+| `duplicate-settling-pass` | 7.411 s | 7.347 s | 0.9% | 2 s budget (known issue), 11 s ceiling | breached, in ceiling |+| `reconcile-worst-case-consolidation` | 40.076 s | 40.042 s | 0.1% | 55 s ceiling | in ceiling |+| `works-snapshot-duplicate-free` | 1.704 s | 1.678 s | 1.5% | 3 s ceiling | in ceiling |+| `backup-projection-duplicate-free` | 1.485 s | 1.484 s | 0.1% | reported | up 14%, see 4.2 |+| `record-counts-duplicate-free` | 0.232 s | 0.235 s | 1.5% | 3 s ceiling | in ceiling |+| `recent-publication-duplicate-free` | 1.029 s | **0.886 s** | **14%** | 2 s budget | run A was noise, see 4.2 |+| `merge-destinations` | 1.371 s | 1.347 s | 1.7% | 3 s class ceiling | in ceiling |+| `membership-reconcile-noop` | 0.380 s | 0.383 s | 0.8% | 800 ms ceiling | in ceiling |+| `membership-heal-full` | 1.704 s | 1.709 s | 0.3% | 5 s ceiling | in ceiling |+| `extension-open-and-validate` | 0.749 s | 0.752 s | 0.4% | 1 s budget | in budget |+| `store-level-validation` | 0.742 s | 0.747 s | 0.8% | 1 s budget | in budget |+| `open-coherent` | 0.743 s | 0.750 s | 1.0% | 1 s budget | in budget |+| `open-duplicateSiteRows` | 0.747 s | 0.750 s | 0.4% | 1 s budget, ≤ 1.25× ratio | ratio 1.000× |+| `open-siteMissing` | 0.336 s | 0.334 s | 0.6% | 1 s budget | in budget |+| `open-duplicateIdentity` | 0.747 s | 0.752 s | 0.7% | 1 s budget | in budget |+| `recent-coherent` | 0.885 s | 0.886 s | 0.1% | 2 s budget | in budget |+| `recent-duplicateSiteRows` | 0.886 s | 0.883 s | 0.4% | 2 s budget, ≤ 1.25× ratio | ratio 0.996× |+| `capture-projection-duplicateSiteRows` | 0.1685 s | 0.1698 s | 0.7% | 100 ms budget (known issue), 250 ms ceiling | breached 1.70× |+| `capture-projection-siteMissing` | 0.1600 s | 0.1602 s | 0.1% | as above | breached 1.60× |+| `capture-projection-duplicateIdentity` | 0.1667 s | 0.1689 s | 1.3% | as above | breached 1.69× |+| `capture-rule-application*` (4 arms) | ~0.07 ms | ~0.07 ms | — | 100 ms budget | in budget |+| `diagnosis-refresh-foreground` | 0.2764 s | 0.2753 s | 0.4% | 250 ms budget (known issue), 400 ms ceiling | breached 1.10× |+| `diagnosis-refresh-after-write` | 0.2758 s | 0.2755 s | 0.1% | as above | breached 1.10× |+| `diagnosis-refresh-duplicateSiteRows` | 0.2757 s | 0.2766 s | 0.3% | as above | breached 1.11× |+| `complete-preview-expanded` | 0.0794 s | 0.0789 s | 0.6% | 1 s budget | in budget |+| `complete-preview-collapsed` | 0.0300 s | 0.0298 s | 1.0% | 1 s budget | in budget |+| `edit-ack-*` (2 arms) | 6 / 15 µs | 6 / 15 µs | — | 100 ms budget | in budget |++**Eight known issues, and that is the whole of the change to the count.** The+observation pass retires; Req 10.1's settling pass, Req 5.4's three capture arms+and Req 5.5's three diagnosis re-derivations are unmoved as *breaches* (all+three families are 6–25% cheaper in absolute terms, none of them enough to reach+its budget), and the full-tier no-op reconcile stays at 3.0× a ceiling it was+108× over.++## 6. Not run++`make test-performance-m4-recent`, `make install`, `make run` and+`xcrun devicectl` were **not** run, and no agent may run them. Every figure here+is host-only, from a `swift test` of the `AsterismCore` package in release with+`-DASTERISM_PERFORMANCE_TESTING`. Nothing above says anything about what the+shipped app does on a phone. The device install is `prerequisites.md`'s step and+the owner's, and it is also the only CloudKit probe this change gets ([Q31](decision_log.md)).++`make test-performance-chunks` was not run either: the bulk write paths are+unchanged by this spec, and its own comment says to re-run it when they change.
diff --git a/specs/retire-migration-chain/library-graph-baseline.txt b/specs/retire-migration-chain/library-graph-baseline.txtindex 0f29b8e..1a07319 100644--- a/specs/retire-migration-chain/library-graph-baseline.txt+++ b/specs/retire-migration-chain/library-graph-baseline.txt@@ -2,27 +2,31 @@ # Produced by LibraryGraphBaselineTests. Regenerating this file from post-change # code defeats the comparison it exists for: it is re-recorded only when the # schema itself changes shape, and the format line says which shape it holds.-# format 3 is schema V8 (multi-site-works, T-2230): the two new tables and the-# two blob columns joined the dump, and every superseded column stayed in it —-# they are retained, unread, until V9 drops them (Decision 3).-format 3+# format 5 is schema V9 (drop-superseded-columns, T-2280): the columns+# format 4 recorded as present-but-unwritten are gone from the store, so the+# dump no longer has a field for them. A Work is its title, its notes and its+# work-type identity; its site presence is its WorkSiteMembership, an Entry's+# citations are its citationsData and a title rule is its definitionData. The+# Site.works inverse went with Work.site, which is why inverse-site lists one+# array fewer.+format 5 counts entries=5 works=1 sites=3 titlePatterns=1 urlRulePatterns=1 workTypes=3 memberships=1 distinctPairs=0-site hostname="alpha.test" displayName="Alpha Reader" modeRaw="untaught" urlIdentityRule=nil junkSuffixRule=nil-site hostname="beta.test" displayName="Beta Serials" modeRaw="taught" urlIdentityRule=nil junkSuffixRule=nil-site hostname="gamma.test" displayName="Gamma Articles" modeRaw="articles" urlIdentityRule=nil junkSuffixRule="{\"anchors\":[{\"offset\":0,\"origin\":\"end\"}],\"version\":1}"-titlePattern id=A1000000-0000-4000-8000-000000000001 site="beta.test" version=1 isActive=true createdAt=1800000000.000 formRaw="segment" segmentWorkAnchor=nil segmentIgnoredAnchors=nil phrasePrefix=nil phraseSeparator=nil phraseSuffix=nil fieldOrderRaw=nil trimPrefix=nil trimSuffix=nil chapterless=false definitionData="{\"definition\":{\"segment\":{\"ignored\":[{\"offset\":0,\"origin\":\"end\"}],\"work\":{\"length\":1,\"offset\":0,\"origin\":\"start\"}}},\"trimPrefix\":\"The \"}"+site hostname="alpha.test" displayName="Alpha Reader" modeRaw="untaught" junkSuffixRule=nil+site hostname="beta.test" displayName="Beta Serials" modeRaw="taught" junkSuffixRule=nil+site hostname="gamma.test" displayName="Gamma Articles" modeRaw="articles" junkSuffixRule="{\"anchors\":[{\"offset\":0,\"origin\":\"end\"}],\"version\":1}"+titlePattern id=A1000000-0000-4000-8000-000000000001 site="beta.test" version=1 isActive=true createdAt=1800000000.000 definitionData="{\"definition\":{\"segment\":{\"ignored\":[{\"offset\":0,\"origin\":\"end\"}],\"work\":{\"length\":1,\"offset\":0,\"origin\":\"start\"}}},\"trimPrefix\":\"The \"}" urlRulePattern id=A2000000-0000-4000-8000-000000000002 site="beta.test" version=1 isCurrent=true createdAt=1800000000.000 originRaw="readerTaught" definition="{\"work\":{\"locator\":{\"query\":{\"name\":\"sid\"}}}}" workType id=D0000001-0000-4000-8000-000000000001 name="novel" nameModifiedAt=0.000 stateRaw="active" stateModifiedAt=0.000 canonicalID=nil createdAt=0.000 modifiedAt=0.000 workType id=D0000002-0000-4000-8000-000000000002 name="webtoon" nameModifiedAt=0.000 stateRaw="active" stateModifiedAt=0.000 canonicalID=nil createdAt=0.000 modifiedAt=0.000 workType id=D0000003-0000-4000-8000-000000000003 name="article" nameModifiedAt=0.000 stateRaw="active" stateModifiedAt=0.000 canonicalID=nil createdAt=0.000 modifiedAt=0.000-work id=A3000000-0000-4000-8000-000000000003 site="beta.test" siteHostname="beta.test" displayTitle="Beta Serial" lastParsedTitle="Beta Serial" urlIdentity=nil urlIdentityStateRaw="none" urlIdentityRuleID=nil urlIdentityRuleVersion=nil workURLString=nil genericNotes="notes on the serial" typeRaw="other" workTypeID=A4000000-0000-4000-8000-000000000004 genreTags=["action","drama"] titleProvenanceRaw="parsed" createdAt=1800000000.000 modifiedAt=1800000000.000-entry id=B1000000-0000-4000-8000-000000000011 site="alpha.test" work=nil hostname="alpha.test" captureTitle="An Alpha Capture" captureTitleSourceRaw="host" rawURLString="https://alpha.test/read/1" canonicalURLString=nil entryIdentityKey="https://alpha.test/read/1" identityKeyVersion=1 conservativeIdentityKey="https://alpha.test/read/1" identityBasisRaw="conservative" identityURLRuleID=nil identityURLRuleVersion=nil identityNameTitleRuleID=nil identityNameTitleRuleVersion=nil urlWorkIdentity=nil urlWorkRuleID=nil urlWorkRuleVersion=nil chapterSequence=nil chapterSequenceRuleID=nil chapterSequenceRuleVersion=nil chapterTitle=nil chapterTitleProvenanceRaw="none" chapterPatternID=nil chapterPatternVersion=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 workAssignmentProvenanceRaw="none" workPatternID=nil workPatternVersion=nil workURLRuleID=nil workURLRuleVersion=nil workURLAssignmentKindRaw=nil intentionallyUnattached=false citationsData=nil-entry id=B2000000-0000-4000-8000-000000000012 site="beta.test" work=A3000000-0000-4000-8000-000000000003 hostname="beta.test" captureTitle="The Beta Serial :: Chapter One" captureTitleSourceRaw="host" rawURLString="https://beta.test/read/1" canonicalURLString=nil entryIdentityKey="https://beta.test/read/1" identityKeyVersion=1 conservativeIdentityKey="https://beta.test/read/1" identityBasisRaw="conservative" identityURLRuleID=nil identityURLRuleVersion=nil identityNameTitleRuleID=nil identityNameTitleRuleVersion=nil urlWorkIdentity=nil urlWorkRuleID=nil urlWorkRuleVersion=nil chapterSequence=nil chapterSequenceRuleID=nil chapterSequenceRuleVersion=nil chapterTitle="Chapter One" chapterTitleProvenanceRaw="manual" chapterPatternID=nil chapterPatternVersion=nil note="a reader's note" ratingRaw="up" firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 workAssignmentProvenanceRaw="manual" workPatternID=nil workPatternVersion=nil workURLRuleID=nil workURLRuleVersion=nil workURLAssignmentKindRaw=nil intentionallyUnattached=false citationsData=nil-entry id=B3000000-0000-4000-8000-000000000013 site="beta.test" work=A3000000-0000-4000-8000-000000000003 hostname="beta.test" captureTitle="The Beta Serial :: Chapter Two" captureTitleSourceRaw="host" rawURLString="https://beta.test/read/2" canonicalURLString=nil entryIdentityKey="https://beta.test/read/2" identityKeyVersion=1 conservativeIdentityKey="https://beta.test/read/2" identityBasisRaw="conservative" identityURLRuleID=nil identityURLRuleVersion=nil identityNameTitleRuleID=nil identityNameTitleRuleVersion=nil urlWorkIdentity=nil urlWorkRuleID=nil urlWorkRuleVersion=nil chapterSequence=nil chapterSequenceRuleID=nil chapterSequenceRuleVersion=nil chapterTitle="Chapter Two" chapterTitleProvenanceRaw="pattern" chapterPatternID=A1000000-0000-4000-8000-000000000001 chapterPatternVersion=1 note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 workAssignmentProvenanceRaw="manual" workPatternID=nil workPatternVersion=nil workURLRuleID=nil workURLRuleVersion=nil workURLAssignmentKindRaw=nil intentionallyUnattached=false citationsData=nil-entry id=B4000000-0000-4000-8000-000000000014 site="gamma.test" work=nil hostname="gamma.test" captureTitle="A Gamma Article" captureTitleSourceRaw="host" rawURLString="https://gamma.test/posts/1" canonicalURLString=nil entryIdentityKey="https://gamma.test/posts/1" identityKeyVersion=1 conservativeIdentityKey="https://gamma.test/posts/1" identityBasisRaw="conservative" identityURLRuleID=nil identityURLRuleVersion=nil identityNameTitleRuleID=nil identityNameTitleRuleVersion=nil urlWorkIdentity=nil urlWorkRuleID=nil urlWorkRuleVersion=nil chapterSequence=nil chapterSequenceRuleID=nil chapterSequenceRuleVersion=nil chapterTitle=nil chapterTitleProvenanceRaw="none" chapterPatternID=nil chapterPatternVersion=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 workAssignmentProvenanceRaw="none" workPatternID=nil workPatternVersion=nil workURLRuleID=nil workURLRuleVersion=nil workURLAssignmentKindRaw=nil intentionallyUnattached=true citationsData=nil-entry id=B5000000-0000-4000-8000-000000000015 site=nil work=nil hostname="orphan.test" captureTitle="An Orphaned Capture" captureTitleSourceRaw="host" rawURLString="https://orphan.test/read/1" canonicalURLString=nil entryIdentityKey="https://orphan.test/read/1" identityKeyVersion=1 conservativeIdentityKey="https://orphan.test/read/1" identityBasisRaw="conservative" identityURLRuleID=nil identityURLRuleVersion=nil identityNameTitleRuleID=nil identityNameTitleRuleVersion=nil urlWorkIdentity=nil urlWorkRuleID=nil urlWorkRuleVersion=nil chapterSequence=nil chapterSequenceRuleID=nil chapterSequenceRuleVersion=nil chapterTitle=nil chapterTitleProvenanceRaw="none" chapterPatternID=nil chapterPatternVersion=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 workAssignmentProvenanceRaw="none" workPatternID=nil workPatternVersion=nil workURLRuleID=nil workURLRuleVersion=nil workURLAssignmentKindRaw=nil intentionallyUnattached=false citationsData=nil+work id=A3000000-0000-4000-8000-000000000003 displayTitle="Beta Serial" lastParsedTitle="Beta Serial" genericNotes="notes on the serial" workTypeID=A4000000-0000-4000-8000-000000000004 genreTags=["action","drama"] titleProvenanceRaw="parsed" createdAt=1800000000.000 modifiedAt=1800000000.000+entry id=B1000000-0000-4000-8000-000000000011 site="alpha.test" work=nil hostname="alpha.test" captureTitle="An Alpha Capture" captureTitleSourceRaw="host" rawURLString="https://alpha.test/read/1" canonicalURLString=nil entryIdentityKey="https://alpha.test/read/1" conservativeIdentityKey="https://alpha.test/read/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"none\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"none\":{}}}"+entry id=B2000000-0000-4000-8000-000000000012 site="beta.test" work=A3000000-0000-4000-8000-000000000003 hostname="beta.test" captureTitle="The Beta Serial :: Chapter One" captureTitleSourceRaw="host" rawURLString="https://beta.test/read/1" canonicalURLString=nil entryIdentityKey="https://beta.test/read/1" conservativeIdentityKey="https://beta.test/read/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle="Chapter One" note="a reader's note" ratingRaw="up" firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"manual\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"manual\":{}}}"+entry id=B3000000-0000-4000-8000-000000000013 site="beta.test" work=A3000000-0000-4000-8000-000000000003 hostname="beta.test" captureTitle="The Beta Serial :: Chapter Two" captureTitleSourceRaw="host" rawURLString="https://beta.test/read/2" canonicalURLString=nil entryIdentityKey="https://beta.test/read/2" conservativeIdentityKey="https://beta.test/read/2" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle="Chapter Two" note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"pattern\",\"patternID\":\"A1000000-0000-4000-8000-000000000001\",\"patternVersion\":1},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"manual\":{}}}"+entry id=B4000000-0000-4000-8000-000000000014 site="gamma.test" work=nil hostname="gamma.test" captureTitle="A Gamma Article" captureTitleSourceRaw="host" rawURLString="https://gamma.test/posts/1" canonicalURLString=nil entryIdentityKey="https://gamma.test/posts/1" conservativeIdentityKey="https://gamma.test/posts/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=true citationsData="{\"chapterTitle\":{\"kind\":\"none\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"none\":{}}}"+entry id=B5000000-0000-4000-8000-000000000015 site=nil work=nil hostname="orphan.test" captureTitle="An Orphaned Capture" captureTitleSourceRaw="host" rawURLString="https://orphan.test/read/1" canonicalURLString=nil entryIdentityKey="https://orphan.test/read/1" conservativeIdentityKey="https://orphan.test/read/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"none\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"none\":{}}}" workSiteMembership id=A5000000-0000-4000-8000-000000000005 hostname="beta.test" createdAt=1800000000.000 urlIdentity=nil urlIdentityStateRaw="none" urlIdentityRuleID=nil workURLString=nil workID=A3000000-0000-4000-8000-000000000003 work=A3000000-0000-4000-8000-000000000003 site="beta.test"-inverse-site hostname="alpha.test" patterns=[] urlRules=[] entries=[B1000000-0000-4000-8000-000000000011] works=[] workMemberships=[]-inverse-site hostname="beta.test" patterns=[A1000000-0000-4000-8000-000000000001] urlRules=[A2000000-0000-4000-8000-000000000002] entries=[B2000000-0000-4000-8000-000000000012,B3000000-0000-4000-8000-000000000013] works=[A3000000-0000-4000-8000-000000000003] workMemberships=[A5000000-0000-4000-8000-000000000005]-inverse-site hostname="gamma.test" patterns=[] urlRules=[] entries=[B4000000-0000-4000-8000-000000000014] works=[] workMemberships=[]+inverse-site hostname="alpha.test" patterns=[] urlRules=[] entries=[B1000000-0000-4000-8000-000000000011] workMemberships=[]+inverse-site hostname="beta.test" patterns=[A1000000-0000-4000-8000-000000000001] urlRules=[A2000000-0000-4000-8000-000000000002] entries=[B2000000-0000-4000-8000-000000000012,B3000000-0000-4000-8000-000000000013] workMemberships=[A5000000-0000-4000-8000-000000000005]+inverse-site hostname="gamma.test" patterns=[] urlRules=[] entries=[B4000000-0000-4000-8000-000000000014] workMemberships=[] inverse-work id=A3000000-0000-4000-8000-000000000003 entries=[B2000000-0000-4000-8000-000000000012,B3000000-0000-4000-8000-000000000013] siteMemberships=[A5000000-0000-4000-8000-000000000005]
The stage is exercised host-only without cloudKitDatabase: .private (Q31). Rolling back to V8 requires wiping the store and importing the pre-V9 archive — installing V8 over V9 rolls nothing back. Read entriesWithoutCitationBlobCount before any post-V9 export (prerequisites.md).
The 30 ms unconditional membership dedupe on the full tier and the +14% backup-projection-duplicate-free rise from the Q18 fault are recorded as a comment on T-2280 and as post-v8-convergence research candidates, with no ticket.
One exit-1 run on a comments-only commit, two clean runs after; likely one of the two live Apple Intelligence calls. Every later run (including after f3e2565) passed.
A nil-blob Entry beside a live Work link no longer throws invalidStateTuple; bytes that decode to .none beside a Work still do. This is a production behaviour change introduced by review, pinned by nilCitationBlobKeepsItsWorkLink.